From f940cc4f6ac083397ff6111d4180a89fd0b7c28d Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 31 Aug 2026 16:08:15 +0200 Subject: [PATCH 1/8] fix(coding-agent): report an empty resident session as idle instead of pinning it at working (ENG-5809) --- .../coding-agent/.changes/empty-session-idle-status.md | 1 + .../coding-agent/src/modes/daemon/daemon-session-list.ts | 6 ++++++ packages/coding-agent/test/daemon-session-list.test.ts | 9 +++++++++ 3 files changed, 16 insertions(+) create mode 100644 packages/coding-agent/.changes/empty-session-idle-status.md diff --git a/packages/coding-agent/.changes/empty-session-idle-status.md b/packages/coding-agent/.changes/empty-session-idle-status.md new file mode 100644 index 0000000000..f93584a897 --- /dev/null +++ b/packages/coding-agent/.changes/empty-session-idle-status.md @@ -0,0 +1 @@ +- Fixed `prime-agent list` pinning an abandoned empty session at "working" forever; an empty session with nothing in flight now reports "idle". diff --git a/packages/coding-agent/src/modes/daemon/daemon-session-list.ts b/packages/coding-agent/src/modes/daemon/daemon-session-list.ts index d41e83a67a..419e8ff0df 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-session-list.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-session-list.ts @@ -395,6 +395,12 @@ export function activeActivityForSession(activeSession: ActiveSessionState): Ses if (activeSession.runtime.metadata?.kind === "subagent") { return "idle"; } + // An empty session never gets a summarizer verdict either (there is nothing + // to classify), so don't hold it at "working" forever. A first turn that has + // not persisted a message yet is busy and already returned "working" above. + if (activeSession.runtime.session.messages.length === 0) { + return "idle"; + } // Hold at "working" until the idle verdict is current, so the view never // buckets an unlabeled idle session. return isSummaryCurrent(activeSession) ? "idle" : "working"; diff --git a/packages/coding-agent/test/daemon-session-list.test.ts b/packages/coding-agent/test/daemon-session-list.test.ts index 6cb772e27f..8bd4bb6dc8 100644 --- a/packages/coding-agent/test/daemon-session-list.test.ts +++ b/packages/coding-agent/test/daemon-session-list.test.ts @@ -230,6 +230,15 @@ describe("buildSessionList", () => { expect(summary.sessionActions).toMatchObject({ queuedCount: 0, active: { kind: "turn" } }); expect(summary.unfinishedActionCount).toBe(3); + // An empty session whose first turn is in flight is busy, not idle. + expect(summary.activity).toBe("working"); + }); + + it("marks an empty resident session idle instead of holding it at working", () => { + // No messages and nothing in flight: the summarizer never issues a verdict + // for an empty session, so it must not be pinned at "working" forever. + const summary = summaryForActiveSession(makeState({ activeSessionId: "empty" })); + expect(summary.activity).toBe("idle"); }); it("marks a finished subagent idle instead of holding it at working", () => { From a2dec9871b758d13855c56f37ad6ea4f93334663 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 31 Aug 2026 16:08:31 +0200 Subject: [PATCH 2/8] feat(coding-agent): evict an empty unnamed session's worker when its last client disconnects (ENG-5809) --- .../.changes/evict-empty-session-on-detach.md | 1 + .../src/modes/daemon/daemon-session-list.ts | 17 ++++++ .../src/modes/daemon/daemon-supervisor.ts | 56 +++++++++++++++++++ .../test/daemon-supervisor-eviction.test.ts | 50 +++++++++++++++++ 4 files changed, 124 insertions(+) create mode 100644 packages/coding-agent/.changes/evict-empty-session-on-detach.md diff --git a/packages/coding-agent/.changes/evict-empty-session-on-detach.md b/packages/coding-agent/.changes/evict-empty-session-on-detach.md new file mode 100644 index 0000000000..dc59645629 --- /dev/null +++ b/packages/coding-agent/.changes/evict-empty-session-on-detach.md @@ -0,0 +1 @@ +- Evict an empty, unnamed session's worker as soon as its last client disconnects, instead of parking it for the idle sweep; the on-disk draft session is preserved. diff --git a/packages/coding-agent/src/modes/daemon/daemon-session-list.ts b/packages/coding-agent/src/modes/daemon/daemon-session-list.ts index 419e8ff0df..462d156eea 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-session-list.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-session-list.ts @@ -110,6 +110,23 @@ export function isSessionSummaryBusy(summary: SessionSummary): boolean { return summary.isSessionActive || summary.hasRunningRlmChildren === true; } +/** + * An abandoned draft: no messages, no user-assigned name, nothing in flight or + * queued (isSessionActive covers unfinished actions), and no schedule pin. + * Safe to passivate as soon as its last client detaches instead of parking the + * worker for the idle sweep. Naming a session signals intent to return, so + * named sessions are exempt even when empty. + */ +export function isEvictableEmptySessionSummary(summary: SessionSummary): boolean { + return ( + summary.messageCount === 0 && + !summary.sessionName && + !isSessionSummaryBusy(summary) && + summary.hasRegisteredHeartbeat !== true && + summary.hasRegisteredCronJob !== true + ); +} + export function buildSessionList( activeSessions: readonly ActiveSessionState[], savedSessions: readonly SessionInfo[], diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 1db1f8eb72..a5dbed8410 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -89,6 +89,7 @@ import { getDaemonRuntimeIdentity } from "./daemon-runtime-identity.js"; import { matchesSessionIdSuffix } from "./daemon-session-id.js"; import { classifySessionRosterStatus, + isEvictableEmptySessionSummary, isSessionSummaryBusy, type SessionSummary, summaryForInactiveSession, @@ -914,6 +915,59 @@ export class DaemonSupervisor { } } + /** + * Passivate an abandoned empty draft as soon as its last client lets go + * instead of parking the worker for the idle sweep. Same passivation as idle + * eviction: the on-disk session (if any) survives and is classified draft by + * inactiveLifecycleForSession. Action-driven only; never rejects. + */ + private async evictEmptySessionOnLastDetach(activeSessionId: string): Promise { + if (this.shuttingDown || this.updateRestartPhase !== undefined) return; + const worker = this.matchWorkers(activeSessionId)[0]?.worker; + if ( + !worker || + worker.descriptor.lifecycle !== "ready" || + !worker.client || + // Client-owned workers have their own disconnect cleanup with a grace period. + worker.descriptor.ownerClientId !== undefined || + this.isWorkerStopping(worker) + ) { + return; + } + try { + // Refresh so the busy check sees an admitted first turn that has not + // persisted a message yet. + await this.refreshWorkerSummaries(worker); + } catch { + return; // A stopping or disconnected worker is never a candidate. + } + if ( + this.shuttingDown || + this.updateRestartPhase !== undefined || + this.workers.get(worker.descriptor.workerId) !== worker || + this.isWorkerStopping(worker) + ) { + return; + } + const summaries = [...worker.summaries.values()]; + const hasAttachedClient = summaries.some((summary) => { + const summaryActiveSessionId = summary.activeSessionId ?? summary.id; + return [...this.clients].some((client) => client.attachedActiveSessionIds.has(summaryActiveSessionId)); + }); + if (summaries.length === 0 || hasAttachedClient || !summaries.every(isEvictableEmptySessionSummary)) { + return; + } + try { + await this.stopWorker(worker, true); + } catch (error) { + this.log(`Empty-session eviction failed for worker ${worker.descriptor.workerId}: ${String(error)}`); + return; + } + this.log( + `Evicted empty session worker ${worker.descriptor.workerId} root=${worker.descriptor.rootSessionId ?? worker.descriptor.rootActiveSessionId} on last client detach`, + ); + } + private async assertCurrentOwnership(): Promise { const ownership = this.ownership; if (!ownership) { @@ -1106,6 +1160,7 @@ export class DaemonSupervisor { for (const activeSessionId of [...client.attachedActiveSessionIds]) { client.attachedActiveSessionIds.delete(activeSessionId); void this.syncWorkerExtensionUi(activeSessionId); + void this.evictEmptySessionOnLastDetach(activeSessionId); } this.scheduleOwnedWorkerCleanupForClient(this.protocolClientId(client)); }; @@ -4174,6 +4229,7 @@ export class DaemonSupervisor { client.catchupPurposes?.delete(resolvedId); this.write(client, { type: "session_detached", activeSessionId: resolvedId }); void this.syncWorkerExtensionUi(resolvedId); + void this.evictEmptySessionOnLastDetach(resolvedId); } } diff --git a/packages/coding-agent/test/daemon-supervisor-eviction.test.ts b/packages/coding-agent/test/daemon-supervisor-eviction.test.ts index 6611372dda..82010ae4c0 100644 --- a/packages/coding-agent/test/daemon-supervisor-eviction.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-eviction.test.ts @@ -442,3 +442,53 @@ describe("daemon supervisor whole-tree eviction", () => { expect(supervisor.createOrReuseWorker).not.toHaveBeenCalled(); }); }); + +describe("daemon supervisor empty-session eviction on detach", () => { + function makeDetachClient(id: string, attached: string[]) { + return { id, attachedActiveSessionIds: new Set(attached), socket: { destroyed: true } }; + } + + async function settle(): Promise { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + + it("evicts an empty unnamed session only when its last client detaches", async () => { + const now = Date.parse("2026-08-01T12:00:00.000Z"); + const supervisor = makeSupervisor(); + const empty = makeWorker("empty", [makeSummary("empty-root", now, { messageCount: 0 })]); + supervisor.workers.set("empty", empty); + const first = makeDetachClient("first", ["empty-root"]); + const second = makeDetachClient("second", ["empty-root"]); + supervisor.clients.add(first); + supervisor.clients.add(second); + + await supervisor.handleCommand(first, { id: "detach-1", type: "detach", activeSessionId: "empty-root" }); + await settle(); + expect(supervisor.stopWorker).not.toHaveBeenCalled(); + + await supervisor.handleCommand(second, { id: "detach-2", type: "detach", activeSessionId: "empty-root" }); + await vi.waitFor(() => expect(supervisor.stopWorker).toHaveBeenCalledWith(empty, true)); + expect(supervisor.workers.has("empty")).toBe(false); + expect(supervisor.log).toHaveBeenCalledWith(expect.stringContaining("Evicted empty session worker empty")); + }); + + it("keeps named, busy, and non-empty sessions resident when their last client detaches", async () => { + const now = Date.parse("2026-08-01T12:00:00.000Z"); + const supervisor = makeSupervisor(); + const named = makeWorker("named", [makeSummary("named-root", now, { messageCount: 0, sessionName: "keep me" })]); + // An empty session whose first turn is in flight reports busy before any message persists. + const busy = makeWorker("busy", [makeSummary("busy-root", now, { messageCount: 0, isSessionActive: true })]); + const oneMessage = makeWorker("one-message", [makeSummary("one-message-root", now)]); + for (const worker of [named, busy, oneMessage]) { + supervisor.workers.set(worker.descriptor.workerId, worker); + } + const client = makeDetachClient("viewer", ["named-root", "busy-root", "one-message-root"]); + supervisor.clients.add(client); + + await supervisor.handleCommand(client, { id: "detach-all", type: "detach" }); + await settle(); + + expect(supervisor.stopWorker).not.toHaveBeenCalled(); + expect([...supervisor.workers.keys()].sort()).toEqual(["busy", "named", "one-message"]); + }); +}); From b474e33e0f475bc21ebe5e99e336dc4245b9f9f7 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 31 Aug 2026 16:32:56 +0200 Subject: [PATCH 3/8] test(coding-agent): kill surviving empty-session eviction mutants (ENG-5809 review) Adversarial-review killer tests for three mutants that survived the shipped suite: dropping the heartbeat/cron registration checks from isEvictableEmptySessionSummary, dropping the client-owned worker exclusion, and removing the post-refresh identity/stopping re-check in evictEmptySessionOnLastDetach. --- .../test/daemon-supervisor-eviction.test.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/packages/coding-agent/test/daemon-supervisor-eviction.test.ts b/packages/coding-agent/test/daemon-supervisor-eviction.test.ts index 82010ae4c0..e60fe91502 100644 --- a/packages/coding-agent/test/daemon-supervisor-eviction.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-eviction.test.ts @@ -491,4 +491,54 @@ describe("daemon supervisor empty-session eviction on detach", () => { expect(supervisor.stopWorker).not.toHaveBeenCalled(); expect([...supervisor.workers.keys()].sort()).toEqual(["busy", "named", "one-message"]); }); + + it("keeps schedule-pinned and client-owned empty sessions resident when their last client detaches", async () => { + const now = Date.parse("2026-08-01T12:00:00.000Z"); + const supervisor = makeSupervisor(); + const heartbeat = makeWorker("heartbeat", [ + makeSummary("heartbeat-root", now, { messageCount: 0, hasRegisteredHeartbeat: true }), + ]); + const cron = makeWorker("cron", [makeSummary("cron-root", now, { messageCount: 0, hasRegisteredCronJob: true })]); + // Client-owned workers keep their own disconnect cleanup path. + const owned = makeWorker("owned", [makeSummary("owned-root", now, { messageCount: 0 })]); + owned.descriptor.ownerClientId = "owner"; + for (const worker of [heartbeat, cron, owned]) { + supervisor.workers.set(worker.descriptor.workerId, worker); + } + const client = makeDetachClient("viewer", ["heartbeat-root", "cron-root", "owned-root"]); + supervisor.clients.add(client); + + await supervisor.handleCommand(client, { id: "detach-all", type: "detach" }); + await settle(); + + expect(supervisor.stopWorker).not.toHaveBeenCalled(); + expect([...supervisor.workers.keys()].sort()).toEqual(["cron", "heartbeat", "owned"]); + }); + + it("does not stop a worker that was replaced while its summary refresh was in flight", async () => { + const now = Date.parse("2026-08-01T12:00:00.000Z"); + const supervisor = makeSupervisor(); + const worker = makeWorker("swap", [makeSummary("swap-root", now, { messageCount: 0 })]); + let releaseList!: () => void; + worker.client!.request.mockImplementation( + () => + new Promise((resolve) => { + releaseList = () => resolve(success(undefined, "list", { sessions: [...worker.summaries.values()] })); + }), + ); + supervisor.workers.set("swap", worker); + const client = makeDetachClient("viewer", ["swap-root"]); + supervisor.clients.add(client); + + await supervisor.handleCommand(client, { id: "detach", type: "detach", activeSessionId: "swap-root" }); + await vi.waitFor(() => expect(worker.client!.request).toHaveBeenCalled()); + // The worker is stopped and relaunched under a successor registration mid-refresh. + const successor = makeWorker("swap", [makeSummary("swap-root", now, { messageCount: 0 })]); + supervisor.workers.set("swap", successor); + releaseList(); + await settle(); + + expect(supervisor.stopWorker).not.toHaveBeenCalled(); + expect(supervisor.workers.get("swap")).toBe(successor); + }); }); From d66e76c2adbe836233d6add32aa5b57893a2974c Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 31 Aug 2026 16:42:01 +0200 Subject: [PATCH 4/8] chore(coding-agent): fold empty-session tests and trim comments --- .../src/modes/daemon/daemon-session-list.ts | 12 +-- .../src/modes/daemon/daemon-supervisor.ts | 15 +--- .../test/daemon-session-list.test.ts | 3 - .../test/daemon-supervisor-eviction.test.ts | 85 ++++++++----------- 4 files changed, 42 insertions(+), 73 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-session-list.ts b/packages/coding-agent/src/modes/daemon/daemon-session-list.ts index 462d156eea..d351fbc4b3 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-session-list.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-session-list.ts @@ -110,13 +110,7 @@ export function isSessionSummaryBusy(summary: SessionSummary): boolean { return summary.isSessionActive || summary.hasRunningRlmChildren === true; } -/** - * An abandoned draft: no messages, no user-assigned name, nothing in flight or - * queued (isSessionActive covers unfinished actions), and no schedule pin. - * Safe to passivate as soon as its last client detaches instead of parking the - * worker for the idle sweep. Naming a session signals intent to return, so - * named sessions are exempt even when empty. - */ +/** An abandoned draft, safe to passivate on last detach. Naming signals intent to return, so named sessions are exempt. */ export function isEvictableEmptySessionSummary(summary: SessionSummary): boolean { return ( summary.messageCount === 0 && @@ -412,9 +406,7 @@ export function activeActivityForSession(activeSession: ActiveSessionState): Ses if (activeSession.runtime.metadata?.kind === "subagent") { return "idle"; } - // An empty session never gets a summarizer verdict either (there is nothing - // to classify), so don't hold it at "working" forever. A first turn that has - // not persisted a message yet is busy and already returned "working" above. + // An empty session never gets a summarizer verdict; don't hold it at "working" forever. if (activeSession.runtime.session.messages.length === 0) { return "idle"; } diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index a5dbed8410..2f4b6aaaa2 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -915,12 +915,7 @@ export class DaemonSupervisor { } } - /** - * Passivate an abandoned empty draft as soon as its last client lets go - * instead of parking the worker for the idle sweep. Same passivation as idle - * eviction: the on-disk session (if any) survives and is classified draft by - * inactiveLifecycleForSession. Action-driven only; never rejects. - */ + /** Passivates an abandoned empty draft on last detach - same passivation as the idle sweep; never rejects. */ private async evictEmptySessionOnLastDetach(activeSessionId: string): Promise { if (this.shuttingDown || this.updateRestartPhase !== undefined) return; const worker = this.matchWorkers(activeSessionId)[0]?.worker; @@ -928,18 +923,16 @@ export class DaemonSupervisor { !worker || worker.descriptor.lifecycle !== "ready" || !worker.client || - // Client-owned workers have their own disconnect cleanup with a grace period. - worker.descriptor.ownerClientId !== undefined || + worker.descriptor.ownerClientId !== undefined || // owned workers have their own cleanup path this.isWorkerStopping(worker) ) { return; } try { - // Refresh so the busy check sees an admitted first turn that has not - // persisted a message yet. + // Fresh summaries, so an admitted-but-unpersisted first turn reads busy. await this.refreshWorkerSummaries(worker); } catch { - return; // A stopping or disconnected worker is never a candidate. + return; } if ( this.shuttingDown || diff --git a/packages/coding-agent/test/daemon-session-list.test.ts b/packages/coding-agent/test/daemon-session-list.test.ts index 8bd4bb6dc8..a6b52745af 100644 --- a/packages/coding-agent/test/daemon-session-list.test.ts +++ b/packages/coding-agent/test/daemon-session-list.test.ts @@ -230,13 +230,10 @@ describe("buildSessionList", () => { expect(summary.sessionActions).toMatchObject({ queuedCount: 0, active: { kind: "turn" } }); expect(summary.unfinishedActionCount).toBe(3); - // An empty session whose first turn is in flight is busy, not idle. expect(summary.activity).toBe("working"); }); it("marks an empty resident session idle instead of holding it at working", () => { - // No messages and nothing in flight: the summarizer never issues a verdict - // for an empty session, so it must not be pinned at "working" forever. const summary = summaryForActiveSession(makeState({ activeSessionId: "empty" })); expect(summary.activity).toBe("idle"); }); diff --git a/packages/coding-agent/test/daemon-supervisor-eviction.test.ts b/packages/coding-agent/test/daemon-supervisor-eviction.test.ts index e60fe91502..db4f1f2fc2 100644 --- a/packages/coding-agent/test/daemon-supervisor-eviction.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-eviction.test.ts @@ -452,67 +452,55 @@ describe("daemon supervisor empty-session eviction on detach", () => { await new Promise((resolve) => setTimeout(resolve, 25)); } - it("evicts an empty unnamed session only when its last client detaches", async () => { + it("evicts only abandoned empty unnamed sessions, and only on the last detach", async () => { const now = Date.parse("2026-08-01T12:00:00.000Z"); const supervisor = makeSupervisor(); const empty = makeWorker("empty", [makeSummary("empty-root", now, { messageCount: 0 })]); - supervisor.workers.set("empty", empty); + const exempt = [ + makeWorker("named", [makeSummary("named-root", now, { messageCount: 0, sessionName: "keep me" })]), + makeWorker("busy", [makeSummary("busy-root", now, { messageCount: 0, isSessionActive: true })]), + makeWorker("one-message", [makeSummary("one-message-root", now)]), + makeWorker("heartbeat", [ + makeSummary("heartbeat-root", now, { messageCount: 0, hasRegisteredHeartbeat: true }), + ]), + makeWorker("cron", [makeSummary("cron-root", now, { messageCount: 0, hasRegisteredCronJob: true })]), + makeWorker("owned", [makeSummary("owned-root", now, { messageCount: 0 })]), + ]; + exempt[5]!.descriptor.ownerClientId = "owner"; + for (const worker of [empty, ...exempt]) { + supervisor.workers.set(worker.descriptor.workerId, worker); + } const first = makeDetachClient("first", ["empty-root"]); - const second = makeDetachClient("second", ["empty-root"]); + const viewer = makeDetachClient("viewer", [ + "empty-root", + "named-root", + "busy-root", + "one-message-root", + "heartbeat-root", + "cron-root", + "owned-root", + ]); supervisor.clients.add(first); - supervisor.clients.add(second); + supervisor.clients.add(viewer); + // Not the last client of empty-root: nothing evicted. await supervisor.handleCommand(first, { id: "detach-1", type: "detach", activeSessionId: "empty-root" }); await settle(); expect(supervisor.stopWorker).not.toHaveBeenCalled(); - await supervisor.handleCommand(second, { id: "detach-2", type: "detach", activeSessionId: "empty-root" }); + await supervisor.handleCommand(viewer, { id: "detach-all", type: "detach" }); await vi.waitFor(() => expect(supervisor.stopWorker).toHaveBeenCalledWith(empty, true)); - expect(supervisor.workers.has("empty")).toBe(false); - expect(supervisor.log).toHaveBeenCalledWith(expect.stringContaining("Evicted empty session worker empty")); - }); - - it("keeps named, busy, and non-empty sessions resident when their last client detaches", async () => { - const now = Date.parse("2026-08-01T12:00:00.000Z"); - const supervisor = makeSupervisor(); - const named = makeWorker("named", [makeSummary("named-root", now, { messageCount: 0, sessionName: "keep me" })]); - // An empty session whose first turn is in flight reports busy before any message persists. - const busy = makeWorker("busy", [makeSummary("busy-root", now, { messageCount: 0, isSessionActive: true })]); - const oneMessage = makeWorker("one-message", [makeSummary("one-message-root", now)]); - for (const worker of [named, busy, oneMessage]) { - supervisor.workers.set(worker.descriptor.workerId, worker); - } - const client = makeDetachClient("viewer", ["named-root", "busy-root", "one-message-root"]); - supervisor.clients.add(client); - - await supervisor.handleCommand(client, { id: "detach-all", type: "detach" }); await settle(); - - expect(supervisor.stopWorker).not.toHaveBeenCalled(); - expect([...supervisor.workers.keys()].sort()).toEqual(["busy", "named", "one-message"]); - }); - - it("keeps schedule-pinned and client-owned empty sessions resident when their last client detaches", async () => { - const now = Date.parse("2026-08-01T12:00:00.000Z"); - const supervisor = makeSupervisor(); - const heartbeat = makeWorker("heartbeat", [ - makeSummary("heartbeat-root", now, { messageCount: 0, hasRegisteredHeartbeat: true }), + expect(supervisor.stopWorker).toHaveBeenCalledTimes(1); + expect([...supervisor.workers.keys()].sort()).toEqual([ + "busy", + "cron", + "heartbeat", + "named", + "one-message", + "owned", ]); - const cron = makeWorker("cron", [makeSummary("cron-root", now, { messageCount: 0, hasRegisteredCronJob: true })]); - // Client-owned workers keep their own disconnect cleanup path. - const owned = makeWorker("owned", [makeSummary("owned-root", now, { messageCount: 0 })]); - owned.descriptor.ownerClientId = "owner"; - for (const worker of [heartbeat, cron, owned]) { - supervisor.workers.set(worker.descriptor.workerId, worker); - } - const client = makeDetachClient("viewer", ["heartbeat-root", "cron-root", "owned-root"]); - supervisor.clients.add(client); - - await supervisor.handleCommand(client, { id: "detach-all", type: "detach" }); - await settle(); - - expect(supervisor.stopWorker).not.toHaveBeenCalled(); - expect([...supervisor.workers.keys()].sort()).toEqual(["cron", "heartbeat", "owned"]); + expect(supervisor.log).toHaveBeenCalledWith(expect.stringContaining("Evicted empty session worker empty")); }); it("does not stop a worker that was replaced while its summary refresh was in flight", async () => { @@ -532,7 +520,6 @@ describe("daemon supervisor empty-session eviction on detach", () => { await supervisor.handleCommand(client, { id: "detach", type: "detach", activeSessionId: "swap-root" }); await vi.waitFor(() => expect(worker.client!.request).toHaveBeenCalled()); - // The worker is stopped and relaunched under a successor registration mid-refresh. const successor = makeWorker("swap", [makeSummary("swap-root", now, { messageCount: 0 })]); supervisor.workers.set("swap", successor); releaseList(); From be47f2db269ca3e9587c8dfdfe2bdece92a3e0bb Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 31 Aug 2026 16:46:42 +0200 Subject: [PATCH 5/8] fix(coding-agent): drain admitted mutations before deciding empty-session eviction (ENG-5809 Macroscope) --- .../src/modes/daemon/daemon-supervisor.ts | 46 +++++++++++++------ .../test/daemon-supervisor-eviction.test.ts | 37 +++++++++++++++ 2 files changed, 69 insertions(+), 14 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 2f4b6aaaa2..4479bdad68 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -917,7 +917,7 @@ export class DaemonSupervisor { /** Passivates an abandoned empty draft on last detach - same passivation as the idle sweep; never rejects. */ private async evictEmptySessionOnLastDetach(activeSessionId: string): Promise { - if (this.shuttingDown || this.updateRestartPhase !== undefined) return; + if (this.shuttingDown || this.updateRestartPhase !== undefined || this.idleEvictionFence) return; const worker = this.matchWorkers(activeSessionId)[0]?.worker; if ( !worker || @@ -934,31 +934,49 @@ export class DaemonSupervisor { } catch { return; } + if (!this.isEmptyDetachEvictionCandidate(worker) || this.idleEvictionFence) return; + // Same coordination as the idle sweep: fence new mutations, drain admitted + // ones, then re-read so the final decision reflects them. + let releaseFence: () => void = () => {}; + const fence = new Promise((resolveFence) => { + releaseFence = resolveFence; + }); + this.idleEvictionFence = fence; + try { + await this.mutationDrain.waitForDrain( + 0, + AbortSignal.timeout(IDLE_EVICTION_DRAIN_TIMEOUT_MS), + "Timed out draining daemon mutations for empty-session eviction", + ); + await this.refreshWorkerSummaries(worker); + if (!this.isEmptyDetachEvictionCandidate(worker)) return; + await this.stopWorker(worker, true); + this.log( + `Evicted empty session worker ${worker.descriptor.workerId} root=${worker.descriptor.rootSessionId ?? worker.descriptor.rootActiveSessionId} on last client detach`, + ); + } catch (error) { + this.log(`Empty-session eviction failed for worker ${worker.descriptor.workerId}: ${String(error)}`); + } finally { + if (this.idleEvictionFence === fence) this.idleEvictionFence = undefined; + releaseFence(); + } + } + + private isEmptyDetachEvictionCandidate(worker: ResidentWorker): boolean { if ( this.shuttingDown || this.updateRestartPhase !== undefined || this.workers.get(worker.descriptor.workerId) !== worker || this.isWorkerStopping(worker) ) { - return; + return false; } const summaries = [...worker.summaries.values()]; const hasAttachedClient = summaries.some((summary) => { const summaryActiveSessionId = summary.activeSessionId ?? summary.id; return [...this.clients].some((client) => client.attachedActiveSessionIds.has(summaryActiveSessionId)); }); - if (summaries.length === 0 || hasAttachedClient || !summaries.every(isEvictableEmptySessionSummary)) { - return; - } - try { - await this.stopWorker(worker, true); - } catch (error) { - this.log(`Empty-session eviction failed for worker ${worker.descriptor.workerId}: ${String(error)}`); - return; - } - this.log( - `Evicted empty session worker ${worker.descriptor.workerId} root=${worker.descriptor.rootSessionId ?? worker.descriptor.rootActiveSessionId} on last client detach`, - ); + return summaries.length > 0 && !hasAttachedClient && summaries.every(isEvictableEmptySessionSummary); } private async assertCurrentOwnership(): Promise { diff --git a/packages/coding-agent/test/daemon-supervisor-eviction.test.ts b/packages/coding-agent/test/daemon-supervisor-eviction.test.ts index db4f1f2fc2..cf06b76cb6 100644 --- a/packages/coding-agent/test/daemon-supervisor-eviction.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-eviction.test.ts @@ -31,6 +31,7 @@ interface SupervisorInternals { workers: Map; clients: Set<{ id: string; attachedActiveSessionIds: Set }>; idleEvictionFence?: Promise; + mutationDrain: { begin(): void; end(): void }; catalog: { resolve: ReturnType; stop: ReturnType }; createOrReuseWorker: ReturnType; stopWorker: ReturnType; @@ -528,4 +529,40 @@ describe("daemon supervisor empty-session eviction on detach", () => { expect(supervisor.stopWorker).not.toHaveBeenCalled(); expect(supervisor.workers.get("swap")).toBe(successor); }); + + it("does not evict when a mutation admitted during the refresh registers a schedule", async () => { + const now = Date.parse("2026-08-01T12:00:00.000Z"); + const supervisor = makeSupervisor(); + const worker = makeWorker("gap", [makeSummary("gap-root", now, { messageCount: 0 })]); + let releaseList!: () => void; + worker.client!.request.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseList = () => + resolve( + success(undefined, "list", { sessions: [makeSummary("gap-root", now, { messageCount: 0 })] }), + ); + }), + ); + supervisor.workers.set("gap", worker); + const client = makeDetachClient("viewer", ["gap-root"]); + supervisor.clients.add(client); + + await supervisor.handleCommand(client, { id: "detach", type: "detach", activeSessionId: "gap-root" }); + await vi.waitFor(() => expect(worker.client!.request).toHaveBeenCalled()); + // A heartbeat_set admitted mid-refresh registers a schedule before the eviction decision. + supervisor.mutationDrain.begin(); + worker.client!.request.mockImplementation(async () => + success(undefined, "list", { + sessions: [makeSummary("gap-root", now, { messageCount: 0, hasRegisteredHeartbeat: true })], + }), + ); + releaseList(); + await settle(); + supervisor.mutationDrain.end(); + await settle(); + + expect(supervisor.stopWorker).not.toHaveBeenCalled(); + expect(supervisor.workers.get("gap")).toBe(worker); + }); }); From d63475f77dace6da68545df7d317a5b210528695 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 31 Aug 2026 16:53:05 +0200 Subject: [PATCH 6/8] chore(coding-agent): drop comments that restate the code --- .../coding-agent/src/modes/daemon/daemon-session-list.ts | 2 +- packages/coding-agent/src/modes/daemon/daemon-supervisor.ts | 5 +---- .../coding-agent/test/daemon-supervisor-eviction.test.ts | 1 - 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-session-list.ts b/packages/coding-agent/src/modes/daemon/daemon-session-list.ts index d351fbc4b3..8d6eeff5cc 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-session-list.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-session-list.ts @@ -110,7 +110,7 @@ export function isSessionSummaryBusy(summary: SessionSummary): boolean { return summary.isSessionActive || summary.hasRunningRlmChildren === true; } -/** An abandoned draft, safe to passivate on last detach. Naming signals intent to return, so named sessions are exempt. */ +/** Naming signals intent to return, so named sessions are exempt even when empty. */ export function isEvictableEmptySessionSummary(summary: SessionSummary): boolean { return ( summary.messageCount === 0 && diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 4479bdad68..5502aea44e 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -915,7 +915,6 @@ export class DaemonSupervisor { } } - /** Passivates an abandoned empty draft on last detach - same passivation as the idle sweep; never rejects. */ private async evictEmptySessionOnLastDetach(activeSessionId: string): Promise { if (this.shuttingDown || this.updateRestartPhase !== undefined || this.idleEvictionFence) return; const worker = this.matchWorkers(activeSessionId)[0]?.worker; @@ -929,14 +928,12 @@ export class DaemonSupervisor { return; } try { - // Fresh summaries, so an admitted-but-unpersisted first turn reads busy. await this.refreshWorkerSummaries(worker); } catch { return; } if (!this.isEmptyDetachEvictionCandidate(worker) || this.idleEvictionFence) return; - // Same coordination as the idle sweep: fence new mutations, drain admitted - // ones, then re-read so the final decision reflects them. + // Idle-sweep coordination: fence new mutations, drain admitted ones, re-read before deciding. let releaseFence: () => void = () => {}; const fence = new Promise((resolveFence) => { releaseFence = resolveFence; diff --git a/packages/coding-agent/test/daemon-supervisor-eviction.test.ts b/packages/coding-agent/test/daemon-supervisor-eviction.test.ts index cf06b76cb6..1d3bee3f93 100644 --- a/packages/coding-agent/test/daemon-supervisor-eviction.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-eviction.test.ts @@ -484,7 +484,6 @@ describe("daemon supervisor empty-session eviction on detach", () => { supervisor.clients.add(first); supervisor.clients.add(viewer); - // Not the last client of empty-root: nothing evicted. await supervisor.handleCommand(first, { id: "detach-1", type: "detach", activeSessionId: "empty-root" }); await settle(); expect(supervisor.stopWorker).not.toHaveBeenCalled(); From 89c788e789bbd54b5bfbf41f9a16b4b84279ec8d Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 31 Aug 2026 17:03:28 +0200 Subject: [PATCH 7/8] fix(coding-agent): share one eviction-fence acquisition between the idle sweep and detach eviction (ENG-5809 review) --- .../src/modes/daemon/daemon-supervisor.ts | 32 +++++----- .../test/daemon-supervisor-eviction.test.ts | 59 +++++++++++++++++++ 2 files changed, 77 insertions(+), 14 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 5502aea44e..1654656ae2 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -870,11 +870,7 @@ export class DaemonSupervisor { ); if (this.shuttingDown || this.updateRestartPhase !== undefined) return; - let releaseFence: () => void = () => {}; - const fence = new Promise((resolveFence) => { - releaseFence = resolveFence; - }); - this.idleEvictionFence = fence; + const releaseFence = await this.acquireIdleEvictionFence(); try { await this.mutationDrain.waitForDrain( 0, @@ -910,13 +906,26 @@ export class DaemonSupervisor { }), ); } finally { - if (this.idleEvictionFence === fence) this.idleEvictionFence = undefined; releaseFence(); } } + /** Waits for any held eviction fence, then takes the slot; release clears it only if still ours. */ + private async acquireIdleEvictionFence(): Promise<() => void> { + while (this.idleEvictionFence) await this.idleEvictionFence; + let releaseFence: () => void = () => {}; + const fence = new Promise((resolveFence) => { + releaseFence = resolveFence; + }); + this.idleEvictionFence = fence; + return () => { + if (this.idleEvictionFence === fence) this.idleEvictionFence = undefined; + releaseFence(); + }; + } + private async evictEmptySessionOnLastDetach(activeSessionId: string): Promise { - if (this.shuttingDown || this.updateRestartPhase !== undefined || this.idleEvictionFence) return; + if (this.shuttingDown || this.updateRestartPhase !== undefined) return; const worker = this.matchWorkers(activeSessionId)[0]?.worker; if ( !worker || @@ -932,13 +941,9 @@ export class DaemonSupervisor { } catch { return; } - if (!this.isEmptyDetachEvictionCandidate(worker) || this.idleEvictionFence) return; + if (!this.isEmptyDetachEvictionCandidate(worker)) return; // Idle-sweep coordination: fence new mutations, drain admitted ones, re-read before deciding. - let releaseFence: () => void = () => {}; - const fence = new Promise((resolveFence) => { - releaseFence = resolveFence; - }); - this.idleEvictionFence = fence; + const releaseFence = await this.acquireIdleEvictionFence(); try { await this.mutationDrain.waitForDrain( 0, @@ -954,7 +959,6 @@ export class DaemonSupervisor { } catch (error) { this.log(`Empty-session eviction failed for worker ${worker.descriptor.workerId}: ${String(error)}`); } finally { - if (this.idleEvictionFence === fence) this.idleEvictionFence = undefined; releaseFence(); } } diff --git a/packages/coding-agent/test/daemon-supervisor-eviction.test.ts b/packages/coding-agent/test/daemon-supervisor-eviction.test.ts index 1d3bee3f93..0941a3e552 100644 --- a/packages/coding-agent/test/daemon-supervisor-eviction.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-eviction.test.ts @@ -564,4 +564,63 @@ describe("daemon supervisor empty-session eviction on detach", () => { expect(supervisor.stopWorker).not.toHaveBeenCalled(); expect(supervisor.workers.get("gap")).toBe(worker); }); + + it("evicts every empty draft when one client detaches from several at once", async () => { + const now = Date.parse("2026-08-01T12:00:00.000Z"); + const supervisor = makeSupervisor(); + const draftA = makeWorker("draft-a", [makeSummary("draft-a-root", now, { messageCount: 0 })]); + const draftB = makeWorker("draft-b", [makeSummary("draft-b-root", now, { messageCount: 0 })]); + supervisor.workers.set("draft-a", draftA); + supervisor.workers.set("draft-b", draftB); + const client = makeDetachClient("viewer", ["draft-a-root", "draft-b-root"]); + supervisor.clients.add(client); + + await supervisor.handleCommand(client, { id: "detach-all", type: "detach" }); + + await vi.waitFor(() => expect(supervisor.stopWorker).toHaveBeenCalledTimes(2)); + expect(supervisor.workers.size).toBe(0); + }); + + it("makes a starting sweep wait for the detach fence instead of overwriting it", async () => { + const now = Date.parse("2026-08-01T12:00:00.000Z"); + const supervisor = makeSupervisor(); + // Recent activity keeps the worker out of the sweep's own idle candidates. + const emptySessions = () => [ + makeSummary("gap-root", now, { messageCount: 0, lastActivityAt: new Date(now).toISOString() }), + ]; + const worker = makeWorker("gap", emptySessions()); + let releaseSweepList!: () => void; + let releaseHookList!: () => void; + worker + .client!.request.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseSweepList = () => resolve(success(undefined, "list", { sessions: emptySessions() })); + }), + ) + .mockImplementationOnce(async () => success(undefined, "list", { sessions: emptySessions() })) + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseHookList = () => resolve(success(undefined, "list", { sessions: emptySessions() })); + }), + ); + supervisor.workers.set("gap", worker); + const client = makeDetachClient("viewer", ["gap-root"]); + supervisor.clients.add(client); + + const sweep = supervisor.runIdleEvictionSweep(now); + await vi.waitFor(() => expect(worker.client!.request).toHaveBeenCalledTimes(1)); + await supervisor.handleCommand(client, { id: "detach", type: "detach", activeSessionId: "gap-root" }); + await vi.waitFor(() => expect(worker.client!.request).toHaveBeenCalledTimes(3)); + releaseSweepList(); + await settle(); + + // The hook is still mid-decision, so its fence must still be in the slot. + expect(supervisor.idleEvictionFence).toBeDefined(); + releaseHookList(); + await sweep; + expect(supervisor.stopWorker).toHaveBeenCalledTimes(1); + expect(supervisor.stopWorker).toHaveBeenCalledWith(worker, true); + }); }); From 21143265a277746623739be1929f6e566bcf1514 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 31 Aug 2026 17:26:12 +0200 Subject: [PATCH 8/8] refactor(coding-agent): extract the shared fenced passivation primitive (ENG-5809) --- .../src/modes/daemon/daemon-supervisor.ts | 57 +++++++++++-------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 1654656ae2..5ba0b3e67c 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -870,13 +870,7 @@ export class DaemonSupervisor { ); if (this.shuttingDown || this.updateRestartPhase !== undefined) return; - const releaseFence = await this.acquireIdleEvictionFence(); - try { - await this.mutationDrain.waitForDrain( - 0, - AbortSignal.timeout(IDLE_EVICTION_DRAIN_TIMEOUT_MS), - "Timed out draining daemon mutations for idle eviction", - ); + await this.withEvictionFence("Timed out draining daemon mutations for idle eviction", async () => { if (this.shuttingDown || this.updateRestartPhase !== undefined) return; await Promise.all( candidates.map((worker) => this.refreshWorkerSummaries(worker).catch(() => refreshed.delete(worker))), @@ -905,9 +899,7 @@ export class DaemonSupervisor { ); }), ); - } finally { - releaseFence(); - } + }); } /** Waits for any held eviction fence, then takes the slot; release clears it only if still ours. */ @@ -924,6 +916,29 @@ export class DaemonSupervisor { }; } + /** Runs a passivation decision under the eviction fence after draining admitted mutations. */ + private async withEvictionFence(drainMessage: string, action: () => Promise): Promise { + const releaseFence = await this.acquireIdleEvictionFence(); + try { + await this.mutationDrain.waitForDrain(0, AbortSignal.timeout(IDLE_EVICTION_DRAIN_TIMEOUT_MS), drainMessage); + await action(); + } finally { + releaseFence(); + } + } + + /** Re-validates one worker on fresh summaries under the caller's fence, then passivates it. */ + private async passivateWorkerIfStillEligible( + worker: ResidentWorker, + isStillEligible: () => boolean, + describeEvicted: () => string, + ): Promise { + await this.refreshWorkerSummaries(worker); + if (!isStillEligible()) return; + await this.stopWorker(worker, true); + this.log(describeEvicted()); + } + private async evictEmptySessionOnLastDetach(activeSessionId: string): Promise { if (this.shuttingDown || this.updateRestartPhase !== undefined) return; const worker = this.matchWorkers(activeSessionId)[0]?.worker; @@ -942,24 +957,18 @@ export class DaemonSupervisor { return; } if (!this.isEmptyDetachEvictionCandidate(worker)) return; - // Idle-sweep coordination: fence new mutations, drain admitted ones, re-read before deciding. - const releaseFence = await this.acquireIdleEvictionFence(); try { - await this.mutationDrain.waitForDrain( - 0, - AbortSignal.timeout(IDLE_EVICTION_DRAIN_TIMEOUT_MS), - "Timed out draining daemon mutations for empty-session eviction", - ); - await this.refreshWorkerSummaries(worker); - if (!this.isEmptyDetachEvictionCandidate(worker)) return; - await this.stopWorker(worker, true); - this.log( - `Evicted empty session worker ${worker.descriptor.workerId} root=${worker.descriptor.rootSessionId ?? worker.descriptor.rootActiveSessionId} on last client detach`, + // Idle-sweep coordination: fence new mutations, drain admitted ones, re-read before deciding. + await this.withEvictionFence("Timed out draining daemon mutations for empty-session eviction", () => + this.passivateWorkerIfStillEligible( + worker, + () => this.isEmptyDetachEvictionCandidate(worker), + () => + `Evicted empty session worker ${worker.descriptor.workerId} root=${worker.descriptor.rootSessionId ?? worker.descriptor.rootActiveSessionId} on last client detach`, + ), ); } catch (error) { this.log(`Empty-session eviction failed for worker ${worker.descriptor.workerId}: ${String(error)}`); - } finally { - releaseFence(); } }