fix(coding-agent): reclaim dead parked agent corpses on respawn

A parked, session-less agent-registry entry with no reviver permanently
poisoned its agent id for the process lifetime. `registerIfAvailable(input,
null)` refuses any existing entry, so a fresh subagent spawn reusing the id
died post-registration with "already owned by another session generation",
and messages to it failed with "is parked and cannot be revived". Such
corpses are left by an isolated run's park or an interrupted construction,
and there was no reclaim or GC path.

Add `AgentLifecycleManager.reclaimDeadCorpse`, which unregisters a ref only
when it is parked, holds no live session, is not owned by the manager (no
in-memory reviver) and has no park/revive in flight. The fresh-spawn path in
`createAgentSession` calls it on a collision and retries registration, so the
id becomes reusable. The registry's strict CAS contract is untouched; the
corpse's transcript stays readable at history://<id>.

Fixes #8490
This commit is contained in:
roboomp
2026-08-14 01:33:06 +00:00
parent ae2d3d6ea1
commit 5421add83b
4 changed files with 92 additions and 0 deletions
@@ -145,6 +145,60 @@ describe("AgentLifecycleManager", () => {
expect(ref?.sessionFile).toBe("/tmp/3-Sub.jsonl");
});
it("reclaimDeadCorpse frees a parked, session-less, unadopted id and refuses live/adopted refs (#8490)", async () => {
// A corpse: registered running, then parked with no session and no adoption
// (the isolated-run finalize / interrupted-construction outcome). It cannot
// be revived and would otherwise poison its id for the process lifetime.
const corpse = registry.register({
id: "Corpse-Sub",
displayName: "task",
kind: "sub",
session: null,
sessionFile: "/tmp/Corpse-Sub.jsonl",
status: "running",
});
registry.setStatus("Corpse-Sub", "parked", corpse);
await expect(lifecycle.ensureLive("Corpse-Sub")).rejects.toThrow(/parked and cannot be revived/);
// A live ref is never reclaimed.
const live = makeSessionStub();
registry.register({
id: "Live-Sub",
displayName: "task",
kind: "sub",
session: live.session,
status: "running",
});
expect(lifecycle.reclaimDeadCorpse("Live-Sub", registry.get("Live-Sub")!)).toBe(false);
expect(registry.get("Live-Sub")?.session).toBe(live.session);
// An adopted (revivable) parked agent is never reclaimed.
const adopted = registry.register({
id: "Adopted-Sub",
displayName: "task",
kind: "sub",
session: null,
sessionFile: "/tmp/Adopted-Sub.jsonl",
status: "parked",
});
lifecycle.adopt("Adopted-Sub", { idleTtlMs: 0, revive: async () => makeSessionStub().session }, adopted);
expect(lifecycle.reclaimDeadCorpse("Adopted-Sub", adopted)).toBe(false);
expect(registry.get("Adopted-Sub")).toBe(adopted);
// A stale expected ref (points at a different agent) is never reclaimed.
expect(lifecycle.reclaimDeadCorpse("Corpse-Sub", adopted)).toBe(false);
// The corpse is reclaimed, and its id becomes registerable again.
expect(lifecycle.reclaimDeadCorpse("Corpse-Sub", corpse)).toBe(true);
expect(registry.get("Corpse-Sub")).toBeUndefined();
const respawn = registry.registerIfAvailable(
{ id: "Corpse-Sub", displayName: "task", kind: "sub", session: null, status: "running" },
null,
);
expect(respawn?.status).toBe("running");
expect(registry.get("Corpse-Sub")).toBe(respawn);
});
it("concurrent ensureLive calls during a slow revive coalesce into one reviver run", async () => {
const gate = deferred();
const revived = makeSessionStub();