Skip to content
Original file line number Diff line number Diff line change
@@ -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".
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions packages/coding-agent/src/modes/daemon/daemon-session-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,17 @@ export function isSessionSummaryBusy(summary: SessionSummary): boolean {
return summary.isSessionActive || summary.hasRunningRlmChildren === true;
}

/** Naming 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[],
Expand Down Expand Up @@ -395,6 +406,10 @@ export function activeActivityForSession(activeSession: ActiveSessionState): Ses
if (activeSession.runtime.metadata?.kind === "subagent") {
return "idle";
}
// An empty session never gets a summarizer verdict; don't hold it at "working" forever.
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";
Expand Down
64 changes: 64 additions & 0 deletions packages/coding-agent/src/modes/daemon/daemon-supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -914,6 +915,67 @@ export class DaemonSupervisor {
}
}

private async evictEmptySessionOnLastDetach(activeSessionId: string): Promise<void> {
if (this.shuttingDown || this.updateRestartPhase !== undefined || this.idleEvictionFence) return;
const worker = this.matchWorkers(activeSessionId)[0]?.worker;
if (
!worker ||
worker.descriptor.lifecycle !== "ready" ||
!worker.client ||
worker.descriptor.ownerClientId !== undefined || // owned workers have their own cleanup path
this.isWorkerStopping(worker)
) {
return;
}
try {
await this.refreshWorkerSummaries(worker);
} catch {
return;
}
if (!this.isEmptyDetachEvictionCandidate(worker) || this.idleEvictionFence) return;
// Idle-sweep coordination: fence new mutations, drain admitted ones, re-read before deciding.
let releaseFence: () => void = () => {};
const fence = new Promise<void>((resolveFence) => {
releaseFence = resolveFence;
});
this.idleEvictionFence = fence;
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
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 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));
});
return summaries.length > 0 && !hasAttachedClient && summaries.every(isEvictableEmptySessionSummary);
}

private async assertCurrentOwnership(): Promise<void> {
const ownership = this.ownership;
if (!ownership) {
Expand Down Expand Up @@ -1106,6 +1168,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));
};
Expand Down Expand Up @@ -4174,6 +4237,7 @@ export class DaemonSupervisor {
client.catchupPurposes?.delete(resolvedId);
this.write(client, { type: "session_detached", activeSessionId: resolvedId });
void this.syncWorkerExtensionUi(resolvedId);
void this.evictEmptySessionOnLastDetach(resolvedId);
}
}

Expand Down
6 changes: 6 additions & 0 deletions packages/coding-agent/test/daemon-session-list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,12 @@ describe("buildSessionList", () => {

expect(summary.sessionActions).toMatchObject({ queuedCount: 0, active: { kind: "turn" } });
expect(summary.unfinishedActionCount).toBe(3);
expect(summary.activity).toBe("working");
});

it("marks an empty resident session idle instead of holding it at working", () => {
const summary = summaryForActiveSession(makeState({ activeSessionId: "empty" }));
expect(summary.activity).toBe("idle");
});

it("marks a finished subagent idle instead of holding it at working", () => {
Expand Down
123 changes: 123 additions & 0 deletions packages/coding-agent/test/daemon-supervisor-eviction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ interface SupervisorInternals {
workers: Map<string, WorkerFixture>;
clients: Set<{ id: string; attachedActiveSessionIds: Set<string> }>;
idleEvictionFence?: Promise<void>;
mutationDrain: { begin(): void; end(): void };
catalog: { resolve: ReturnType<typeof vi.fn>; stop: ReturnType<typeof vi.fn> };
createOrReuseWorker: ReturnType<typeof vi.fn>;
stopWorker: ReturnType<typeof vi.fn>;
Expand Down Expand Up @@ -442,3 +443,125 @@ 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<void> {
await new Promise((resolve) => setTimeout(resolve, 25));
}

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 })]);
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 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(viewer);

await supervisor.handleCommand(first, { id: "detach-1", type: "detach", activeSessionId: "empty-root" });
await settle();
expect(supervisor.stopWorker).not.toHaveBeenCalled();

await supervisor.handleCommand(viewer, { id: "detach-all", type: "detach" });
await vi.waitFor(() => expect(supervisor.stopWorker).toHaveBeenCalledWith(empty, true));
await settle();
expect(supervisor.stopWorker).toHaveBeenCalledTimes(1);
expect([...supervisor.workers.keys()].sort()).toEqual([
"busy",
"cron",
"heartbeat",
"named",
"one-message",
"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 () => {
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());
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);
});

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);
});
});
Loading