From 2df26221d80c13a22cf6e15a1b87a84cdf4b60cc Mon Sep 17 00:00:00 2001 From: Spencer Fuller Date: Sun, 6 Sep 2026 22:15:13 -0500 Subject: [PATCH 1/2] fix: bound task-deferred goal continuation A Task child session that never reports a terminal state, or whose terminal result is never reconciled by an orchestrator turn, could defer goal continuation forever. Bound it with a `max_task_block_seconds` ceiling (default 900, `0` to disable), measured from `runningSince` for a listed child and from `terminalAt` for an unreconciled terminal one. `markTerminal` now carries the original `terminalAt` when it re-marks the same still-unreconciled terminal state, along with the assistant marker captured at that moment. `refreshLiveChildren` re-marks a listed idle child on every poll, so rewriting the timestamp each time meant the terminal branch of the ceiling could never fire for the case it exists to bound. The deferral also validates the goal before re-arming. The retry runs at 1 Hz and writes nothing to the goal, so a goal closed, cleared, or paused while a child still blocks would otherwise keep polling until the ceiling - and forever with `max_task_block_seconds: 0`. Rebased onto b7e185c (#46). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MLdQ88tCqSgfEsaGR978UF --- README.md | 6 +- dist/server.js | 58 ++++++++++--- src/server.ts | 105 ++++++++++++++++++++--- test/server-v2.test.ts | 64 +++++++++++++- test/server.test.ts | 184 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 388 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 727e1ee..3a83b2a 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,7 @@ In OpenCode 1, server options use the package-and-options tuple in `opencode.jso "max_auto_turns": 25, "min_continue_interval_seconds": 3, "max_turn_time": 300, + "max_task_block_seconds": 900, "max_prompt_failures": 3, "default_token_budget": 200000, "max_goal_duration_seconds": 1800, @@ -157,7 +158,8 @@ In OpenCode 2, use the plugin object form instead: Defaults: - `auto_continue`: `true` -- `defer_while_tasks_active`: `true`; when enabled, goal auto-continuation waits for active OpenCode Task child sessions and their orchestrator reconciliation before sending the next goal prompt. +- `defer_while_tasks_active`: `true`; when enabled, goal auto-continuation waits for active OpenCode Task child sessions and their orchestrator reconciliation before sending the next goal prompt. A deferral re-checks child sessions on a short timer, so a goal deferred by a task never depends on a further idle event to resume. +- `max_task_block_seconds`: `900`; wall-clock ceiling on how long a single Task child session may defer goal continuation. A child that stays listed but never reports a terminal state, or a terminal child whose result is never reconciled, stops blocking once the ceiling passes. Set a smaller value for shorter subagents, `0` to remove the ceiling, or disable deferral entirely with `defer_while_tasks_active: false`. - `max_auto_turns`: `25` - `min_continue_interval_seconds`: `3` - Fast V2 executions that finish inside this interval schedule a delayed continuation; they do not require another user message to wake up. @@ -291,6 +293,6 @@ OpenCode plugin modules are target-specific. This package exports separate modul } ``` -Codex goal mode has deeper runtime integration for thread lifecycle control. This plugin implements the same workflow using OpenCode plugin hooks. Token usage is read from OpenCode step-finish usage when available and falls back to message token metadata or text estimation when exact usage is unavailable. Continuation is driven by V2 `session.execution.succeeded` events and legacy `session.idle` / `session.status` idle notifications, never by intermediate model-step completion. V2 execution starts arm busy tracking; native `session.retry.scheduled` events cancel plugin recovery while OpenCode retries. Terminal execution transport failures use bounded recovery, while interruptions (user, shutdown, or superseded) cancel local timers without starting another turn or charging a prompt failure. Interrupted or non-transport-failed executions remain suppressed until the host starts a new execution; this does not change the persisted goal status. Use `/pause_goal` for a durable pause. Each V2 plugin instance handles goal events only for its own location while still observing cross-location child task lifecycles. The optional `max_turn_time` watchdog can retry one goal continuation prompt when a model turn remains busy, without consuming the goal's auto-turn or no-progress budgets; recognized transport failures do count toward the prompt-failure ceiling. By default, continuation is deferred while OpenCode Task child sessions are active or their terminal result still needs an orchestrator turn. During compaction, the plugin disables OpenCode's generic synthetic auto-continue while an active goal exists so the goal-specific continuation prompt remains authoritative. +Codex goal mode has deeper runtime integration for thread lifecycle control. This plugin implements the same workflow using OpenCode plugin hooks. Token usage is read from OpenCode step-finish usage when available and falls back to message token metadata or text estimation when exact usage is unavailable. Continuation is driven by V2 `session.execution.succeeded` events and legacy `session.idle` / `session.status` idle notifications, never by intermediate model-step completion. V2 execution starts arm busy tracking; native `session.retry.scheduled` events cancel plugin recovery while OpenCode retries. Terminal execution transport failures use bounded recovery, while interruptions (user, shutdown, or superseded) cancel local timers without starting another turn or charging a prompt failure. Interrupted or non-transport-failed executions remain suppressed until the host starts a new execution; this does not change the persisted goal status. Use `/pause_goal` for a durable pause. Each V2 plugin instance handles goal events only for its own location while still observing cross-location child task lifecycles. The optional `max_turn_time` watchdog can retry one goal continuation prompt when a model turn remains busy, without consuming the goal's auto-turn or no-progress budgets; recognized transport failures do count toward the prompt-failure ceiling. By default, continuation is deferred while OpenCode Task child sessions are active or their terminal result still needs an orchestrator turn, bounded by the `max_task_block_seconds` ceiling so an unobservable child cannot stall a goal indefinitely. During compaction, the plugin disables OpenCode's generic synthetic auto-continue while an active goal exists so the goal-specific continuation prompt remains authoritative. The goal sidebar shows the current status, elapsed time, token usage, auto-continue count, latest checkpoint, latest status message, stop reason, and objective when a goal is active, paused, or safety-limited. Closed goals remain visible briefly through the latest tool state as achieved or unmet. diff --git a/dist/server.js b/dist/server.js index 8bf3a49..375da65 100644 --- a/dist/server.js +++ b/dist/server.js @@ -1254,6 +1254,8 @@ var DEFAULT_COMMAND_NAME = "goal"; var DEFAULT_RESTRICTED_AGENTS = ["plan"]; var TASK_SETTLE_DELAY_MS = 25; var SNAPSHOT_IDLE_HOLD_MS = 250; +var DEFAULT_MAX_TASK_BLOCK_SECONDS = 900; +var TASK_BLOCK_RETRY_MS = 1000; var MAX_TIMER_DELAY_MS = 2147483647; var STALE_PENDING_MS = 30000; var RETRY_SETTLE_MS = 25; @@ -1644,6 +1646,12 @@ function toolOutputFailed(output) { return true; return false; } +function taskBlockExpired(task, maxBlockMs, now) { + if (maxBlockMs == null) + return false; + const blockingSince = task.state === "running" ? task.runningSince : task.terminalAt; + return blockingSince != null && now - blockingSince >= maxBlockMs; +} function sessionIDFromEvent(event) { const direct = event.properties?.sessionID; if (typeof direct === "string") @@ -1769,13 +1777,17 @@ class TaskTracker { if (marker) this.observeAssistant(sessionID, marker); } - hasBlockingTasks(parentSessionID) { + hasBlockingTasks(parentSessionID, maxBlockMs = null) { this.pruneExpiredSnapshotIdleHolds(); + const now = Date.now(); for (const task of this.tasks.values()) { if (task.parentSessionID !== parentSessionID) continue; - if (task.state === "running" || task.terminalUnreconciled) - return true; + if (task.state !== "running" && !task.terminalUnreconciled) + continue; + if (taskBlockExpired(task, maxBlockMs, now)) + continue; + return true; } for (const hold of this.snapshotIdleHolds.values()) { if (hold.parentSessionID === parentSessionID) @@ -1836,6 +1848,7 @@ class TaskTracker { parentSessionID, state: "running", terminalUnreconciled: false, + runningSince: existing?.state === "running" ? existing.runningSince ?? Date.now() : Date.now(), terminalAt: null, lastAssistantMessageIDAtTerminal: existing?.lastAssistantMessageIDAtTerminal ?? null }); @@ -1851,13 +1864,15 @@ class TaskTracker { if (existing && TASK_TERMINAL_STATES.has(existing.state) && !existing.terminalUnreconciled && !options.resetReconciled) { return; } + const continuesExistingTerminal = existing != null && TASK_TERMINAL_STATES.has(existing.state) && existing.state === state && existing.terminalUnreconciled && !options.resetReconciled; this.tasks.set(taskID, { taskID, parentSessionID: resolvedParentSessionID, state, terminalUnreconciled: true, - terminalAt: Date.now(), - lastAssistantMessageIDAtTerminal: this.latestAssistantBySession.get(resolvedParentSessionID)?.id ?? null + runningSince: null, + terminalAt: continuesExistingTerminal ? existing.terminalAt ?? Date.now() : Date.now(), + lastAssistantMessageIDAtTerminal: continuesExistingTerminal ? existing.lastAssistantMessageIDAtTerminal : this.latestAssistantBySession.get(resolvedParentSessionID)?.id ?? null }); } markSnapshotIdle(parentSessionID, taskID) { @@ -2010,6 +2025,13 @@ async function createGoalFromTool(input, context, services) { function isClosedGoal(goal) { return goal.status === "complete" || goal.status === "unmet"; } +function taskDeferralGoalContinuable(goal) { + if (!goal) + return false; + if (isClosedGoal(goal)) + return false; + return goal.status !== "paused"; +} function existingGoalResult(goal, requestedObjective, planningOnly) { const reused = goal.objective === requestedObjective; return JSON.stringify({ @@ -2097,6 +2119,7 @@ var server = async ({ client }, options) => { const maxAutoTurns = positiveIntegerOrNull2(options?.max_auto_turns) ?? DEFAULT_MAX_AUTO_TURNS; const minInterval = nonNegativeIntegerOrNull2(options?.min_continue_interval_seconds) ?? DEFAULT_CONTINUE_INTERVAL_SECONDS; const maxTurnTimeMs = timeoutMillisecondsFromSeconds(options?.max_turn_time); + const maxTaskBlockMs = timeoutMillisecondsFromSeconds(options?.max_task_block_seconds ?? DEFAULT_MAX_TASK_BLOCK_SECONDS); const maxPromptFailures = positiveIntegerOrNull2(options?.max_prompt_failures) ?? DEFAULT_MAX_PROMPT_FAILURES; const registerCommand = options?.register_command ?? true; const commandName = commandNameFromOptions(options); @@ -2129,7 +2152,7 @@ var server = async ({ client }, options) => { return false; await taskTracker.refreshLiveChildren(client, sessionID); return { - blocked: taskTracker.hasBlockingTasks(sessionID), + blocked: taskTracker.hasBlockingTasks(sessionID, maxTaskBlockMs), retryAt: taskTracker.nextSnapshotIdleRetryAt(sessionID) }; } @@ -2267,10 +2290,14 @@ var server = async ({ client }, options) => { taskTracker.observeAssistantMessage(sessionID, latestAssistant); const taskStatus = await taskBlockStatus(sessionID); if (taskStatus && taskStatus.blocked) { - taskDeferredSessions.add(sessionID); - if (taskStatus.retryAt != null) { - scheduleSettledContinuation(sessionID, taskStatus.retryAt - Date.now(), scheduled != null); + const deferralGoal = await getGoalInternal(sessionID); + if (!taskDeferralGoalContinuable(deferralGoal)) { + taskDeferredSessions.delete(sessionID); + cancelScheduledContinuation(sessionID); + return; } + taskDeferredSessions.add(sessionID); + scheduleSettledContinuation(sessionID, taskStatus.retryAt != null ? taskStatus.retryAt - Date.now() : TASK_BLOCK_RETRY_MS, scheduled != null); return; } if (busySessions.has(sessionID)) @@ -2671,6 +2698,7 @@ async function setupV2(context) { const maxAutoTurns = positiveIntegerOrNull2(options.max_auto_turns) ?? DEFAULT_MAX_AUTO_TURNS; const minInterval = nonNegativeIntegerOrNull2(options.min_continue_interval_seconds) ?? DEFAULT_CONTINUE_INTERVAL_SECONDS; const maxTurnTimeMs = timeoutMillisecondsFromSeconds(options.max_turn_time); + const maxTaskBlockMs = timeoutMillisecondsFromSeconds(options.max_task_block_seconds ?? DEFAULT_MAX_TASK_BLOCK_SECONDS); const maxPromptFailures = positiveIntegerOrNull2(options.max_prompt_failures) ?? DEFAULT_MAX_PROMPT_FAILURES; const registerCommand = options.register_command ?? true; const commandName = commandNameFromOptions(options); @@ -2719,7 +2747,7 @@ async function setupV2(context) { if (!deferWhileTasksActive) return false; return { - blocked: taskTracker.hasBlockingTasks(sessionID), + blocked: taskTracker.hasBlockingTasks(sessionID, maxTaskBlockMs), retryAt: taskTracker.nextSnapshotIdleRetryAt(sessionID) }; } @@ -2849,10 +2877,14 @@ async function setupV2(context) { } const taskStatus = taskBlockStatus(sessionID); if (taskStatus && taskStatus.blocked) { - taskDeferredSessions.add(sessionID); - if (taskStatus.retryAt != null) { - scheduleSettledContinuation(sessionID, taskStatus.retryAt - Date.now(), scheduled != null); + const deferralGoal = await getGoalInternal(sessionID); + if (!taskDeferralGoalContinuable(deferralGoal)) { + taskDeferredSessions.delete(sessionID); + cancelScheduledContinuation(sessionID); + return; } + taskDeferredSessions.add(sessionID); + scheduleSettledContinuation(sessionID, taskStatus.retryAt != null ? taskStatus.retryAt - Date.now() : TASK_BLOCK_RETRY_MS, scheduled != null); return; } if (busySessions.has(sessionID)) diff --git a/src/server.ts b/src/server.ts index 4effa3d..f44aab2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -40,6 +40,7 @@ type Options = { max_auto_turns?: number min_continue_interval_seconds?: number max_turn_time?: number + max_task_block_seconds?: number max_prompt_failures?: number register_command?: boolean command_name?: string @@ -78,6 +79,8 @@ const DEFAULT_COMMAND_NAME = "goal" const DEFAULT_RESTRICTED_AGENTS = ["plan"] const TASK_SETTLE_DELAY_MS = 25 const SNAPSHOT_IDLE_HOLD_MS = 250 +const DEFAULT_MAX_TASK_BLOCK_SECONDS = 900 +const TASK_BLOCK_RETRY_MS = 1_000 const MAX_TIMER_DELAY_MS = 2_147_483_647 const STALE_PENDING_MS = 30_000 const RETRY_SETTLE_MS = 25 @@ -115,6 +118,7 @@ type TaskRecord = { parentSessionID: string state: TaskState terminalUnreconciled: boolean + runningSince: number | null terminalAt: number | null lastAssistantMessageIDAtTerminal: string | null } @@ -531,6 +535,15 @@ function toolOutputFailed(output: unknown) { return false } +// A task record must not defer goal continuation forever. `runningSince` bounds a +// child that stays listed but never reports a terminal state; `terminalAt` bounds a +// terminal child whose result is never reconciled by an orchestrator turn. +function taskBlockExpired(task: TaskRecord, maxBlockMs: number | null, now: number) { + if (maxBlockMs == null) return false + const blockingSince = task.state === "running" ? task.runningSince : task.terminalAt + return blockingSince != null && now - blockingSince >= maxBlockMs +} + function sessionIDFromEvent(event: { type?: string; properties?: Record }) { const direct = event.properties?.sessionID if (typeof direct === "string") return direct @@ -653,11 +666,14 @@ class TaskTracker { if (marker) this.observeAssistant(sessionID, marker) } - hasBlockingTasks(parentSessionID: string) { + hasBlockingTasks(parentSessionID: string, maxBlockMs: number | null = null) { this.pruneExpiredSnapshotIdleHolds() + const now = Date.now() for (const task of this.tasks.values()) { if (task.parentSessionID !== parentSessionID) continue - if (task.state === "running" || task.terminalUnreconciled) return true + if (task.state !== "running" && !task.terminalUnreconciled) continue + if (taskBlockExpired(task, maxBlockMs, now)) continue + return true } for (const hold of this.snapshotIdleHolds.values()) { if (hold.parentSessionID === parentSessionID) return true @@ -717,6 +733,7 @@ class TaskTracker { parentSessionID, state: "running", terminalUnreconciled: false, + runningSince: existing?.state === "running" ? existing.runningSince ?? Date.now() : Date.now(), terminalAt: null, lastAssistantMessageIDAtTerminal: existing?.lastAssistantMessageIDAtTerminal ?? null, }) @@ -741,13 +758,29 @@ class TaskTracker { ) { return } + // refreshLiveChildren re-marks a listed idle child on every poll. Once the record is + // terminal-unreconciled the guard above no longer returns, so a naive rewrite would + // restart `terminalAt` each time and `taskBlockExpired` would never see the record + // age out - the ceiling would never fire for the very case it exists to bound. Carry + // the original timestamp, and with it the assistant marker captured when the child + // first went terminal, so both the ceiling and reconciliation measure from that + // moment. A genuine state change (or an explicit resetReconciled) starts a new clock. + const continuesExistingTerminal = + existing != null && + TASK_TERMINAL_STATES.has(existing.state) && + existing.state === state && + existing.terminalUnreconciled && + !options.resetReconciled this.tasks.set(taskID, { taskID, parentSessionID: resolvedParentSessionID, state, terminalUnreconciled: true, - terminalAt: Date.now(), - lastAssistantMessageIDAtTerminal: this.latestAssistantBySession.get(resolvedParentSessionID)?.id ?? null, + runningSince: null, + terminalAt: continuesExistingTerminal ? existing.terminalAt ?? Date.now() : Date.now(), + lastAssistantMessageIDAtTerminal: continuesExistingTerminal + ? existing.lastAssistantMessageIDAtTerminal + : this.latestAssistantBySession.get(resolvedParentSessionID)?.id ?? null, }) } @@ -923,6 +956,16 @@ function isClosedGoal(goal: GoalSnapshot) { return goal.status === "complete" || goal.status === "unmet" } +// A task-block deferral re-arms a poll that records nothing on the goal, so it must not +// outlive the goal it exists to continue. `budgetLimited` / `usageLimited` still receive a +// wrap-up continuation (see reserveContinuation), so only a missing, closed, or paused goal +// stops the poll. +function taskDeferralGoalContinuable(goal: GoalSnapshot | null | undefined) { + if (!goal) return false + if (isClosedGoal(goal)) return false + return goal.status !== "paused" +} + function existingGoalResult(goal: GoalSnapshot, requestedObjective: string, planningOnly: boolean) { const reused = goal.objective === requestedObjective return JSON.stringify( @@ -1049,6 +1092,9 @@ const server: Plugin = async ({ client }, options?: Options) => { const maxAutoTurns = positiveIntegerOrNull(options?.max_auto_turns) ?? DEFAULT_MAX_AUTO_TURNS const minInterval = nonNegativeIntegerOrNull(options?.min_continue_interval_seconds) ?? DEFAULT_CONTINUE_INTERVAL_SECONDS const maxTurnTimeMs = timeoutMillisecondsFromSeconds(options?.max_turn_time) + const maxTaskBlockMs = timeoutMillisecondsFromSeconds( + options?.max_task_block_seconds ?? DEFAULT_MAX_TASK_BLOCK_SECONDS, + ) const maxPromptFailures = positiveIntegerOrNull(options?.max_prompt_failures) ?? DEFAULT_MAX_PROMPT_FAILURES const registerCommand = options?.register_command ?? true const commandName = commandNameFromOptions(options) @@ -1095,7 +1141,7 @@ const server: Plugin = async ({ client }, options?: Options) => { if (!deferWhileTasksActive) return false await taskTracker.refreshLiveChildren(client, sessionID) return { - blocked: taskTracker.hasBlockingTasks(sessionID), + blocked: taskTracker.hasBlockingTasks(sessionID, maxTaskBlockMs), retryAt: taskTracker.nextSnapshotIdleRetryAt(sessionID), } } @@ -1238,10 +1284,26 @@ const server: Plugin = async ({ client }, options?: Options) => { taskTracker.observeAssistantMessage(sessionID, latestAssistant) const taskStatus = await taskBlockStatus(sessionID) if (taskStatus && taskStatus.blocked) { - taskDeferredSessions.add(sessionID) - if (taskStatus.retryAt != null) { - scheduleSettledContinuation(sessionID, taskStatus.retryAt - Date.now(), scheduled != null) + // Validate the goal before re-arming. The re-arm below runs at TASK_BLOCK_RETRY_MS + // and writes nothing to the goal, so a goal completed, cleared, or paused while a + // child still blocks would otherwise keep a 1 Hz poll alive until the ceiling - and + // forever when max_task_block_seconds is 0. + const deferralGoal = await getGoalInternal(sessionID) + if (!taskDeferralGoalContinuable(deferralGoal)) { + taskDeferredSessions.delete(sessionID) + cancelScheduledContinuation(sessionID) + return } + taskDeferredSessions.add(sessionID) + // Always re-arm. A task block is the only deferral that records nothing on the + // goal, so without a scheduled retry a child that never reports a terminal state + // silently ends auto-continuation: nothing refreshes live children again and the + // goal keeps reading active with no stop reason. + scheduleSettledContinuation( + sessionID, + taskStatus.retryAt != null ? taskStatus.retryAt - Date.now() : TASK_BLOCK_RETRY_MS, + scheduled != null, + ) return } if (busySessions.has(sessionID)) return @@ -1711,6 +1773,9 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise boolean | Promise) { - const deadline = Date.now() + 3000 +async function waitFor(predicate: () => boolean | Promise, deadlineMs = 3000) { + const deadline = Date.now() + deadlineMs while (Date.now() < deadline) { if (await predicate()) return await new Promise((resolve) => setTimeout(resolve, 5)) @@ -916,6 +916,66 @@ test("V2 idle continuation waits for a running child session", async () => { await cleanup() }) +test("V2 running child session stops blocking after the task block ceiling", async () => { + const mock = makeMockContext({ min_continue_interval_seconds: 0, max_task_block_seconds: 0.2 }) + const cleanup = await setupPlugin(mock as never) + await createGoalViaV2Tool(mock, "wait for delegated work that never reports back") + + // The child is never deleted and never reports a terminal state, and no further idle + // event arrives, so only the retry plus the wall-clock ceiling can resume the goal. + mock.stream.push({ type: "session.created", created: 100, data: { sessionID: "child", parentID: "ses_v2" } }) + mock.stream.push({ type: "session.idle", created: 101, data: { sessionID: "ses_v2" } }) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(mock.promptCalls).toHaveLength(0) + + await waitFor(() => mock.promptCalls.length === 1, 10_000) + expect(mock.promptCalls[0]?.text).toContain("Continue working toward the active session goal") + + mock.stream.end() + await cleanup() +}, 20_000) + +// A V2 task-block poll touches nothing on the mock context (taskBlockStatus is entirely +// in-memory), so "no prompt was sent" cannot distinguish a stopped loop from a running +// one - a cleared goal is refused later in runAutoContinue either way. Count the re-arm +// timers themselves instead: that is the resource the fix is about. +test("V2 task deferral stops re-arming when the goal is cleared while a child still blocks", async () => { + const mock = makeMockContext({ min_continue_interval_seconds: 0 }) + const realSetTimeout = globalThis.setTimeout + let taskBlockRearms = 0 + const countingSetTimeout = (handler: (...handlerArgs: never[]) => void, timeout?: number, ...rest: unknown[]) => { + if (timeout === 1_000) taskBlockRearms += 1 + return realSetTimeout(handler as never, timeout as never, ...(rest as never[])) + } + globalThis.setTimeout = countingSetTimeout as unknown as typeof globalThis.setTimeout + try { + const cleanup = await setupPlugin(mock as never) + await createGoalViaV2Tool(mock, "wait for delegated work") + + mock.stream.push({ type: "session.created", created: 100, data: { sessionID: "child", parentID: "ses_v2" } }) + mock.stream.push({ type: "session.idle", created: 101, data: { sessionID: "ses_v2" } }) + + // The deferral is armed and re-arming once per second while the child blocks. + await waitFor(() => taskBlockRearms >= 2, 10_000) + expect(mock.promptCalls).toHaveLength(0) + + await goalTool(mock, "clear_goal").execute({}, toolContext()) + expect(await getGoalInternal("ses_v2")).toBeNull() + + // Let any in-flight re-arm land, then confirm the loop has genuinely stopped. + await new Promise((resolve) => realSetTimeout(resolve, 1_500)) + const rearmsAfterClear = taskBlockRearms + await new Promise((resolve) => realSetTimeout(resolve, 3_000)) + expect(taskBlockRearms).toBe(rearmsAfterClear) + expect(mock.promptCalls).toHaveLength(0) + + mock.stream.end() + await cleanup() + } finally { + globalThis.setTimeout = realSetTimeout + } +}, 30_000) + test("V2 idle auto-continue is suppressed for plan-agent goals", async () => { const mock = makeMockContext({ auto_continue: true, min_continue_interval_seconds: 0, max_auto_turns: 5 }) const cleanup = await setupPlugin(mock as never) diff --git a/test/server.test.ts b/test/server.test.ts index eac3d74..4e9ea31 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -1909,6 +1909,190 @@ test("tracked running child absent from live children stops blocking after grace expect(JSON.stringify(calls[0])).toContain("Continue working toward the active session goal") }) +test("task deferral re-polls live children without a further idle event", async () => { + const calls: unknown[] = [] + let children = [{ id: "task_1" }] + const hooks = await setupServer( + { + client: { + session: { + children: async () => ({ data: children }), + status: async () => ({ data: { task_1: { type: "busy" } } }), + promptAsync: async (input: unknown) => { + calls.push(input) + }, + }, + }, + } as never, + { auto_continue: true, max_auto_turns: 1, min_continue_interval_seconds: 0 }, + ) + const tools = hooks.tool + if (!tools) throw new Error("expected goal tools to be registered") + + await requireTool(tools.create_goal, "create_goal").execute({ objective: "keep going" }, { sessionID: "ses_repoll" } as never) + await hooks.event!({ event: { type: "session.idle", properties: { sessionID: "ses_repoll" } } as never }) + expect(calls).toHaveLength(0) + + // The child disappears and no new idle event ever arrives. Only the task-block + // retry can observe the absence, so continuation must resume without further input. + children = [] + await waitForLong(() => calls.length === 1, 10_000) + expect(JSON.stringify(calls[0])).toContain("Continue working toward the active session goal") +}, 20_000) + +test("live child that never reaches a terminal state stops blocking after the task block ceiling", async () => { + const calls: unknown[] = [] + const hooks = await setupServer( + { + client: { + session: { + children: async () => ({ data: [{ id: "task_1" }] }), + status: async () => ({ data: { task_1: { type: "busy" } } }), + promptAsync: async (input: unknown) => { + calls.push(input) + }, + }, + }, + } as never, + { + auto_continue: true, + max_auto_turns: 1, + min_continue_interval_seconds: 0, + max_task_block_seconds: 0.2, + }, + ) + const tools = hooks.tool + if (!tools) throw new Error("expected goal tools to be registered") + + await requireTool(tools.create_goal, "create_goal").execute({ objective: "keep going" }, { sessionID: "ses_ceiling" } as never) + await hooks.event!({ event: { type: "session.idle", properties: { sessionID: "ses_ceiling" } } as never }) + expect(calls).toHaveLength(0) + + // The child stays listed and busy forever, so it is never pruned as absent and no + // terminal result is ever reconciled. The wall-clock ceiling is the only way out. + await waitForLong(() => calls.length === 1, 10_000) + expect(JSON.stringify(calls[0])).toContain("Continue working toward the active session goal") +}, 20_000) + +test("listed idle child whose result is never reconciled stops blocking after the task block ceiling", async () => { + const calls: unknown[] = [] + let childStatus: "busy" | "idle" = "busy" + const hooks = await setupServer( + { + client: { + session: { + children: async () => ({ data: [{ id: "task_1" }] }), + status: async () => ({ data: { task_1: { type: childStatus } } }), + promptAsync: async (input: unknown) => { + calls.push(input) + }, + }, + }, + } as never, + { + auto_continue: true, + max_auto_turns: 1, + min_continue_interval_seconds: 0, + max_task_block_seconds: 0.2, + }, + ) + const tools = hooks.tool + if (!tools) throw new Error("expected goal tools to be registered") + + await requireTool(tools.create_goal, "create_goal").execute( + { objective: "keep going" }, + { sessionID: "ses_unreconciled" } as never, + ) + await hooks.event!({ event: { type: "session.idle", properties: { sessionID: "ses_unreconciled" } } as never }) + expect(calls).toHaveLength(0) + + // The child now reports idle, so it is tracked as terminal-unreconciled: it stays + // listed in children(), and no orchestrator turn ever reconciles its result. Every + // poll re-marks the same terminal state, so the ceiling can only fire if the original + // terminal timestamp is preserved across those repeat marks. + childStatus = "idle" + + await waitForLong(() => calls.length === 1, 10_000) + expect(JSON.stringify(calls[0])).toContain("Continue working toward the active session goal") +}, 20_000) + +test("task deferral stops polling when the goal is cleared while a child still blocks", async () => { + const calls: unknown[] = [] + let childPolls = 0 + const hooks = await setupServer( + { + client: { + session: { + children: async () => { + childPolls += 1 + return { data: [{ id: "task_1" }] } + }, + status: async () => ({ data: { task_1: { type: "busy" } } }), + promptAsync: async (input: unknown) => { + calls.push(input) + }, + }, + }, + } as never, + { auto_continue: true, max_auto_turns: 1, min_continue_interval_seconds: 0 }, + ) + const tools = hooks.tool + if (!tools) throw new Error("expected goal tools to be registered") + const context = { sessionID: "ses_cleared" } as never + + await requireTool(tools.create_goal, "create_goal").execute({ objective: "keep going" }, context) + await hooks.event!({ event: { type: "session.idle", properties: { sessionID: "ses_cleared" } } as never }) + expect(calls).toHaveLength(0) + + // The deferral is armed and re-polling. Clearing the goal must stop it: the retry + // writes nothing to the goal, so nothing else would ever end the loop. + await waitForLong(() => childPolls >= 2, 10_000) + await requireTool(tools.clear_goal, "clear_goal").execute({}, context) + + await new Promise((resolve) => setTimeout(resolve, 1_500)) + const pollsAfterClear = childPolls + await new Promise((resolve) => setTimeout(resolve, 2_500)) + expect(childPolls).toBe(pollsAfterClear) + expect(calls).toHaveLength(0) +}, 30_000) + +test("task deferral stops polling when the goal is paused while a child still blocks", async () => { + const calls: unknown[] = [] + let childPolls = 0 + const hooks = await setupServer( + { + client: { + session: { + children: async () => { + childPolls += 1 + return { data: [{ id: "task_1" }] } + }, + status: async () => ({ data: { task_1: { type: "busy" } } }), + promptAsync: async (input: unknown) => { + calls.push(input) + }, + }, + }, + } as never, + { auto_continue: true, max_auto_turns: 1, min_continue_interval_seconds: 0 }, + ) + const tools = hooks.tool + if (!tools) throw new Error("expected goal tools to be registered") + const context = { sessionID: "ses_paused_block" } as never + + await requireTool(tools.create_goal, "create_goal").execute({ objective: "keep going" }, context) + await hooks.event!({ event: { type: "session.idle", properties: { sessionID: "ses_paused_block" } } as never }) + await waitForLong(() => childPolls >= 2, 10_000) + + await requireTool(tools.update_goal_status, "update_goal_status").execute({ status: "paused" }, context) + + await new Promise((resolve) => setTimeout(resolve, 1_500)) + const pollsAfterPause = childPolls + await new Promise((resolve) => setTimeout(resolve, 2_500)) + expect(childPolls).toBe(pollsAfterPause) + expect(calls).toHaveLength(0) +}, 30_000) + test("task deferral can be disabled with config", async () => { const calls: unknown[] = [] const hooks = await setupServer( From 963d18c9784ff546b2e36adde616e400f8023478 Mon Sep 17 00:00:00 2001 From: Spencer Fuller Date: Sun, 6 Sep 2026 22:32:06 -0500 Subject: [PATCH 2/2] fix: match the task-deferral predicate to reserveContinuation's rules `taskDeferralGoalContinuable` treated every `budgetLimited` / `usageLimited` goal as continuable. That holds only until the single wrap-up is reserved: `reserveWrapup` returns null once `budgetWrapupSent` is set, so a child still blocking after the wrap-up had been sent kept the 1 Hz poll re-arming with no possible continuation - indefinitely with `max_task_block_seconds: 0`. The predicate now mirrors the reservation rules instead of restating them: a limited goal is continuable only while its wrap-up is unspent, and every other non-active status fails `canContinue` outright. Lifecycle coverage is now symmetric. V1 and V2 each cover cleared, paused, completed, and the two-legged wrap-up case. Leg A of that pair is the control: a predicate that simply refused every non-active status would pass leg B while silently dropping the wrap-up a blocked limited goal is still owed. Both wrap-up tests also assert the deferral returns after `update_goal_status active` clears `budgetWrapupSent`, proving the loop stayed reachable and only the predicate was holding it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MLdQ88tCqSgfEsaGR978UF --- dist/server.js | 6 +- src/server.ts | 12 +-- test/server-v2.test.ts | 167 ++++++++++++++++++++++++++++++++++++----- test/server.test.ts | 144 +++++++++++++++++++++++++++++++++++ 4 files changed, 303 insertions(+), 26 deletions(-) diff --git a/dist/server.js b/dist/server.js index 375da65..96a5219 100644 --- a/dist/server.js +++ b/dist/server.js @@ -2028,9 +2028,9 @@ function isClosedGoal(goal) { function taskDeferralGoalContinuable(goal) { if (!goal) return false; - if (isClosedGoal(goal)) - return false; - return goal.status !== "paused"; + if (goal.status === "budgetLimited" || goal.status === "usageLimited") + return !goal.budgetWrapupSent; + return goal.status === "active"; } function existingGoalResult(goal, requestedObjective, planningOnly) { const reused = goal.objective === requestedObjective; diff --git a/src/server.ts b/src/server.ts index f44aab2..11b1274 100644 --- a/src/server.ts +++ b/src/server.ts @@ -957,13 +957,15 @@ function isClosedGoal(goal: GoalSnapshot) { } // A task-block deferral re-arms a poll that records nothing on the goal, so it must not -// outlive the goal it exists to continue. `budgetLimited` / `usageLimited` still receive a -// wrap-up continuation (see reserveContinuation), so only a missing, closed, or paused goal -// stops the poll. +// outlive the goal it exists to continue. This mirrors reserveContinuation's reservation +// rules rather than restating them: a limited goal is owed exactly one wrap-up +// continuation, and reserveWrapup returns null once budgetWrapupSent is set, so after the +// wrap-up has been sent there is nothing left for the poll to wake up for. Every other +// non-active status (paused, complete, unmet) fails canContinue outright. function taskDeferralGoalContinuable(goal: GoalSnapshot | null | undefined) { if (!goal) return false - if (isClosedGoal(goal)) return false - return goal.status !== "paused" + if (goal.status === "budgetLimited" || goal.status === "usageLimited") return !goal.budgetWrapupSent + return goal.status === "active" } function existingGoalResult(goal: GoalSnapshot, requestedObjective: string, planningOnly: boolean) { diff --git a/test/server-v2.test.ts b/test/server-v2.test.ts index 6f0157b..d28c867 100644 --- a/test/server-v2.test.ts +++ b/test/server-v2.test.ts @@ -180,6 +180,27 @@ async function waitFor(predicate: () => boolean | Promise, deadlineMs = expect(await predicate()).toBe(true) } +// A task-block re-arm is a TASK_BLOCK_RETRY_MS timer and nothing else: the V2 poll touches +// no mock surface, so counting the timers is the only way to tell a stopped loop from a +// running one. Swapping the global back in a finally keeps a failed assertion from leaking +// the patched setTimeout into the rest of the file. +async function countTaskBlockRearms( + body: (rearms: () => number, realSetTimeout: typeof globalThis.setTimeout) => Promise, +) { + const realSetTimeout = globalThis.setTimeout + let rearms = 0 + const countingSetTimeout = (handler: (...handlerArgs: never[]) => void, timeout?: number, ...rest: unknown[]) => { + if (timeout === 1_000) rearms += 1 + return realSetTimeout(handler as never, timeout as never, ...(rest as never[])) + } + globalThis.setTimeout = countingSetTimeout as unknown as typeof globalThis.setTimeout + try { + await body(() => rearms, realSetTimeout) + } finally { + globalThis.setTimeout = realSetTimeout + } +} + function goalTool(mock: MockContext, name: string) { const tool = mock.tools.find((candidate) => candidate.name === name) if (!tool) throw new Error(`expected V2 tool ${name} to be registered`) @@ -935,20 +956,12 @@ test("V2 running child session stops blocking after the task block ceiling", asy await cleanup() }, 20_000) -// A V2 task-block poll touches nothing on the mock context (taskBlockStatus is entirely -// in-memory), so "no prompt was sent" cannot distinguish a stopped loop from a running -// one - a cleared goal is refused later in runAutoContinue either way. Count the re-arm -// timers themselves instead: that is the resource the fix is about. +// "No prompt was sent" cannot distinguish a stopped loop from a running one here - a +// cleared goal is refused later in runAutoContinue either way - so these lifecycle tests +// count the re-arm timers, which are the resource the fix is about. test("V2 task deferral stops re-arming when the goal is cleared while a child still blocks", async () => { const mock = makeMockContext({ min_continue_interval_seconds: 0 }) - const realSetTimeout = globalThis.setTimeout - let taskBlockRearms = 0 - const countingSetTimeout = (handler: (...handlerArgs: never[]) => void, timeout?: number, ...rest: unknown[]) => { - if (timeout === 1_000) taskBlockRearms += 1 - return realSetTimeout(handler as never, timeout as never, ...(rest as never[])) - } - globalThis.setTimeout = countingSetTimeout as unknown as typeof globalThis.setTimeout - try { + await countTaskBlockRearms(async (rearms, realSetTimeout) => { const cleanup = await setupPlugin(mock as never) await createGoalViaV2Tool(mock, "wait for delegated work") @@ -956,7 +969,7 @@ test("V2 task deferral stops re-arming when the goal is cleared while a child st mock.stream.push({ type: "session.idle", created: 101, data: { sessionID: "ses_v2" } }) // The deferral is armed and re-arming once per second while the child blocks. - await waitFor(() => taskBlockRearms >= 2, 10_000) + await waitFor(() => rearms() >= 2, 10_000) expect(mock.promptCalls).toHaveLength(0) await goalTool(mock, "clear_goal").execute({}, toolContext()) @@ -964,18 +977,136 @@ test("V2 task deferral stops re-arming when the goal is cleared while a child st // Let any in-flight re-arm land, then confirm the loop has genuinely stopped. await new Promise((resolve) => realSetTimeout(resolve, 1_500)) - const rearmsAfterClear = taskBlockRearms + const rearmsAfterClear = rearms() await new Promise((resolve) => realSetTimeout(resolve, 3_000)) - expect(taskBlockRearms).toBe(rearmsAfterClear) + expect(rearms()).toBe(rearmsAfterClear) expect(mock.promptCalls).toHaveLength(0) mock.stream.end() await cleanup() - } finally { - globalThis.setTimeout = realSetTimeout - } + }) +}, 30_000) + +test("V2 task deferral stops re-arming when the goal is paused while a child still blocks", async () => { + const mock = makeMockContext({ min_continue_interval_seconds: 0 }) + await countTaskBlockRearms(async (rearms, realSetTimeout) => { + const cleanup = await setupPlugin(mock as never) + await createGoalViaV2Tool(mock, "wait for delegated work") + + mock.stream.push({ type: "session.created", created: 100, data: { sessionID: "child", parentID: "ses_v2" } }) + mock.stream.push({ type: "session.idle", created: 101, data: { sessionID: "ses_v2" } }) + await waitFor(() => rearms() >= 2, 10_000) + expect(mock.promptCalls).toHaveLength(0) + + await goalTool(mock, "update_goal_status").execute({ status: "paused" }, toolContext()) + expect((await getGoalInternal("ses_v2"))?.status).toBe("paused") + + await new Promise((resolve) => realSetTimeout(resolve, 1_500)) + const rearmsAfterPause = rearms() + await new Promise((resolve) => realSetTimeout(resolve, 3_000)) + expect(rearms()).toBe(rearmsAfterPause) + expect(mock.promptCalls).toHaveLength(0) + + mock.stream.end() + await cleanup() + }) }, 30_000) +test("V2 task deferral stops re-arming when the goal is completed while a child still blocks", async () => { + const mock = makeMockContext({ min_continue_interval_seconds: 0 }) + await countTaskBlockRearms(async (rearms, realSetTimeout) => { + const cleanup = await setupPlugin(mock as never) + await createGoalViaV2Tool(mock, "wait for delegated work") + + mock.stream.push({ type: "session.created", created: 100, data: { sessionID: "child", parentID: "ses_v2" } }) + mock.stream.push({ type: "session.idle", created: 101, data: { sessionID: "ses_v2" } }) + await waitFor(() => rearms() >= 2, 10_000) + expect(mock.promptCalls).toHaveLength(0) + + // Unlike a pause, a closed goal can never be resumed, so the poll has nothing to + // wake up for even in principle. + await goalTool(mock, "update_goal").execute( + { status: "complete", evidence: "delegated work is no longer needed" }, + toolContext(), + ) + expect((await getGoalInternal("ses_v2"))?.status).toBe("complete") + + await new Promise((resolve) => realSetTimeout(resolve, 1_500)) + const rearmsAfterComplete = rearms() + await new Promise((resolve) => realSetTimeout(resolve, 3_000)) + expect(rearms()).toBe(rearmsAfterComplete) + expect(mock.promptCalls).toHaveLength(0) + + mock.stream.end() + await cleanup() + }) +}, 30_000) + +// Mirrors the V1 wrap-up regression. Leg A is the control: a predicate that refused every +// non-active status would pass leg B while silently dropping the one wrap-up continuation +// a blocked limited goal is still owed. +test("V2 task deferral keeps re-arming a limited goal until its wrap-up is spent, then stops", async () => { + const mock = makeMockContext({ min_continue_interval_seconds: 0 }) + await countTaskBlockRearms(async (rearms, realSetTimeout) => { + const cleanup = await setupPlugin(mock as never) + await goalTool(mock, "create_goal").execute( + { objective: "wait for delegated work", token_budget: 10 }, + toolContext(), + ) + + mock.stream.push({ + type: "session.step.ended", + created: Date.now(), + data: { + sessionID: "ses_v2", + assistantMessageID: "msg_v2_wrapup", + finish: "stop", + tokens: { input: 20, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + }) + await waitFor(async () => (await getGoalInternal("ses_v2"))?.status === "budgetLimited") + expect((await getGoalInternal("ses_v2"))?.budgetWrapupSent).toBe(false) + + // Leg A - the wrap-up is still unspent, so a blocking child must not stop the poll. + mock.stream.push({ type: "session.created", created: 100, data: { sessionID: "child", parentID: "ses_v2" } }) + mock.stream.push({ type: "session.idle", created: 101, data: { sessionID: "ses_v2" } }) + await waitFor(() => rearms() >= 2, 10_000) + expect(mock.promptCalls).toHaveLength(0) + + // Releasing the child lets the running poll reach reserveContinuation, which spends + // the single wrap-up a limited goal is owed. + mock.stream.push({ type: "session.deleted", created: 102, data: { sessionID: "child" } }) + await waitFor(() => mock.promptCalls.length === 1, 10_000) + expect((await getGoalInternal("ses_v2"))?.budgetWrapupSent).toBe(true) + + // The delivered wrap-up is still finishing its post-delivery bookkeeping, and + // runAutoContinue refuses re-entry while a continuation is in flight. Without this + // settle the assertions below would pass for that reason instead of the intended one. + await new Promise((resolve) => realSetTimeout(resolve, 1_000)) + + // Leg B - same status, same blocking child, but nothing left to continue to. + const rearmsBeforeSecondBlock = rearms() + mock.stream.push({ type: "session.created", created: 103, data: { sessionID: "child2", parentID: "ses_v2" } }) + mock.stream.push({ type: "session.idle", created: 104, data: { sessionID: "ses_v2" } }) + await new Promise((resolve) => realSetTimeout(resolve, 1_500)) + const rearmsAfterWrapup = rearms() + await new Promise((resolve) => realSetTimeout(resolve, 3_000)) + expect(rearms()).toBe(rearmsAfterWrapup) + expect(rearmsAfterWrapup).toBe(rearmsBeforeSecondBlock) + expect(mock.promptCalls).toHaveLength(1) + + // Positive control: nothing about the session or the blocked child changed, so resuming + // the goal - which clears budgetWrapupSent - must bring the same deferral straight back. + // Only the predicate was ever holding it, and the loop is provably still reachable. + await goalTool(mock, "update_goal_status").execute({ status: "active" }, toolContext()) + mock.stream.push({ type: "session.idle", created: 105, data: { sessionID: "ses_v2" } }) + await waitFor(() => rearms() > rearmsAfterWrapup, 10_000) + + mock.stream.end() + await cleanup() + }) +}, 60_000) + test("V2 idle auto-continue is suppressed for plan-agent goals", async () => { const mock = makeMockContext({ auto_continue: true, min_continue_interval_seconds: 0, max_auto_turns: 5 }) const cleanup = await setupPlugin(mock as never) diff --git a/test/server.test.ts b/test/server.test.ts index 4e9ea31..4348673 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -2093,6 +2093,150 @@ test("task deferral stops polling when the goal is paused while a child still bl expect(calls).toHaveLength(0) }, 30_000) +test("task deferral stops polling when the goal is completed while a child still blocks", async () => { + const calls: unknown[] = [] + let childPolls = 0 + const hooks = await setupServer( + { + client: { + session: { + children: async () => { + childPolls += 1 + return { data: [{ id: "task_1" }] } + }, + status: async () => ({ data: { task_1: { type: "busy" } } }), + promptAsync: async (input: unknown) => { + calls.push(input) + }, + }, + }, + } as never, + { auto_continue: true, max_auto_turns: 5, min_continue_interval_seconds: 0 }, + ) + const tools = hooks.tool + if (!tools) throw new Error("expected goal tools to be registered") + const context = { sessionID: "ses_completed_block" } as never + + await requireTool(tools.create_goal, "create_goal").execute({ objective: "keep going" }, context) + await hooks.event!({ event: { type: "session.idle", properties: { sessionID: "ses_completed_block" } } as never }) + await waitForLong(() => childPolls >= 2, 10_000) + + // A closed goal is terminal: nothing will ever reopen it, so the poll has nothing left + // to wake up for. `paused` can at least be resumed; `complete` cannot. + await requireTool(tools.update_goal, "update_goal").execute( + { status: "complete", evidence: "delegated work is no longer needed" }, + context, + ) + + await new Promise((resolve) => setTimeout(resolve, 1_500)) + const pollsAfterComplete = childPolls + await new Promise((resolve) => setTimeout(resolve, 2_500)) + expect(childPolls).toBe(pollsAfterComplete) + expect(calls).toHaveLength(0) +}, 30_000) + +// The two legs below are one regression: a limited goal is owed exactly one wrap-up +// continuation, so the deferral must survive `budgetLimited` (leg A) and must stop once +// `reserveWrapup` has spent it (leg B). Leg A is the control - without it a predicate that +// simply refused every non-active status would pass leg B while silently dropping the +// wrap-up a blocked limited goal is still entitled to. +test("task deferral keeps polling a limited goal until its wrap-up is spent, then stops", async () => { + const calls: unknown[] = [] + let childPolls = 0 + let childBlocks = false + const hooks = await setupServer( + { + client: { + session: { + children: async () => { + childPolls += 1 + return { data: childBlocks ? [{ id: "task_1" }] : [] } + }, + status: async () => ({ data: { task_1: { type: "busy" } } }), + promptAsync: async (input: unknown) => { + calls.push(input) + }, + }, + }, + } as never, + { auto_continue: true, max_auto_turns: 5, min_continue_interval_seconds: 0 }, + ) + const tools = hooks.tool + if (!tools) throw new Error("expected goal tools to be registered") + const context = { sessionID: "ses_wrapup_block" } as never + + await requireTool(tools.create_goal, "create_goal").execute( + { objective: "keep going", token_budget: 10 }, + context, + ) + // The first step-finish observation only establishes the usage baseline; the second is + // what actually accrues against the budget. + await hooks["experimental.chat.messages.transform"]!( + {}, + { + messages: [ + { + info: { id: "msg_wrapup_budget", role: "assistant", sessionID: "ses_wrapup_block" }, + parts: [{ type: "step-finish", tokens: { input: 6, output: 5 } }], + }, + ], + } as never, + ) + await hooks["experimental.chat.messages.transform"]!( + {}, + { + messages: [ + { + info: { id: "msg_wrapup_budget_2", role: "assistant", sessionID: "ses_wrapup_block" }, + parts: [{ type: "step-finish", tokens: { input: 17, output: 5 } }], + }, + ], + } as never, + ) + const limited = await requireTool(tools.get_goal, "get_goal").execute({}, context) + expect(String(limited)).toContain('"status": "budgetLimited"') + expect(String(limited)).toContain('"budgetWrapupSent": false') + + // Leg A - the wrap-up is still unspent, so a blocking child must NOT stop the poll. + childBlocks = true + const pollsBeforeBlock = childPolls + await hooks.event!({ event: { type: "session.idle", properties: { sessionID: "ses_wrapup_block" } } as never }) + await waitForLong(() => childPolls >= pollsBeforeBlock + 3, 10_000) + expect(calls).toHaveLength(0) + + // Releasing the child lets the running poll reach reserveContinuation, which spends the + // one wrap-up. That is the only prompt a limited goal ever gets. + childBlocks = false + await waitForLong(() => calls.length === 1, 10_000) + const spent = await requireTool(tools.get_goal, "get_goal").execute({}, context) + expect(String(spent)).toContain('"budgetWrapupSent": true') + + // The delivered wrap-up is still finishing its post-delivery bookkeeping, and + // runAutoContinue refuses re-entry while a continuation is in flight. Without this + // settle the assertions below would pass for that reason instead of the intended one. + await new Promise((resolve) => setTimeout(resolve, 1_000)) + + // Leg B - same status, same blocking child, but nothing left to continue to. + childBlocks = true + const pollsBeforeSecondBlock = childPolls + await hooks.event!({ event: { type: "session.idle", properties: { sessionID: "ses_wrapup_block" } } as never }) + // The idle's own taskBlockStatus poll must land: it proves runAutoContinue was reachable, + // so a frozen count afterwards means the loop stopped rather than never started. + await waitForLong(() => childPolls > pollsBeforeSecondBlock, 10_000) + await new Promise((resolve) => setTimeout(resolve, 1_500)) + const pollsAfterWrapup = childPolls + await new Promise((resolve) => setTimeout(resolve, 2_500)) + expect(childPolls).toBe(pollsAfterWrapup) + expect(calls).toHaveLength(1) + + // Positive control: nothing about the session or the blocked child changed, so resuming + // the goal - which clears budgetWrapupSent - must bring the same deferral straight back. + // Only the predicate was ever holding it. + await requireTool(tools.update_goal_status, "update_goal_status").execute({ status: "active" }, context) + await hooks.event!({ event: { type: "session.idle", properties: { sessionID: "ses_wrapup_block" } } as never }) + await waitForLong(() => childPolls >= pollsAfterWrapup + 3, 10_000) +}, 60_000) + test("task deferral can be disabled with config", async () => { const calls: unknown[] = [] const hooks = await setupServer(