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
+25 -9
View File
@@ -14,7 +14,7 @@ Requirements record: `docs/PRD.md` in mosaic-brain (issue #52). Design locked wi
/goal <text> set (or replace, with a notify) the active goal
/goal --max N <text> set with a non-default run limit (default 25)
/goal stop pause the loop, goal retained
/goal resume continue a paused goal (kicks a check turn)
/goal resume reconcile a paused or waiting goal (kicks one check turn)
/goal clear remove the goal entirely
```
@@ -24,7 +24,7 @@ Requirements record: `docs/PRD.md` in mosaic-brain (issue #52). Design locked wi
1. `before_agent_start` appends the active goal and reporting instructions to the system
prompt every turn, so the goal survives context growth and compaction.
2. When the agent settles without the goal being satisfied, the extension injects a check
2. When the agent settles with ready work and no unresolved wait, the extension injects a check
prompt (`sendUserMessage`), forcing the next turn: the check<->proceed loop. Before reporting,
follow the continuation loop in `skills-local/ms-proactive-agent/SKILL.md`.
3. The agent reports via `goal_report`, the loop's only exit:
@@ -87,15 +87,26 @@ The suite includes pure state, persistence, incarnation fencing, exact report-on
duplicate evidence, legitimate wait, headless extension-runtime, and enforcement-neutralization
red controls.
## Bounded waits (operator opt-in)
## Quiet waits and optional deadlines
Use `/goal --wait-timeout 60 <goal text>` to suspend automatic checks during a
reported wait. The flag accepts 1086400 seconds and combines with `--max N`.
Omitting it preserves the previous wait-loop behavior. Existing running sessions
must `/reload` to load this patch; do not reload unrelated seats for a trial.
Every accepted wait suspends automatic checks, including untimed waits. No
cooldown or model polling is used. A next-check condition describes the dependency;
it is not executed as code, and the extension does not register a watch for it.
Existing external message/watch delivery or explicit `/goal resume` can start
reconciliation. Incoming messages alone do not clear the wait. The model must
verify the dependency and report substantive progress or completion. Unrelated
input preserves the wait. Explicit resume clears the old wait; another unresolved
wait report suspends checks again. Paused and blocked goals still require explicit
operator resume, not merely an incoming message.
Use `/goal --wait-timeout 60 <goal text>` to add one deadline wake to a wait.
The flag accepts 1086400 seconds and combines with `--max N`. Without it,
no deadline timer is armed. Existing running sessions require a controlled reload
after an approved deployment; do not reload unrelated seats for a trial.
A valid `goal_report` with `status: "in_progress"`, `progress.kind: "wait"`, an
owner, and a watch id or concrete `nextCheck` persists the deadline and yields.
owner, and a watch id or concrete `nextCheck` persists the wait and yields.
A deadline is persisted only when the operator configured one.
There are no model heartbeats during that wait. Repeated wait reports preserve
its original deadline. A substantive progress report clears the wait. The
extension neither evaluates arbitrary shell conditions nor sends messages to
@@ -124,6 +135,11 @@ Late reports cannot settle a paused goal. Reports also refuse if the goal change
while an asynchronous policy check is running. State snapshots use atomic file
replacement; failed saves stop continuation instead of claiming a saved result.
The tests exercise unchanged waits, deadline dispatch and observation, unresolved
An in-memory pending-check guard prevents duplicate settle/startup events from
queuing the same check before its matching start is observed. It does not cancel
messages already queued by Pi, span independent extension instances, or supply a
process supervisor. Wait state itself persists across same-incarnation reloads.
The tests exercise timed and untimed waits, deadline dispatch and observation, unresolved
pause, compaction, reload, cancellation, print mode, malformed persisted waits,
and actual filesystem write failure. This is separate from live seat acceptance.
+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");
}
+6 -1
View File
@@ -5,6 +5,11 @@ import { checkLimitReached, type GoalState } from "./state.ts";
export type SettleDecision = { action: "inject" } | { action: "wait" } | { action: "pause"; reason: string };
/** An untimed wait never spends model tokens on automatic reconciliation. */
export function hasUnresolvedWait(state: GoalState): boolean {
return !!state.activeWait && (!state.waitTimeoutSeconds || !state.activeWait.wakeSent);
}
/**
* Decide what the loop does when the agent settles while a goal is active.
* stopReason comes from the last assistant message ("stop", "toolUse",
@@ -17,7 +22,7 @@ export function decideSettle(state: GoalState, stopReason: string | undefined):
if (stopReason === "error") {
return { action: "pause", reason: "paused: run error" };
}
if (state.waitTimeoutSeconds && state.activeWait && !state.activeWait.wakeSent) {
if (hasUnresolvedWait(state)) {
return { action: "wait" };
}
if (checkLimitReached(state)) {
+4 -3
View File
@@ -77,7 +77,7 @@ export interface GoalState {
wakeDispatchedAt?: number;
wakeObserved?: boolean;
};
/** Operator opt-in: suspend waits, with one deadline wake per goal/resume. */
/** Operator opt-in: add one deadline wake to a suspended wait per goal/resume. */
waitTimeoutSeconds?: number;
waitWakeUsed?: boolean;
setAt: string;
@@ -144,7 +144,8 @@ export function blockGoal(state: GoalState, reason: string): GoalState {
}
export function resumeGoal(state: GoalState): GoalState {
if (state.status !== "paused" && state.status !== "blocked") return state;
if (state.status !== "paused" && state.status !== "blocked" &&
!(state.status === "active" && state.activeWait)) return state;
return {
...state,
status: "active",
@@ -152,7 +153,7 @@ export function resumeGoal(state: GoalState): GoalState {
noProgressReports: 0,
workEventSinceReport: false,
pausedReason: undefined,
activeWait: state.waitTimeoutSeconds ? undefined : state.activeWait,
activeWait: undefined,
waitWakeUsed: state.waitTimeoutSeconds ? false : state.waitWakeUsed,
};
}
+1 -1
View File
@@ -23,5 +23,5 @@ test("timeout input is bounded and opt-in never leaks to another goal", () => {
const opted = setGoal(initialState(), "one", undefined, 60);
assert.equal(setGoal(opted, "two").waitTimeoutSeconds, undefined);
const legacyWait = recordInProgressReport(setGoal(initialState(), "legacy"), wait).state;
assert.equal(decideSettle(legacyWait, "stop").action, "inject");
assert.equal(decideSettle(legacyWait, "stop").action, "wait");
});
+122 -1
View File
@@ -51,6 +51,7 @@ async function createHeadlessHarness(root: string, incarnation: string) {
const commands = new Map<string, any>();
const tools = new Map<string, any>();
const sent: SentMessage[] = [];
const observedChecks = new Set<string>();
const notifications: Array<{ message: string; level: string }> = [];
const widgets: unknown[] = [];
const statuses: string[] = [];
@@ -70,7 +71,18 @@ async function createHeadlessHarness(root: string, incarnation: string) {
},
registerShortcut(key: string, shortcut: any) { shortcuts.set(key, shortcut); },
registerTool(tool: any) {
tools.set(tool.name, tool);
// Reports execute inside a model turn. Deliver its queued check's start
// rather than bypassing before_agent_start as the old fixture did.
tools.set(tool.name, { ...tool, execute: async (...args: any[]) => {
const queued = sent.at(-1)?.content;
if (queued && !observedChecks.has(queued)) {
observedChecks.add(queued);
for (const handler of handlers.get("before_agent_start") ?? []) {
await handler({ prompt: queued, systemPrompt: "base" }, ctx);
}
}
return tool.execute(...args);
} });
},
sendUserMessage(content: string, options?: SentMessage["options"]) {
sent.push({ content, options });
@@ -113,6 +125,7 @@ async function createHeadlessHarness(root: string, incarnation: string) {
idle = value;
},
async emit(name: string, event: any = { type: name }) {
if (name === "before_agent_start") observedChecks.add(event.prompt);
for (const handler of handlers.get(name) ?? []) await handler(event, ctx);
},
state() {
@@ -324,6 +337,114 @@ const waitReport = {
nextAction: "Read input and finish output", nextCheck: "input file available" },
};
test("queued check latch deduplicates lifecycle events until the matching start", async () => {
const dir = mkdtempSync(join(tmpdir(), "goal-check-latch-"));
const harness = await createHeadlessHarness(dir, "check-latch");
try {
await harness.commands.get("goal").handler("ready work", harness.ctx);
const first = harness.sent[0].content;
for (let i = 0; i < 10; i++) {
await harness.emit("session_start");
await harness.emit("agent_settled");
}
assert.equal(harness.sent.length, 1);
await harness.emit("before_agent_start", { prompt: "unrelated incoming question", systemPrompt: "base" });
await harness.emit("agent_settled");
assert.equal(harness.sent.length, 1, "unrelated input does not release a pending check");
await harness.emit("before_agent_start", { prompt: first, systemPrompt: "base" });
await harness.emit("agent_settled");
assert.equal(harness.sent.length, 2, "a completed check can continue ready work");
await harness.emit("before_agent_start", { prompt: first, systemPrompt: "base" });
await harness.emit("agent_settled");
assert.equal(harness.sent.length, 2, "duplicate old start cannot release a newer check");
await harness.commands.get("goal").handler("replacement", harness.ctx);
assert.equal(harness.sent.length, 3, "explicit replacement may schedule its new goal");
await harness.commands.get("goal").handler("stop", harness.ctx);
await harness.commands.get("goal").handler("resume", harness.ctx);
assert.equal(harness.sent.length, 4);
} finally {
await harness.emit("session_shutdown"); harness.restoreEnv(); rmSync(dir, { recursive: true, force: true });
}
});
test("untimed wait: settle and duplicate reports never inject a heartbeat", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 100000 });
const dir = mkdtempSync(join(tmpdir(), "goal-untimed-"));
const harness = await createHeadlessHarness(dir, "untimed");
try {
await harness.commands.get("goal").handler("wait for coordinator", harness.ctx);
const report = harness.tools.get("goal_report");
await report.execute("wait", waitReport, undefined, undefined, harness.ctx);
const before = harness.sent.length;
for (let i = 0; i < 100; i++) {
await harness.emit("agent_settled");
await report.execute(`repeat-${i}`, waitReport, undefined, undefined, harness.ctx);
}
t.mock.timers.tick(7 * 86400000);
assert.equal(harness.sent.length, before, "accepted waits must not generate model turns");
assert.equal(harness.state().checks, 0);
assert.equal(harness.state().noProgressReports, 0);
assert.equal(harness.state().activeWait.deadlineAt, undefined);
} finally {
await harness.emit("session_shutdown"); harness.restoreEnv(); rmSync(dir, { recursive: true, force: true });
}
});
test("untimed wait: reload, unrelated input and tool work preserve suspension", async () => {
const dir = mkdtempSync(join(tmpdir(), "goal-untimed-reload-"));
const harness = await createHeadlessHarness(dir, "untimed-reload");
try {
await harness.commands.get("goal").handler("wait for coordinator", harness.ctx);
const report = harness.tools.get("goal_report");
await report.execute("wait", waitReport, undefined, undefined, harness.ctx);
const wait = harness.state().activeWait;
const before = harness.sent.length;
await harness.emit("session_shutdown");
await harness.emit("session_start");
await harness.emit("session_start");
assert.equal(harness.sent.length, before, "startup must preserve even an untimed wait");
await harness.emit("before_agent_start", { prompt: "unrelated question", systemPrompt: "base" });
await harness.emit("tool_result", { toolName: "read", isError: false });
await harness.emit("agent_settled");
assert.deepEqual(harness.state().activeWait, wait);
assert.equal(harness.sent.length, before);
await harness.emit("before_agent_start", { prompt: "dependency result arrived", systemPrompt: "base" });
await report.execute("progress", { status: "in_progress", evidence: "dependency verified", progress: { ...waitReport.progress, kind: "measurement", lastMeasurement: "verified input" } }, undefined, undefined, harness.ctx);
assert.equal(harness.state().activeWait, undefined);
await harness.emit("agent_settled");
assert.equal(harness.sent.length, before + 1, "verified progress allows continuation");
} finally {
await harness.emit("session_shutdown"); harness.restoreEnv(); rmSync(dir, { recursive: true, force: true });
}
});
test("untimed wait: explicit resume reconciles once, paused waits remain owner-controlled", async () => {
const dir = mkdtempSync(join(tmpdir(), "goal-untimed-resume-"));
const harness = await createHeadlessHarness(dir, "untimed-resume");
try {
const goal = harness.commands.get("goal");
const report = harness.tools.get("goal_report");
await goal.handler("wait for coordinator", harness.ctx);
await report.execute("wait", waitReport, undefined, undefined, harness.ctx);
await goal.handler("resume", harness.ctx);
assert.equal(harness.sent.length, 2, "resume must work for an active waiting goal");
assert.equal(harness.state().activeWait, undefined);
await report.execute("still-waiting", waitReport, undefined, undefined, harness.ctx);
await harness.emit("agent_settled");
assert.equal(harness.sent.length, 2);
await goal.handler("stop", harness.ctx);
await harness.emit("before_agent_start", { prompt: "dependency result", systemPrompt: "base" });
await harness.emit("agent_settled");
assert.equal(harness.state().status, "paused");
assert.equal(harness.sent.length, 2);
await goal.handler("resume", harness.ctx);
assert.equal(harness.sent.length, 3);
assert.equal(harness.state().activeWait, undefined);
} finally {
await harness.emit("session_shutdown"); harness.restoreEnv(); rmSync(dir, { recursive: true, force: true });
}
});
test("quiet wait: no heartbeats, one deadline wake, unresolved dependency pauses", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 100000 });
const dir = mkdtempSync(join(tmpdir(), "goal-quiet-runtime-"));