diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 8303e37e9..c4a53d6df 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -3743,10 +3743,14 @@ export class AgentSession { // dead-letter rather than enqueue a follow-up into a disposing session. this.#unregisterAsyncDeliverySink?.(); this.#unregisterAsyncDeliverySink = undefined; - // Process shutdown, not an explicit kill: tag owned jobs so the task - // executor parks (rather than tombstones) any subagent interrupted here. - this.#cancelOwnAsyncJobs(ASYNC_JOB_MANAGER_SHUTDOWN_REASON); const manager = this.#ownedAsyncJobManager; + // The shutdown reason is reserved for the top-level session that OWNS the + // manager — the genuine process/handled-shutdown path — so the task + // executor parks (rather than tombstones) interrupted subagents. A + // subagent session dispose (e.g. `release({ tombstone: true })` during an + // explicit hard kill) leaves `#ownedAsyncJobManager` undefined and must + // propagate a generic cancellation so its nested children stay terminal. + this.#cancelOwnAsyncJobs(manager ? ASYNC_JOB_MANAGER_SHUTDOWN_REASON : undefined); if (!manager) return; try { diff --git a/packages/coding-agent/test/agent-session-dispose-concurrent.test.ts b/packages/coding-agent/test/agent-session-dispose-concurrent.test.ts index 330e27d4b..f4d3600fe 100644 --- a/packages/coding-agent/test/agent-session-dispose-concurrent.test.ts +++ b/packages/coding-agent/test/agent-session-dispose-concurrent.test.ts @@ -41,7 +41,10 @@ describe("AgentSession concurrent disposal", () => { tempDir.removeSync(); }); - function createSession(ownedAsyncJobManager?: AsyncJobManager): AgentSession { + function createSession( + ownedAsyncJobManager?: AsyncJobManager, + options?: { agentId?: string; asyncJobManager?: AsyncJobManager }, + ): AgentSession { const model = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!model) throw new Error("expected bundled model"); const mock = createMockModel({ handler: () => ({ content: ["ok"] }) }); @@ -56,7 +59,8 @@ describe("AgentSession concurrent disposal", () => { settings: Settings.isolated(), modelRegistry: new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")), ownedAsyncJobManager, - agentId: "Main", + asyncJobManager: options?.asyncJobManager, + agentId: options?.agentId ?? "Main", }); return session; } @@ -97,6 +101,44 @@ describe("AgentSession concurrent disposal", () => { expect(abortReason).toBe(ASYNC_JOB_MANAGER_SHUTDOWN_REASON); }); + it("propagates a generic cancellation for a subagent dispose so nested children stay terminal", async () => { + // A subagent session leaves `ownedAsyncJobManager` undefined and inherits + // the shared manager. Its dispose (e.g. `release({ tombstone: true })` + // during an explicit hard kill) must NOT tag its owned jobs as shutdown, + // or nested children would be rediscovered as parked instead of terminal. + const shared = new AsyncJobManager({ maxRunningJobs: 1 }); + const started = Promise.withResolvers(); + let abortReason: unknown; + shared.register( + "task", + "nested child", + async ({ signal }) => { + const aborted = Promise.withResolvers(); + signal.addEventListener( + "abort", + () => { + abortReason = signal.reason; + aborted.resolve(); + }, + { once: true }, + ); + started.resolve(); + await aborted.promise; + return "stopped"; + }, + { ownerId: "Sub", agentId: "NestedChild" }, + ); + const current = createSession(undefined, { agentId: "Sub", asyncJobManager: shared }); + + await started.promise; + await current.dispose(); + session = undefined; + + expect(abortReason).not.toBe(ASYNC_JOB_MANAGER_SHUTDOWN_REASON); + expect(abortReason).toBeInstanceOf(DOMException); + await shared.dispose({ timeoutMs: 1_000 }); + }); + it("starts independent writers together and closes persistence after their barrier", async () => { const owned = new AsyncJobManager({ maxRunningJobs: 1, retentionMs: 1_000, onJobComplete: () => {} }); const asyncGate = Promise.withResolvers();