fix(goal): quiet waits and unify fleet NG ownership (#56, #57, #58)

This commit is contained in:
Dewey
2026-09-06 04:07:09 -05:00
parent 7345f330fc
commit 9a5fbdbda7
14 changed files with 892 additions and 26 deletions
+17 -8
View File
@@ -25,7 +25,7 @@ import {
type GoalState,
type ProgressDetails,
} from "./lib/state.ts";
import { decideSettle } from "./lib/settle.ts";
import { decideSettle, hasUnresolvedWait } from "./lib/settle.ts";
import { parseGoalCommand } from "./lib/parse.ts";
import { loadState, saveState, resolveStatePath } from "./lib/store.ts";
import { goalPolicyDenialCode, validateAttestedGoalReport } from "./lib/executive-update.ts";
@@ -53,6 +53,8 @@ export default function goalExtension(pi: ExtensionAPI) {
let waitTimer: ReturnType<typeof setTimeout> | undefined;
let armedDeadline: string | undefined;
let deadlinePending = false;
// Process-local queue latch. Only observing this check's start releases it.
let pendingCheck: string | undefined;
function cancelWaitTimer(): void {
if (waitTimer !== undefined) clearTimeout(waitTimer);
@@ -172,7 +174,7 @@ export default function goalExtension(pi: ExtensionAPI) {
/** Inject one check prompt: immediately when idle, queued behind a running agent otherwise. */
function beginCheck(ctx: ExtensionContext): void {
if (dead || state.status !== "active") return;
if (dead || state.status !== "active" || hasUnresolvedWait(state) || pendingCheck) return;
// The loop needs a persistent session. Print mode exits after the prompt
// pipeline completes; a forced turn there races teardown (measured). The
// goal directive (before_agent_start) still applies in print mode.
@@ -180,7 +182,8 @@ export default function goalExtension(pi: ExtensionAPI) {
state = recordCheckInjected(state);
if (!persist(ctx)) return;
renderWidget(ctx);
const msg = checkPrompt();
const msg = `${checkPrompt()} [goal dispatch ${randomUUID()}]`;
pendingCheck = msg;
if (ctx.isIdle()) {
try {
pi.sendUserMessage(msg);
@@ -216,6 +219,7 @@ export default function goalExtension(pi: ExtensionAPI) {
function pauseWith(ctx: ExtensionContext, reason: string, notice: string): void {
state = pauseGoal(state, reason);
pendingCheck = undefined;
persist(ctx);
renderWidget(ctx);
safeNotify(ctx, `goal: ${notice}`, "warning");
@@ -247,16 +251,18 @@ export default function goalExtension(pi: ExtensionAPI) {
return;
}
state = clearGoal(state);
pendingCheck = undefined;
if (!persist(ctx)) return;
renderWidget(ctx);
safeNotify(ctx, "goal: cleared — normal operation", "info");
return;
case "resume":
if (state.status !== "paused" && state.status !== "blocked") {
safeNotify(ctx, "goal: nothing paused to resume", "warning");
if (state.status !== "paused" && state.status !== "blocked" && !hasUnresolvedWait(state)) {
safeNotify(ctx, "goal: nothing paused or waiting to resume", "warning");
return;
}
state = resumeGoal(state);
pendingCheck = undefined;
if (!persist(ctx)) return;
renderWidget(ctx);
safeNotify(ctx, "goal: resumed", "info");
@@ -265,6 +271,7 @@ export default function goalExtension(pi: ExtensionAPI) {
case "set": {
const replacing = state.status !== "none";
state = setGoal(state, cmd.text, cmd.max, cmd.waitTimeoutSeconds);
pendingCheck = undefined;
if (!persist(ctx)) return;
renderWidget(ctx);
safeNotify(
@@ -414,7 +421,7 @@ export default function goalExtension(pi: ExtensionAPI) {
: outcome.classification === "waiting"
? (state.waitTimeoutSeconds && ctx.mode !== "print"
? `Waiting recorded. Automatic goal checks are suspended until a relevant incoming message or the deadline at ${new Date(state.activeWait!.deadlineAt!).toISOString()}. The extension owns this single deadline wake; do not add timers or report unchanged status. Checkpoint and yield now.`
: (ctx.mode === "print" ? "Waiting recorded. Print mode cannot schedule a wake; resume this goal in a persistent session." : `Recorded explicit waiting state (${outcome.reason}). Keep the approved watch or next-check condition active.`))
: (ctx.mode === "print" ? "Waiting recorded. Print mode cannot schedule a wake; resume this goal in a persistent session." : "Waiting recorded. Automatic goal checks are suspended. No deadline timer is armed. Keep the existing message/watch delivery path; nextCheck is descriptive, not executable. Reconcile on relevant input or explicit /goal resume. Do not poll or report unchanged status. Checkpoint and yield now."))
: `No new progress recorded (${outcome.reason}). No-progress count is ${state.noProgressReports}/${state.maxNoProgressReports}; take the next safe action before reporting again.`;
return {
content: [{ type: "text", text }],
@@ -426,6 +433,7 @@ export default function goalExtension(pi: ExtensionAPI) {
// ---------- loop wiring ----------
pi.on("before_agent_start", async (event, ctx) => {
if (pendingCheck && event.prompt.includes(pendingCheck)) pendingCheck = undefined;
if (state.status !== "active") {
if (event.prompt.startsWith("[goal check ")) {
return { systemPrompt: `${event.systemPrompt}\nThis queued goal check is obsolete: the goal is paused or cleared. Do not execute it or call goal_report.` };
@@ -438,7 +446,7 @@ export default function goalExtension(pi: ExtensionAPI) {
}
const waitingDirective = state.waitTimeoutSeconds && ctx.mode !== "print"
? " Bounded waits enabled: report in_progress with progress.kind=wait and a concrete nextCheck when no ready work remains. The extension suspends checks and supplies one deadline wake per goal/resume. Do not create a duplicate timer, sleep loop, or heartbeat. On unrelated incoming messages, keep the wait; on a relevant event verify the dependency. After the deadline wake, an unresolved wait pauses."
: "";
: " Accepted waits suspend automatic goal checks without a deadline timer. Incoming messages do not clear the wait by themselves: on unrelated input preserve it; on relevant input verify the dependency before reporting progress. Do not poll, add timers, or report unchanged status. The operator can use /goal resume to reconcile once.";
const directive =
`[goal] ACTIVE OPERATOR GOAL: ${state.text}\n` +
`Work toward this goal. Before reporting, run the ms-proactive-agent loop: reconcile records, perform the next authorized ready task, and verify its result. If no task can proceed, report a concrete wait or blocker. This extension owns goal lifecycle; use skills for task tracking, not a second goal loop. ` +
@@ -478,6 +486,7 @@ export default function goalExtension(pi: ExtensionAPI) {
// Teardown guard: after session replacement/reload/quit, stop touching ctx.
pi.on("session_shutdown", async () => {
dead = true;
pendingCheck = undefined;
cancelWaitTimer();
});
@@ -492,7 +501,7 @@ export default function goalExtension(pi: ExtensionAPI) {
safeNotify(ctx, `goal: active — ${truncate(state.text, 60)}`, "info");
// Durability (Q7b): a fresh session with an active goal re-engages the
// loop immediately — no prior turn exists to emit agent_settled.
if (!(state.waitTimeoutSeconds && state.activeWait && !state.activeWait.wakeSent)) beginCheck(ctx);
if (!hasUnresolvedWait(state)) beginCheck(ctx);
} else if (state.status === "paused" || state.status === "blocked") {
safeNotify(ctx, "goal: paused — /goal resume to continue", "info");
}