fix(coding-agent): detach hard-aborted refs so ensureLive can't route into a dead session

Preserving aborted refs on dispose exposed a latent invariant break: the
executor's hard-abort path (finalizeSubagentLifecycle) set status `aborted`
and disposed the session without detaching it. With the ref now retained, it
kept a dangling pointer to the disposed session, and ensureLive returns any
non-null ref.session before its revivability check — so hub focus / transcript
chat could route into a dead session.

- finalizeSubagentLifecycle: detach the session before disposing on the
  terminal hard-abort path, upholding the AgentRef invariant (session === null
  when aborted).
- release(tombstone): detach before dispose too (capture the live session
  first), same invariant.
- unregisterUnlessParked: preserve `aborted` refs only when already detached;
  an aborted ref still holding a live session is a bug and is unregistered
  rather than kept reachable.
- Regression test now asserts ensureLive rejects a tombstoned id as terminal.

Fixes #7250
This commit is contained in:
roboomp
2026-08-01 09:42:41 +00:00
parent d350dd8f84
commit 57732e9dcd
4 changed files with 32 additions and 18 deletions
+8 -1
View File
@@ -2364,7 +2364,14 @@ export async function finalizeSubagentLifecycle(args: {
const resumableAbort =
args.abortKind === "budget" && args.keepAlive && !args.isolated && args.reviveSession !== null;
if (args.aborted && !resumableAbort) {
if (ref && ownsRef) registry.setStatus(args.id, "aborted", ref);
if (ref && ownsRef) {
// Terminal hard kill: mark `aborted` and detach the session before
// disposing so the ref satisfies the AgentRef invariant (session null
// when aborted) — ensureLive/hub focus must treat it as terminal, never
// route into the disposed session.
registry.setStatus(args.id, "aborted", ref);
registry.detachSession(args.id, ref);
}
await disposeSession();
return;
}