diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 11daeea31..e90581e2e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -10,7 +10,7 @@ ### Changed -- Subagents now inherit `async.enabled` and `bash.autoBackground.enabled` from the parent instead of having both force-disabled. Subagent runs complete only after their own background jobs settle (results are folded into the run as async-result follow-ups, with a one-time notice offering `hub` wait/cancel), and teardown cancels and awaits surviving jobs before isolation worktree capture and cleanup. +- Subagents now inherit `async.enabled` and `bash.autoBackground.enabled` from the parent instead of having both force-disabled. Subagent runs complete only after their own background jobs settle and the agent submits a `yield` that postdates every delivered result: a terminal yield with jobs still pending parks the run (recoverable turn stop) instead of completing it, async results are folded in as follow-up turns (with a one-time notice offering `hub` wait/cancel), a result delivered after a yield supersedes that yield and re-runs the yield reminder ladder, and a run that never refreshes a superseded yield fails with the stale payload preserved as salvage. Teardown cancels and awaits surviving jobs before isolation worktree capture and cleanup. ### Fixed diff --git a/packages/coding-agent/src/prompts/system/subagent-async-pending.md b/packages/coding-agent/src/prompts/system/subagent-async-pending.md index 89bf568cb..daee84c25 100644 --- a/packages/coding-agent/src/prompts/system/subagent-async-pending.md +++ b/packages/coding-agent/src/prompts/system/subagent-async-pending.md @@ -1,6 +1,6 @@ Your yield was recorded, but {{count}} background job{{#if multiple}}s{{/if}} you own {{#if multiple}}are{{else}}is{{/if}} still running: {{jobs}}. -This run completes only after these jobs settle; their results arrive as follow-up messages. Decide now: +This run completes only after these jobs settle AND you submit a fresh `yield` that accounts for their results. Job results arrive as follow-up messages; a result that arrives after your yield supersedes it — your current yield will NOT be accepted as the final report. Decide now: - Need the results? Wait for them (`hub` op:"wait"), then submit a fresh `yield` that incorporates them. - Job no longer needed? Cancel it (`hub` op:"cancel", ids:[...]) and re-yield. -- Otherwise say nothing further; your current yield stands once the jobs finish. +- Otherwise stand by; when each result arrives, submit a fresh `yield` (repeat your report unchanged if the result does not affect it). diff --git a/packages/coding-agent/src/session/async-job-delivery.ts b/packages/coding-agent/src/session/async-job-delivery.ts index 5565bbf28..061d99dc6 100644 --- a/packages/coding-agent/src/session/async-job-delivery.ts +++ b/packages/coding-agent/src/session/async-job-delivery.ts @@ -13,6 +13,13 @@ import type { AsyncJob } from "../async"; import asyncResultTemplate from "../prompts/tools/async-result.md" with { type: "text" }; import type { CustomMessage } from "./messages"; +/** + * `customType` of the injected async-result follow-up message. The task + * executor's run monitor matches on it to invalidate a previously recorded + * yield: a result injected after the yield supersedes that yield's payload. + */ +export const ASYNC_RESULT_MESSAGE_TYPE = "async-result"; + /** Result payloads longer than this spill to an artifact with an inline preview. */ export const ASYNC_INLINE_RESULT_MAX_CHARS = 12_000; export const ASYNC_PREVIEW_MAX_CHARS = 4_000; @@ -54,7 +61,7 @@ export function buildAsyncResultBatchMessage(entries: AsyncResultEntry[]): Custo }; return { role: "custom", - customType: "async-result", + customType: ASYNC_RESULT_MESSAGE_TYPE, content: prompt.render(asyncResultTemplate, { multiple: jobs.length > 1, jobs, diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 6a54bb9bc..864558a19 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -5,7 +5,7 @@ */ import path from "node:path"; -import type { AgentEvent, AgentIdentity, AgentTelemetryConfig } from "@oh-my-pi/pi-agent-core"; +import type { AgentEvent, AgentIdentity, AgentMessage, AgentTelemetryConfig } from "@oh-my-pi/pi-agent-core"; import { recordHandoff, resolveTelemetry } from "@oh-my-pi/pi-agent-core"; import type { Api, Model, ServiceTierByFamily, Usage } from "@oh-my-pi/pi-ai"; import { logger, popLoopPhase, prompt, pushLoopPhase, untilAborted } from "@oh-my-pi/pi-utils"; @@ -42,6 +42,7 @@ import { AgentRegistry } from "../registry/agent-registry"; import { type CreateAgentSessionOptions, createAgentSession, discoverAuthStorage } from "../sdk"; import type { AgentSession, AgentSessionEvent, Prewalk } from "../session/agent-session"; import type { ArtifactManager } from "../session/artifacts"; +import { ASYNC_RESULT_MESSAGE_TYPE } from "../session/async-job-delivery"; import type { AuthStorage } from "../session/auth-storage"; import { SKILL_PROMPT_MESSAGE_TYPE, USER_INTERRUPT_LABEL } from "../session/messages"; import { SessionManager } from "../session/session-manager"; @@ -877,6 +878,20 @@ interface SubagentRunMonitor { budgetStopRequested(): boolean; /** Resolves when the budget-stop session abort has settled (immediately when no stop fired). */ waitForBudgetStop(): Promise; + /** + * True when a recorded yield was invalidated by a later async-result + * injection and no fresh yield has landed since: the yield payload + * predates background job outcomes the model was shown. + */ + yieldInvalidatedByAsync(): boolean; + /** + * True once a terminal yield with pending owner async work stopped the + * free-running turn (recoverable, like a budget stop) instead of + * terminating the run. Cleared when {@link waitForYieldTurnStop} settles. + */ + yieldTurnStopRequested(): boolean; + /** Resolves when the yield turn-stop session abort has settled (immediately when none fired). */ + waitForYieldTurnStop(): Promise; /** The abort kind for this run, when an abort was requested. */ abortKind(): AbortReason | undefined; /** True when the abort carries a precise external reason (signal / wall-clock / budget). */ @@ -903,6 +918,15 @@ interface SubagentRunMonitor { finish(): void; } +/** + * True when `message` is the session-injected async-result follow-up + * ({@link ASYNC_RESULT_MESSAGE_TYPE}): the transcript-ordered signal that a + * background job outcome landed after whatever the model said before it. + */ +function isAsyncResultInjection(message: AgentMessage | undefined): boolean { + return message?.role === "custom" && message.customType === ASYNC_RESULT_MESSAGE_TYPE; +} + function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor { const { index, @@ -954,6 +978,9 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor { let activeSession: AgentSession | null = null; let yieldCalled = false; let yieldCallPending = false; + let yieldInvalidatedByAsync = false; + let yieldTurnStopRequested = false; + let yieldTurnStopPromise: Promise | null = null; // Accumulate usage incrementally from message_end events (no memory for streaming events) const accumulatedUsage: Usage = { @@ -1026,6 +1053,30 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor { : Promise.resolve(); }; + // Yield turn-stop: a terminal yield recorded while owner async work is + // still pending is a scheduling pause, not run completion. Stop the + // free-running turn exactly like a budget stop (session abort, monitor + // signal untouched) so driveSessionToYield's quiescence barrier can settle + // the jobs, fold their results in, and demand a fresh yield. Terminating + // here instead would abort the run signal and make the barrier + // unreachable, completing the run with a payload that predates the job + // outcomes. + const requestYieldTurnStop = () => { + if (yieldTurnStopRequested || abortSent || resolved) return; + yieldTurnStopRequested = true; + const session = activeSession; + yieldTurnStopPromise = session + ? session.abort().catch(error => { + logger.debug("Subagent yield turn-stop abort failed", { + error: error instanceof Error ? error.message : String(error), + }); + }) + : Promise.resolve(); + }; + + /** Owner async work that can still re-wake the run (quiescence barrier predicate). */ + const sessionHasPendingAsyncWork = (): boolean => activeSession?.hasPendingAsyncWork?.() ?? false; + // Handle abort signal if (signal) { signal.addEventListener( @@ -1229,6 +1280,7 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor { if (toolName === "yield") { yieldCalled = true; yieldCallPending = false; + yieldInvalidatedByAsync = false; } }; @@ -1242,6 +1294,16 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor { if (event.message?.role === "assistant") { resetRecentOutput(); } + // An async-result follow-up injected after a recorded yield + // supersedes that yield: its payload predates the job outcome the + // model is now being shown. Un-latch so the quiescence barrier's + // reminder ladder demands a fresh yield. Guarded on the run signal: + // once the run is completing, late injections must not destabilize + // the settled classification. + if (yieldCalled && !abortSignal.aborted && isAsyncResultInjection(event.message)) { + yieldCalled = false; + yieldInvalidatedByAsync = true; + } break; case "tool_execution_start": { @@ -1325,7 +1387,14 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor { isError: event.isError, }) ) { - requestAbort("terminate"); + if (event.toolName === "yield" && sessionHasPendingAsyncWork()) { + // Terminal yield with owner jobs still pending: park the + // run behind the quiescence barrier instead of completing + // it (see requestYieldTurnStop). + requestYieldTurnStop(); + } else { + requestAbort("terminate"); + } } } flushProgress = true; @@ -1567,6 +1636,25 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor { abortReason === "signal" || runtimeLimitExceeded || budgetLimitExceeded || budgetStopRequested, budgetStopRequested: () => budgetStopRequested, waitForBudgetStop: () => budgetStopAbortPromise ?? Promise.resolve(), + yieldInvalidatedByAsync: () => yieldInvalidatedByAsync, + yieldTurnStopRequested: () => yieldTurnStopRequested, + waitForYieldTurnStop: async () => { + const pending = yieldTurnStopPromise; + if (!pending) { + yieldTurnStopRequested = false; + return; + } + try { + await pending; + } finally { + // Clear only after the abort settled so the idempotence gate in + // requestYieldTurnStop stays closed while it is in flight. + if (yieldTurnStopPromise === pending) { + yieldTurnStopPromise = null; + yieldTurnStopRequested = false; + } + } + }, // A soft stop that never escalated still identifies as a budget abort so // the lifecycle can park the agent as resumable instead of killing it. abortKind: () => abortReason ?? (budgetStopRequested ? "budget" : undefined), @@ -1664,83 +1752,106 @@ async function driveSessionToYield( await awaitAbortable(session.prompt(task, { attribution: "agent" })); await awaitAbortable(session.waitForIdle()); } catch (err) { - // A budget stop cancels the free-running turn by aborting the - // session, which can surface here as a rejected prompt. Swallow it - // and drive the forced final yield below; real caller/timeout - // aborts (monitor signal) and genuine failures keep the old path. - if (!monitor.budgetStopRequested() || abortSignal.aborted) throw err; + // A budget stop or a yield turn-stop (terminal yield parked behind + // the async quiescence barrier) cancels the free-running turn by + // aborting the session, which can surface here as a rejected + // prompt. Swallow it and drive the barrier/forced final yield + // below; real caller/timeout aborts (monitor signal) and genuine + // failures keep the old path. + const recoverableStop = monitor.budgetStopRequested() || monitor.yieldTurnStopRequested(); + if (!recoverableStop || abortSignal.aborted) throw err; } const reminderToolChoice = buildNamedToolChoice("yield", session.model); - let retryCount = 0; - while (!monitor.yieldCalled() && retryCount < MAX_YIELD_RETRIES && !abortSignal.aborted) { - // A budget stop collapses the reminder ladder to a single forced - // final yield: wait for the stop's session abort to settle, then - // prompt once with the wrap-up reminder + named tool choice. - const budgetStop = monitor.budgetStopRequested(); - if (budgetStop) { - retryCount = MAX_YIELD_RETRIES - 1; - await monitor.waitForBudgetStop(); - if (monitor.yieldCalled() || abortSignal.aborted) break; - } - // Skip reminders when the model returned a terminal error (e.g. - // rate-limit cap hit, auth failure). Re-prompting would just - // hit the same wall, multiplying the failure noise without - // any chance of producing a yield. - const lastBeforeReminder = session.getLastAssistantMessage(); - if (lastBeforeReminder?.stopReason === "error") break; - try { - retryCount++; - const reminder = prompt.render(submitReminderTemplate, { - retryCount, - maxRetries: MAX_YIELD_RETRIES, - budgetStop, - }); - - const isFinalRetry = retryCount >= MAX_YIELD_RETRIES; - await awaitAbortable( - session.prompt(reminder, { - attribution: "agent", - synthetic: true, - ...(isFinalRetry && reminderToolChoice ? { toolChoice: reminderToolChoice } : {}), - }), - ); - await awaitAbortable(session.waitForIdle()); - } catch (err) { - if (abortSignal.aborted || err instanceof ToolAbortError) { - // Benign control-flow exit — user cancel (^C) or compaction aborting - // pending operations both surface here as ToolAbortError. The outer - // catch and finally already mark the run aborted; logging at ERROR - // would spam operator dashboards with non-failures. - logger.debug("Subagent prompt aborted"); - } else { - logger.error("Subagent prompt failed", { - error: err instanceof Error ? err.message : String(err), + const runYieldLadder = async (): Promise => { + let retryCount = 0; + while (!monitor.yieldCalled() && retryCount < MAX_YIELD_RETRIES && !abortSignal.aborted) { + // A budget stop collapses the reminder ladder to a single forced + // final yield: wait for the stop's session abort to settle, then + // prompt once with the wrap-up reminder + named tool choice. + const budgetStop = monitor.budgetStopRequested(); + if (budgetStop) { + retryCount = MAX_YIELD_RETRIES - 1; + await monitor.waitForBudgetStop(); + if (monitor.yieldCalled() || abortSignal.aborted) break; + } + // Skip reminders when the model returned a terminal error (e.g. + // rate-limit cap hit, auth failure). Re-prompting would just + // hit the same wall, multiplying the failure noise without + // any chance of producing a yield. + const lastBeforeReminder = session.getLastAssistantMessage(); + if (lastBeforeReminder?.stopReason === "error") break; + try { + retryCount++; + const reminder = prompt.render(submitReminderTemplate, { + retryCount, + maxRetries: MAX_YIELD_RETRIES, + budgetStop, }); + + const isFinalRetry = retryCount >= MAX_YIELD_RETRIES; + await awaitAbortable( + session.prompt(reminder, { + attribution: "agent", + synthetic: true, + ...(isFinalRetry && reminderToolChoice ? { toolChoice: reminderToolChoice } : {}), + }), + ); + await awaitAbortable(session.waitForIdle()); + } catch (err) { + if (abortSignal.aborted || err instanceof ToolAbortError) { + // Benign control-flow exit — user cancel (^C) or compaction aborting + // pending operations both surface here as ToolAbortError. The outer + // catch and finally already mark the run aborted; logging at ERROR + // would spam operator dashboards with non-failures. + logger.debug("Subagent prompt aborted"); + } else { + logger.error("Subagent prompt failed", { + error: err instanceof Error ? err.message : String(err), + }); + } } } - } + }; - // Quiescence barrier (structured concurrency): a final yield with owner - // background jobs still running or undelivered is a scheduling pause, - // not run completion. Wait for the jobs to settle, let their - // async-result follow-up turns run to idle, and only then classify the - // terminal state — the isolation runner captures and destroys the - // worktree right after this run resolves, so no owner job that could - // still re-wake the session may outlive it. Suppressed (acknowledged / - // hub-watched) jobs never re-wake the run and are reaped at teardown. + // Yield ladder + quiescence barrier (structured concurrency), one + // loop: each iteration first demands a yield — initially, and again + // whenever an async-result delivery un-latched the previous one + // (including during the notice turn) — then either completes on + // quiescence or settles one generation of owner async work. + // + // A final yield with owner background jobs still running or + // undelivered is a scheduling pause, not run completion — the monitor + // parks such a yield with a recoverable turn-stop instead of + // terminating the run. Jobs are settled and their results folded into + // the run as async-result follow-up turns; each delivered result + // supersedes the yield it postdates, so the reminder ladder re-runs + // to demand a fresh yield that accounts for it. Only a yield with no + // pending owner work left is terminal — the isolation runner captures + // and destroys the worktree right after this run resolves, so no + // owner job that could still re-wake the session may outlive it. + // Suppressed (acknowledged / hub-watched) jobs never re-wake the run + // and are reaped at teardown. // // Before blocking on running jobs, tell the model ONCE what it is // waiting on so it can `hub` wait/cancel instead of sitting silent - // until the jobs (or the runtime limit) expire. Guarded to a single - // notice per run — a model that yields again with jobs still running - // is then waited out passively. Runs that never yielded (ladder - // exhausted / terminal model error) skip the barrier entirely — more + // until the jobs (or the runtime limit) expire. Runs that never yield + // (ladder exhausted / terminal model error) skip the barrier — more // injected turns just multiply the failure noise; the teardown reap // still cancels and awaits their jobs before worktree capture. let asyncPendingNoticeSent = false; - while (monitor.yieldCalled() && !abortSignal.aborted && session.hasPendingAsyncWork()) { + while (!abortSignal.aborted) { + if (!monitor.yieldCalled()) { + await runYieldLadder(); + // Ladder exhausted / terminal model error: classified below + // (missing yield, or stale yield when one was invalidated). + if (!monitor.yieldCalled()) break; + } + // Let the parked yield's turn-stop session abort settle before + // prompting again (mirrors waitForBudgetStop). + await awaitAbortable(monitor.waitForYieldTurnStop()); + if (!session.hasPendingAsyncWork()) break; if (!asyncPendingNoticeSent) { asyncPendingNoticeSent = true; const running = session.getAsyncJobSnapshot()?.running ?? []; @@ -1763,11 +1874,13 @@ async function driveSessionToYield( }); } // Re-evaluate: the notice turn may have cancelled, watched, or - // absorbed the jobs. + // absorbed the jobs — or already re-yielded. continue; } } await awaitAbortable(session.settleAsyncWork()); + // Results delivered during the settle invalidated the recorded + // yield: the next iteration's ladder demands a fresh one. } if (monitor.yieldCalled()) { @@ -1806,6 +1919,18 @@ async function driveSessionToYield( abortReasonText ??= monitor.resolveAbortReasonText(); exitCode = 1; } + + // A recorded yield that async-result deliveries superseded and the + // model never refreshed is stale: fail the run instead of letting the + // parent act on a payload that predates the background job outcomes + // the model was shown. The stale payload still ships through + // finalizeSubprocessOutput's failed-after-yield path (exit 1 + stderr, + // output preserved as salvage). + if (monitor.yieldInvalidatedByAsync() && !abortSignal.aborted) { + exitCode = 1; + error ??= + "Background job results arrived after the subagent's last yield; it did not submit a refreshed yield covering them."; + } } catch (err) { if (abortSignal.aborted && monitor.yieldCalled() && !monitor.runtimeLimitExceeded()) { exitCode = 0; diff --git a/packages/coding-agent/test/async-job-manager.test.ts b/packages/coding-agent/test/async-job-manager.test.ts index 303141ab5..1a336456a 100644 --- a/packages/coding-agent/test/async-job-manager.test.ts +++ b/packages/coding-agent/test/async-job-manager.test.ts @@ -333,7 +333,6 @@ describe("AsyncJobManager", () => { }); test("scoped delivery drain times out while a matching delivery callback is in flight", async () => { - let mainJobId = ""; let targetJobId = ""; let releaseMainDelivery = (): void => {}; let notifyMainDeliveryStarted = (): void => {}; @@ -363,7 +362,7 @@ describe("AsyncJobManager", () => { completions.push(jobId); }); - mainJobId = manager.register("task", "main job", async () => "main result", { ownerId: "0-Main" }); + manager.register("task", "main job", async () => "main result", { ownerId: "0-Main" }); targetJobId = manager.register("task", "subagent job", async () => "subagent result", { ownerId: "3-AuthLoader", }); diff --git a/packages/coding-agent/test/task/executor-async-quiescence.test.ts b/packages/coding-agent/test/task/executor-async-quiescence.test.ts new file mode 100644 index 000000000..25c3b3d90 --- /dev/null +++ b/packages/coding-agent/test/task/executor-async-quiescence.test.ts @@ -0,0 +1,253 @@ +/** + * Quiescence barrier fresh-yield contract (PR #6119 review): a terminal + * `yield` recorded while owner background jobs are still pending parks the + * run instead of terminating it, and an async-result delivered after that + * yield supersedes it — the run only completes on a yield that postdates + * every delivered result. A model that never refreshes its yield must fail + * the run rather than surface the stale payload as a clean success. + */ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import type { AssistantMessage } from "@oh-my-pi/pi-ai"; +import type { LoadExtensionsResult } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/types"; +import type { CreateAgentSessionResult } from "@oh-my-pi/pi-coding-agent/sdk"; +import * as sdkModule from "@oh-my-pi/pi-coding-agent/sdk"; +import type { AgentSession, AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { runSubprocess } from "@oh-my-pi/pi-coding-agent/task/executor"; +import type { AgentDefinition } from "@oh-my-pi/pi-coding-agent/task/types"; +import { EventBus } from "@oh-my-pi/pi-coding-agent/utils/event-bus"; + +const baseAgent: AgentDefinition = { name: "task", description: "test", systemPrompt: "test", source: "bundled" }; + +function assistantStopMessage(text: string): AssistantMessage { + return { + role: "assistant", + content: [{ type: "text", text }], + api: "openai-responses", + provider: "openai", + model: "mock", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; +} + +interface AsyncQuiescenceHarness { + session: AgentSession; + prompts: string[]; + abortCalls: () => number; + settleCalls: () => number; + emitTerminalYield: (data: unknown) => void; + finishJob: () => void; +} + +/** + * Mock session with the owner-async surface the barrier drives: + * `hasPendingAsyncWork` / `getAsyncJobSnapshot` / `settleAsyncWork`. The job + * "finishes" during the first settle, which injects the async-result + * follow-up (custom message_start) and a plain assistant reaction WITHOUT a + * fresh yield — exactly the review's stale-yield scenario. + */ +function createAsyncSession( + onPrompt: (params: { text: string; promptIndex: number; harness: AsyncQuiescenceHarness }) => void, +): AsyncQuiescenceHarness { + const listeners: Array<(event: AgentSessionEvent) => void> = []; + const state = { messages: [] as AssistantMessage[] }; + const prompts: string[] = []; + let abortCount = 0; + let settleCount = 0; + let pendingAsync = true; + let runningJobs: Array<{ id: string; label?: string }> = [{ id: "job-1", label: "background build" }]; + let toolCallSeq = 0; + + const emit = (event: AgentSessionEvent) => { + for (const listener of [...listeners]) listener(event); + }; + + const emitTerminalYield = (data: unknown) => { + toolCallSeq += 1; + emit({ + type: "tool_execution_end", + toolCallId: `yield-${toolCallSeq}`, + toolName: "yield", + result: { + content: [{ type: "text", text: "Result submitted." }], + details: { status: "success", data }, + }, + } as AgentSessionEvent); + }; + + const finishJob = () => { + pendingAsync = false; + runningJobs = []; + // Owner job completed: the session injects the async-result follow-up + // turn. The model reacts with text only — no fresh yield. + emit({ + type: "message_start", + message: { + role: "custom", + customType: "async-result", + content: "Background job job-1 has completed.\nexit 1: build FAILED", + display: true, + attribution: "agent", + timestamp: Date.now(), + }, + } as AgentSessionEvent); + const reaction = assistantStopMessage("The background build failed after I yielded."); + state.messages.push(reaction); + emit({ type: "message_end", message: reaction } as AgentSessionEvent); + }; + + const harness: AsyncQuiescenceHarness = { + session: undefined as unknown as AgentSession, + prompts, + abortCalls: () => abortCount, + settleCalls: () => settleCount, + emitTerminalYield, + finishJob, + }; + + const session = { + state, + agent: { state: { systemPrompt: ["test"] } }, + model: undefined, + extensionRunner: undefined, + sessionManager: { appendSessionInit: () => {} }, + getActiveToolNames: () => ["read", "yield"], + getEnabledToolNames: () => ["read", "yield"], + setActiveToolsByName: async (_toolNames: string[]) => {}, + subscribe: (listener: (event: AgentSessionEvent) => void) => { + listeners.push(listener); + return () => { + const index = listeners.indexOf(listener); + if (index >= 0) listeners.splice(index, 1); + }; + }, + prompt: async (text: string) => { + prompts.push(text); + onPrompt({ text, promptIndex: prompts.length, harness }); + }, + waitForIdle: async () => {}, + getLastAssistantMessage: () => state.messages[state.messages.length - 1], + hasPendingAsyncWork: () => pendingAsync, + getAsyncJobSnapshot: () => ({ running: runningJobs, recent: [] }), + settleAsyncWork: async () => { + settleCount += 1; + harness.finishJob(); + }, + abort: async () => { + abortCount += 1; + }, + dispose: async () => {}, + }; + harness.session = session as unknown as AgentSession; + return harness; +} + +function mockCreateAgentSession(session: AgentSession) { + return vi.spyOn(sdkModule, "createAgentSession").mockResolvedValue({ + session, + extensionsResult: {} as unknown as LoadExtensionsResult, + setToolUIContext: () => {}, + eventBus: new EventBus(), + } as CreateAgentSessionResult); +} + +describe("runSubprocess async quiescence fresh-yield contract", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("parks a pending yield, injects the result, and completes on the fresh yield", async () => { + const harness = createAsyncSession(({ promptIndex, harness: h }) => { + if (promptIndex === 1) { + // Terminal yield while the background job is still running. + h.emitTerminalYield({ report: "STALE: build passing (job still running)" }); + return; + } + if (promptIndex === 2) { + // Async-pending notice: the model stands by. The job then + // finishes during the barrier's settle. + return; + } + // Reminder ladder after the async-result invalidated the yield: + // submit the fresh yield that accounts for the job outcome. + h.emitTerminalYield({ report: "FRESH: build failed, see job-1" }); + }); + mockCreateAgentSession(harness.session); + + const result = await runSubprocess({ + cwd: "/tmp", + agent: baseAgent, + task: "do the work", + index: 0, + id: "quiescence-fresh-yield", + }); + + // Run did not terminate on the parked yield: the barrier noticed, the + // job settled, and the ladder demanded exactly one more prompt. + expect(harness.prompts).toHaveLength(3); + expect(harness.prompts[1]).toContain("yield was recorded"); + expect(harness.settleCalls()).toBe(1); + // The parked yield stopped the turn without killing the run. + expect(harness.abortCalls()).toBeGreaterThanOrEqual(1); + // The fresh yield — not the stale one — is the result of record. + expect(result.exitCode).toBe(0); + expect(result.output).toContain("FRESH: build failed"); + expect(result.output).not.toContain("STALE"); + }); + + it("fails the run when the model never refreshes the superseded yield", async () => { + const harness = createAsyncSession(({ promptIndex, harness: h }) => { + if (promptIndex === 1) { + h.emitTerminalYield({ report: "STALE: build passing (job still running)" }); + } + // Notice and every reminder: the model never yields again. + }); + mockCreateAgentSession(harness.session); + + const result = await runSubprocess({ + cwd: "/tmp", + agent: baseAgent, + task: "do the work", + index: 0, + id: "quiescence-stale-refusal", + }); + + // task + notice + full reminder ladder (3). + expect(harness.prompts).toHaveLength(5); + // Stale payload must not read as success; it ships only as failed-run + // salvage with an explicit reason. + expect(result.exitCode).toBe(1); + expect(result.error).toContain("refreshed yield"); + expect(result.output).toContain("STALE: build passing"); + }); + + it("terminates immediately on yield when no owner async work is pending", async () => { + const harness = createAsyncSession(({ promptIndex, harness: h }) => { + if (promptIndex === 1) { + h.finishJob(); + h.emitTerminalYield({ report: "done" }); + } + }); + mockCreateAgentSession(harness.session); + + const result = await runSubprocess({ + cwd: "/tmp", + agent: baseAgent, + task: "do the work", + index: 0, + id: "quiescence-no-async", + }); + + expect(harness.prompts).toHaveLength(1); + expect(result.exitCode).toBe(0); + expect(result.output).toContain("done"); + }); +});