diff --git a/packages/coding-agent/.changes/eng-5831-direct-detach-eviction.md b/packages/coding-agent/.changes/eng-5831-direct-detach-eviction.md new file mode 100644 index 0000000000..522a4d13cc --- /dev/null +++ b/packages/coding-agent/.changes/eng-5831-direct-detach-eviction.md @@ -0,0 +1 @@ +- Fixed empty draft sessions lingering as zombie rows after the last viewer quit: a direct-transport client's detach or socket drop now triggers the same last-detach eviction as supervisor-routed clients. diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 48260a7f18..7ea78cd89e 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -1048,10 +1048,9 @@ export class DaemonSupervisor { const summaries = this.workerRosterEntries(worker) .filter((entry) => !entry.queuedChild) .map(sessionSummaryFromRosterEntry); - const hasAttachedClient = summaries.some((summary) => { - const summaryActiveSessionId = summary.activeSessionId ?? summary.id; - return [...this.clients].some((client) => client.attachedActiveSessionIds.has(summaryActiveSessionId)); - }); + const hasAttachedClient = summaries.some( + (summary) => this.attachedClientCount(summary, summary.activeSessionId ?? summary.id) > 0, + ); return summaries.length > 0 && !hasAttachedClient && summaries.every(isEvictableEmptySessionSummary); } @@ -3928,7 +3927,14 @@ export class DaemonSupervisor { worker?: ResidentWorker, statusLabel?: AgentRosterEntry["statusLabel"], ): AgentRosterEntry { - return this.roster().write(entry, worker?.descriptor.workerId, statusLabel); + const previousDirect = this.roster().get(entry.agentId)?.summary.directAttachedClients ?? 0; + const stored = this.roster().write(entry, worker?.descriptor.workerId, statusLabel); + // Direct peers attach and detach on the worker socket, so their last detach arrives + // here as roster truth instead of through a supervisor-socket close. + if (worker !== undefined && previousDirect > 0 && (entry.summary.directAttachedClients ?? 0) === 0) { + void this.evictEmptySessionOnLastDetach(entry.summary.activeSessionId ?? entry.summary.id); + } + return stored; } private workerOwnedRosterSummaryForPath(canonicalPath: string): SessionSummary | undefined { diff --git a/packages/coding-agent/test/daemon-supervisor-eviction.test.ts b/packages/coding-agent/test/daemon-supervisor-eviction.test.ts index a0359d6daa..b655d1d2aa 100644 --- a/packages/coding-agent/test/daemon-supervisor-eviction.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-eviction.test.ts @@ -2,6 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { workerRosterEntryFromSummary } from "../src/modes/daemon/agent-roster.js"; import { success } from "../src/modes/daemon/daemon-protocol.js"; import type { SessionSummary } from "../src/modes/daemon/daemon-session-list.js"; import { DaemonSupervisor, idleEvictionSweepIntervalMs } from "../src/modes/daemon/daemon-supervisor.js"; @@ -41,6 +42,7 @@ interface SupervisorInternals { runIdleEvictionSweep(now?: number): Promise; shutdown(exitCode: number, stopWorkers: boolean): Promise; handleCommand(client: object, command: object): Promise; + writeRosterEntry(entry: object, worker?: object): unknown; } const tempDirs: string[] = []; @@ -517,6 +519,46 @@ describe("daemon supervisor empty-session eviction on detach", () => { expect(supervisor.log).toHaveBeenCalledWith(expect.stringContaining("Evicted empty session worker empty")); }); + it("evicts an empty draft when the worker reports its last direct viewer gone", async () => { + const now = Date.parse("2026-08-01T12:00:00.000Z"); + const supervisor = makeSupervisor(); + const liveSummaries = [makeSummary("draft-root", now, { messageCount: 0, directAttachedClients: 1 })]; + const worker = makeWorker("draft", liveSummaries); + supervisor.workers.set("draft", worker); + seedSupervisorRoster(supervisor, worker); + + // Clean detach and socket drop both surface as the same worker roster truth. + const detached = makeSummary("draft-root", now, { messageCount: 0 }); + liveSummaries[0] = detached; + worker.summaries.set("draft-root", detached); + supervisor.writeRosterEntry(workerRosterEntryFromSummary(detached), worker); + + await vi.waitFor(() => expect(supervisor.stopWorker).toHaveBeenCalledWith(worker, true)); + expect(supervisor.log).toHaveBeenCalledWith(expect.stringContaining("Evicted empty session worker draft")); + }); + + it("evicts a mixed-client empty draft only when the last of both client kinds is gone", async () => { + const now = Date.parse("2026-08-01T12:00:00.000Z"); + const supervisor = makeSupervisor(); + const liveSummaries = [makeSummary("mixed-root", now, { messageCount: 0, directAttachedClients: 1 })]; + const worker = makeWorker("mixed", liveSummaries); + supervisor.workers.set("mixed", worker); + seedSupervisorRoster(supervisor, worker); + const routed = makeDetachClient("routed", ["mixed-root"]); + supervisor.clients.add(routed); + + await supervisor.handleCommand(routed, { id: "detach-1", type: "detach", activeSessionId: "mixed-root" }); + await settle(); + expect(supervisor.stopWorker).not.toHaveBeenCalled(); + + const detached = makeSummary("mixed-root", now, { messageCount: 0 }); + liveSummaries[0] = detached; + worker.summaries.set("mixed-root", detached); + supervisor.writeRosterEntry(workerRosterEntryFromSummary(detached), worker); + + await vi.waitFor(() => expect(supervisor.stopWorker).toHaveBeenCalledWith(worker, true)); + }); + 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();