fix(agent): scope shutdown reason to the owning session

Every session dispose broadcast the shutdown abort reason, so an explicit
hard kill of a subagent (release with tombstone, then live.dispose()) tagged
its nested children as shutdown and rediscovered them as parked instead of
terminal.

- Gate ASYNC_JOB_MANAGER_SHUTDOWN_REASON on #ownedAsyncJobManager so only the
  top-level owning session's dispose (genuine process shutdown) uses it.
- Subagent disposes propagate a generic cancellation, keeping nested children
  terminal.
- Cover the subagent generic-cancel path alongside the owning-session shutdown.

Fixes #8216
This commit is contained in:
roboomp
2026-08-11 06:40:44 +00:00
parent 8614979281
commit 546016596e
2 changed files with 51 additions and 5 deletions
@@ -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 {
@@ -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<void>();
let abortReason: unknown;
shared.register(
"task",
"nested child",
async ({ signal }) => {
const aborted = Promise.withResolvers<void>();
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<void>();