From ab6e2e837ad0f636f11f0a8131c28d779b249ced Mon Sep 17 00:00:00 2001 From: issacboy123 Date: Thu, 27 Aug 2026 16:18:15 -0700 Subject: [PATCH 1/3] fix(coding-agent): propagate child prompt cancellation --- .../src/core/subagents/controller.ts | 4 +-- .../test/suite/subagent-startup.test.ts | 31 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/core/subagents/controller.ts b/packages/coding-agent/src/core/subagents/controller.ts index 92dc6bb3e..e03454947 100644 --- a/packages/coding-agent/src/core/subagents/controller.ts +++ b/packages/coding-agent/src/core/subagents/controller.ts @@ -1423,7 +1423,7 @@ export class SubagentController { assistantMessages.push(event.message); } }); - await activeSession.prompt(prompt); + await activeSession.prompt(prompt, { signal: runController.signal }); if (this.#closed) return; const terminalMessage = assistantMessages.at(-1); @@ -1483,7 +1483,7 @@ export class SubagentController { #detachRecords(reason: Error): SubagentRecord[] { const records = [...this.#records.values()]; for (const record of records) { - record.runController?.abort(reason); + if (record.status === "queued") record.runController?.abort(reason); } this.#records.clear(); return records; diff --git a/packages/coding-agent/test/suite/subagent-startup.test.ts b/packages/coding-agent/test/suite/subagent-startup.test.ts index 8ecf400fd..fbbd496bd 100644 --- a/packages/coding-agent/test/suite/subagent-startup.test.ts +++ b/packages/coding-agent/test/suite/subagent-startup.test.ts @@ -188,6 +188,37 @@ describe("asynchronous subagent startup", () => { expect(events.map((event) => event.type)).toEqual(["subagent_reserved", "subagent_controller_disposed"]); }); + it("interrupts an attached child during prompt preflight without starting the provider", async () => { + const { harness, controller, createSession } = await setup(); + const authStarted = deferred(); + let child: AgentSession | undefined; + harness.setResponses([fauxAssistantMessage("must not run")]); + vi.spyOn(harness.session.modelRuntime, "hasConfiguredAuth").mockReturnValue(false); + vi.spyOn(harness.session.modelRuntime, "checkAuth").mockImplementation(async (_providerId, options) => { + authStarted.resolve(); + const signal = options?.signal; + if (!signal) throw new Error("Expected the controller run signal during prompt preflight"); + return new Promise((_resolve, reject) => { + if (signal.aborted) { + reject(signal.reason); + return; + } + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }); + }); + controller.spawn("worker", "task", async (signal) => { + child = await createSession(signal); + return child; + }); + await authStarted.promise; + + expect(controller.snapshot().agents).toMatchObject([{ name: "worker", status: "queued" }]); + await expect(controller.interrupt("worker")).resolves.toMatchObject({ status: "interrupted" }); + expect(controller.snapshot().agents).toMatchObject([{ name: "worker", status: "interrupted" }]); + expect(child?.messages).toEqual([]); + expect(harness.faux.state.callCount).toBe(0); + }); + it("aborts and disposes an executing child once during shutdown", async () => { const { harness, controller, createSession } = await setup(); const response = deferred(); From 7d0b14bd4b0ec293a26f95c0c63ce7913f703bfc Mon Sep 17 00:00:00 2001 From: issacboy123 Date: Thu, 27 Aug 2026 16:32:28 -0700 Subject: [PATCH 2/3] fix(coding-agent): serialize child shutdown cancellation --- .../src/core/subagents/controller.ts | 63 +++++++++++-------- .../test/suite/subagent-startup.test.ts | 50 +++++++++++++++ 2 files changed, 88 insertions(+), 25 deletions(-) diff --git a/packages/coding-agent/src/core/subagents/controller.ts b/packages/coding-agent/src/core/subagents/controller.ts index e03454947..514848578 100644 --- a/packages/coding-agent/src/core/subagents/controller.ts +++ b/packages/coding-agent/src/core/subagents/controller.ts @@ -99,6 +99,7 @@ interface SubagentRecord { run?: Promise; createSession?: CreateSubagentSession; runController?: AbortController; + runCancellation?: Promise; interruptionRequested?: boolean; actionTail?: Promise; cleanupFailures?: unknown[]; @@ -810,11 +811,7 @@ export class SubagentController { async #quiesceRecord(record: SubagentRecord): Promise { record.session?.clearQueue(); record.interruptionRequested = true; - if (record.status === "queued") { - record.runController?.abort(new Error(`Subagent interrupted: ${record.name}`)); - } else if (record.session) { - await record.session.abort(); - } + await this.#requestRunCancellation(record, new Error(`Subagent interrupted: ${record.name}`)); await record.run; if (record.status === "queued" || record.status === "running") this.markInterrupted(record.name); } @@ -1318,14 +1315,10 @@ export class SubagentController { record.interruptionRequested = true; const run = record.run; - if (record.status === "queued") { - record.runController?.abort(new Error(`Subagent interrupted: ${name}`)); - } else if (record.session) { - try { - await record.session.abort(); - } catch { - // The captured run still determines the settled controller state. - } + try { + await this.#requestRunCancellation(record, new Error(`Subagent interrupted: ${name}`)); + } catch { + // The captured run still determines the settled controller state. } await run; if (record.status === "queued" || record.status === "running") { @@ -1342,6 +1335,7 @@ export class SubagentController { record.interruptionRequested = false; const runController = new AbortController(); record.runController = runController; + record.runCancellation = undefined; const run = this.#runPrompt(record, prompt, runController); record.run = run; void run.catch(() => {}); @@ -1483,31 +1477,50 @@ export class SubagentController { #detachRecords(reason: Error): SubagentRecord[] { const records = [...this.#records.values()]; for (const record of records) { - if (record.status === "queued") record.runController?.abort(reason); + void this.#requestRunCancellation(record, reason); } this.#records.clear(); return records; } + #requestRunCancellation(record: SubagentRecord, reason: Error): Promise { + if (record.runCancellation) return record.runCancellation; + + let cancellation: Promise; + // AgentSession marks streaming before awaiting agent_start hooks, while this record can still be queued. + if (record.runController && !record.session?.isStreaming) { + record.runController.abort(reason); + cancellation = Promise.resolve(); + } else if (record.session) { + try { + cancellation = record.session.abort(); + } catch (error) { + cancellation = Promise.reject(error); + } + } else { + record.runController?.abort(reason); + cancellation = Promise.resolve(); + } + + record.runCancellation = cancellation; + void cancellation.catch(() => {}); + return cancellation; + } + async #cleanupRecords(records: SubagentRecord[]): Promise { const failures: unknown[] = []; for (const record of records) record.leafUnsubscribe?.(); - const abortResults = await Promise.allSettled( - records.flatMap((record) => { - const session = record.session; - if (!session) return []; + for (const record of records) { + const session = record.session; + if (session) { try { session.clearQueue(); } catch (error) { failures.push(error); } - try { - return [session.abort()]; - } catch (error) { - return [Promise.reject(error)]; - } - }), - ); + } + } + const abortResults = await Promise.allSettled(records.map((record) => record.runCancellation)); for (const result of abortResults) { if (result.status === "rejected") failures.push(result.reason); } diff --git a/packages/coding-agent/test/suite/subagent-startup.test.ts b/packages/coding-agent/test/suite/subagent-startup.test.ts index fbbd496bd..cc29f4673 100644 --- a/packages/coding-agent/test/suite/subagent-startup.test.ts +++ b/packages/coding-agent/test/suite/subagent-startup.test.ts @@ -1,3 +1,4 @@ +import type { AgentEvent } from "@earendil-works/diffex-agent-core"; import type { AssistantMessage, Context } from "@earendil-works/diffex-ai"; import { fauxAssistantMessage } from "@earendil-works/diffex-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -14,6 +15,10 @@ import { waitForStatus, } from "./subagent-harness.ts"; +interface AgentSessionWithExtensionEmitHook { + _emitExtensionEvent: (event: AgentEvent) => Promise; +} + describe("asynchronous subagent startup", () => { const harnesses: SubagentTestHarness[] = []; @@ -219,6 +224,51 @@ describe("asynchronous subagent startup", () => { expect(harness.faux.state.callCount).toBe(0); }); + it("serializes shutdown cancellation during agent_start publication", async () => { + const { harness, controller, createSession } = await setup(); + const childReady = deferred(); + const allowChildReturn = deferred(); + const agentStartHandling = deferred(); + const allowAgentStartHandling = deferred(); + const events: SubagentLifecycleEvent[] = []; + let child: AgentSession | undefined; + harness.setResponses([fauxAssistantMessage("must not run")]); + controller.subscribe((event) => events.push(event)); + controller.spawn("worker", "task", async (signal) => { + child = await createSession(signal); + childReady.resolve(); + await allowChildReturn.promise; + return child; + }); + await childReady.promise; + + const activeChild = child!; + const sessionWithHook = activeChild as unknown as AgentSessionWithExtensionEmitHook; + const emitExtensionEvent = sessionWithHook._emitExtensionEvent.bind(sessionWithHook); + sessionWithHook._emitExtensionEvent = async (event) => { + if (event.type === "agent_start") { + agentStartHandling.resolve(); + await allowAgentStartHandling.promise; + } + await emitExtensionEvent(event); + }; + const abort = vi.spyOn(activeChild, "abort"); + const dispose = vi.spyOn(activeChild, "dispose"); + allowChildReturn.resolve(); + await agentStartHandling.promise; + + expect(controller.snapshot().agents).toMatchObject([{ name: "worker", status: "queued" }]); + const shutdown = controller.shutdown(); + expect(abort).toHaveBeenCalledOnce(); + allowAgentStartHandling.resolve(); + await shutdown; + + expect(abort).toHaveBeenCalledOnce(); + expect(dispose).toHaveBeenCalledOnce(); + expect(harness.faux.state.callCount).toBe(0); + expect(events.map((event) => event.type)).toEqual(["subagent_reserved", "subagent_controller_disposed"]); + }); + it("aborts and disposes an executing child once during shutdown", async () => { const { harness, controller, createSession } = await setup(); const response = deferred(); From 371f7b04787d86543aa2d29a239c0ad52fb36291 Mon Sep 17 00:00:00 2001 From: issacboy123 Date: Thu, 27 Aug 2026 16:41:01 -0700 Subject: [PATCH 3/3] fix(coding-agent): retry failed child cancellation --- .../coding-agent/src/core/subagents/controller.ts | 6 +++++- .../test/suite/subagent-persistence.test.ts | 14 +++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/src/core/subagents/controller.ts b/packages/coding-agent/src/core/subagents/controller.ts index 514848578..f9ae388f3 100644 --- a/packages/coding-agent/src/core/subagents/controller.ts +++ b/packages/coding-agent/src/core/subagents/controller.ts @@ -1503,7 +1503,11 @@ export class SubagentController { } record.runCancellation = cancellation; - void cancellation.catch(() => {}); + void cancellation.catch(() => { + if (record.runCancellation === cancellation) { + record.runCancellation = undefined; + } + }); return cancellation; } diff --git a/packages/coding-agent/test/suite/subagent-persistence.test.ts b/packages/coding-agent/test/suite/subagent-persistence.test.ts index 541dca050..336f3b84d 100644 --- a/packages/coding-agent/test/suite/subagent-persistence.test.ts +++ b/packages/coding-agent/test/suite/subagent-persistence.test.ts @@ -618,15 +618,16 @@ describe("durable subagent lifecycle", () => { expect(root.session.getSubagents()).toEqual([{ name: "target", status: "idle", task: "Inspect the change" }]); }); - it("retains the old leaf and roster when child quiescing fails", async () => { + it("retains the old leaf and roster when child quiescing fails and retries cancellation on shutdown", async () => { const childStarted = deferred(); - const releaseChild = deferred(); const root = await createRoot(); const targetId = createRosterBranch(root, []); root.harness.setResponses([ - async () => { + async (_context, request) => { childStarted.resolve(); - await releaseChild.promise; + await new Promise((resolvePromise) => { + request?.signal?.addEventListener("abort", () => resolvePromise(), { once: true }); + }); return fauxAssistantMessage("finished later"); }, ]); @@ -640,7 +641,10 @@ describe("durable subagent lifecycle", () => { expect(root.sessionManager.getLeafId()).toBe(currentLeafId); expect(root.session.getSubagents()[0]).toMatchObject({ name: "worker", status: "running" }); abort.mockRestore(); - releaseChild.resolve(); + const shutdownAbort = vi.spyOn(AgentSession.prototype, "abort"); + + await expect(root.session.dispose()).resolves.toBeUndefined(); + expect(shutdownAbort).toHaveBeenCalledOnce(); }); it("does not block a successful leaf change when a quiesce status snapshot fails", async () => {