diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 353096747..dbefba996 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -860,6 +860,8 @@ interface SubagentRunMonitor { /** Whether the (attempted) abort counts as a cancelled run rather than an internal failure. */ isAbortedRun(): boolean; requestAbort(reason: AbortReason): void; + abortActiveSession(): Promise; + waitForActiveSessionAbort(): Promise; resolveSignalAbortReason(): string; resolveAbortReasonText(): string; setActiveSession(session: AgentSession | null): void; @@ -929,6 +931,22 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor { let budgetSteerSent = false; let budgetLimitExceeded = false; let lastAssistantSalvageText: string | undefined; + let activeSessionAbortPromise: Promise | undefined; + + const abortActiveSession = (): Promise => { + const session = activeSession; + if (!session) return Promise.resolve(); + activeSessionAbortPromise ??= session.abort().catch(error => { + logger.debug("Subagent session abort cleanup failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); + return activeSessionAbortPromise; + }; + + const waitForActiveSessionAbort = async (): Promise => { + if (activeSessionAbortPromise) await activeSessionAbortPromise; + }; const requestAbort = (reason: AbortReason) => { if (reason === "timeout") { @@ -947,9 +965,7 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor { abortSent = true; abortReason = reason; abortController.abort(); - if (activeSession) { - void activeSession.abort(); - } + void abortActiveSession(); }; // Handle abort signal @@ -1429,6 +1445,8 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor { isAbortedRun: () => abortReason === "signal" || runtimeLimitExceeded || budgetLimitExceeded || abortReason === undefined, requestAbort, + abortActiveSession, + waitForActiveSessionAbort, resolveSignalAbortReason, resolveAbortReasonText, setActiveSession: session => { @@ -2270,7 +2288,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise { - void session.abort(); + void monitor.abortActiveSession(); }, { once: true, signal: sessionAbortController.signal }, ); @@ -2278,7 +2296,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise> = []; @@ -2384,6 +2402,11 @@ export async function runSubprocess(options: ExecutorOptions): Promise monitor.waitForActiveSessionAbort()); + } catch { + // Ignore abort cleanup timeouts/errors; terminal disposal below is still best-effort. + } if (unsubscribe) { try { unsubscribe(); diff --git a/packages/coding-agent/test/task/executor-subagent-reminders.test.ts b/packages/coding-agent/test/task/executor-subagent-reminders.test.ts index d3023329f..b475eb4e8 100644 --- a/packages/coding-agent/test/task/executor-subagent-reminders.test.ts +++ b/packages/coding-agent/test/task/executor-subagent-reminders.test.ts @@ -43,7 +43,7 @@ function createMockSession( promptIndex: number; emit: (event: AgentSessionEvent) => void; state: { messages: AssistantMessage[] }; - }) => void, + }) => void | Promise, ): AgentSession { const listeners: Array<(event: AgentSessionEvent) => void> = []; const state = { messages: [] as AssistantMessage[] }; @@ -72,7 +72,7 @@ function createMockSession( }, prompt: async (text: string, options?: PromptOptions) => { promptIndex += 1; - onPrompt({ text, options, promptIndex, emit, state }); + await onPrompt({ text, options, promptIndex, emit, state }); }, waitForIdle: async () => {}, getLastAssistantMessage: () => state.messages[state.messages.length - 1], @@ -341,6 +341,66 @@ describe("runSubprocess yield reminders", () => { expect(result.output).toContain('"ok": true'); }); + it("waits for yield-triggered abort cleanup before resolving the subagent", async () => { + const promptCleanup = Promise.withResolvers(); + const abortCleanup = Promise.withResolvers(); + const validYieldEmitted = Promise.withResolvers(); + let abortCalls = 0; + const session = createMockSession(async ({ promptIndex, emit, state }) => { + if (promptIndex === 1) { + const assistant = createAssistantStopMessage("malformed yield attempt"); + state.messages.push(assistant); + emit({ type: "message_end", message: assistant }); + emit({ + type: "tool_execution_end", + toolCallId: "tool-malformed", + toolName: "yield", + result: { + content: [{ type: "text", text: "result must be an object containing either data or error" }], + details: { status: "error", error: "result must be an object containing either data or error" }, + }, + isError: true, + }); + return; + } + + emit({ + type: "tool_execution_end", + toolCallId: "tool-success-after-malformed", + toolName: "yield", + result: { + content: [{ type: "text", text: "Result submitted." }], + details: { status: "success", data: { ok: true } }, + }, + isError: false, + }); + validYieldEmitted.resolve(); + await promptCleanup.promise; + }); + (session as unknown as { abort: () => Promise }).abort = async () => { + abortCalls += 1; + promptCleanup.resolve(); + await abortCleanup.promise; + }; + + mockCreateAgentSession(session); + + let settled = false; + const resultPromise = runSubprocess({ ...baseOptions, id: "subagent-yield-abort-cleanup" }).finally(() => { + settled = true; + }); + + await validYieldEmitted.promise; + await Bun.sleep(20); + expect(abortCalls).toBe(1); + expect(settled).toBe(false); + + abortCleanup.resolve(); + const result = await resultPromise; + expect(result.exitCode).toBe(0); + expect(result.output).toContain('"ok": true'); + }); + it("keeps a real run failure from being masked by a successful yield", () => { const result = finalizeSubprocessOutput({ rawOutput: "partial output",