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:
@@ -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>();
|
||||
|
||||
Reference in New Issue
Block a user