Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 11 additions & 5 deletions packages/coding-agent/src/modes/daemon/daemon-supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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 {
Expand Down
42 changes: 42 additions & 0 deletions packages/coding-agent/test/daemon-supervisor-eviction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -41,6 +42,7 @@ interface SupervisorInternals {
runIdleEvictionSweep(now?: number): Promise<void>;
shutdown(exitCode: number, stopWorkers: boolean): Promise<never>;
handleCommand(client: object, command: object): Promise<unknown>;
writeRosterEntry(entry: object, worker?: object): unknown;
}

const tempDirs: string[] = [];
Expand Down Expand Up @@ -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();
Expand Down
Loading