From 8a14d7b2c36f716005358f26901de36130ab1992 Mon Sep 17 00:00:00 2001 From: Brandon Elliott Date: Sat, 7 Nov 2026 10:54:27 -0600 Subject: [PATCH] Collect idle Claude runtimes reopened without a turn (#9019) Reopening a chat lists its commands, which resumes the agent and starts a Claude process with no prompt. Claude only sends init with a turn, so the runtime's background-work state stayed unknown and idle cleanup vetoed it on every sweep: the process stayed resident until the daemon restarted. A process that has not been sent a prompt and has not reported init has run no turn and owns no background work, so it can stop. Older CLIs that ran a turn without a roster are still kept. Fixes #9017 Co-authored-by: Brandon Elliott <923787+Brandon168@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- .../claude/agent.background-subagent.test.ts | 51 +++++++++++++++++++ .../server/agent/providers/claude/agent.ts | 1 + .../agent/providers/claude/runtime-safety.ts | 12 +++++ 3 files changed, 64 insertions(+) diff --git a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts index 5c70f0a1fe..432071741f 100644 --- a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts @@ -806,6 +806,57 @@ test("emits inventory only when relevant observations change it", () => { ).toEqual([{ id: "m", kind: "shell", description: null, canStop: true }]); }); +test("Claude runtime safety releases a process that has not been sent a turn", () => { + const safety = new ClaudeRuntimeSafety(); + // A resumed process that only answered control requests never sends init. + expect(safety.canStopRuntime()).toBe(true); + safety.notePromptSent(); + expect(safety.canStopRuntime()).toBe(false); + safety.observe({ type: "system", subtype: "init", claude_code_version: "2.1.292" } as SDKMessage); + expect(safety.canStopRuntime()).toBe(true); + safety.reset(); + expect(safety.canStopRuntime()).toBe(true); + // A CLI without a roster that ran a turn may own background work. + safety.observe({ type: "system", subtype: "init", claude_code_version: "2.1.220" } as SDKMessage); + expect(safety.canStopRuntime()).toBe(false); + safety.retire(); + expect(safety.canStopRuntime()).toBe(false); +}); + +test("Claude session started by a control request without a turn can stop its runtime", async () => { + let launched = 0; + const query = { + next: () => new Promise>(() => {}), + return: async () => ({ done: true, value: undefined }), + close: () => {}, + interrupt: async () => {}, + setPermissionMode: async () => {}, + setModel: async () => {}, + supportedModels: async () => [], + supportedCommands: async () => [], + [Symbol.asyncIterator]() { + return this; + }, + } as unknown as Query; + const session = await new ClaudeAgentClient({ + logger: createTestLogger(), + resolveBinary: async () => "/test/claude/bin", + queryFactory: ({ prompt }) => { + if (typeof prompt === "string") throw new Error("Expected streaming input"); + launched++; + return query; + }, + }).createSession({ provider: "claude", cwd: process.cwd() }); + try { + // Reopening a chat lists its commands, which launches Claude with no prompt. + await session.listCommands!(); + expect(launched).toBe(1); + expect(await session.canStopRuntime!()).toBe(true); + } finally { + await session.close(); + } +}); + test("Claude session runtime safety observes pumped background work and autonomous replies", async () => { let wake: (() => void) | undefined; const channel = { closed: false }; diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index b4bd7e27db..7f72fefbb2 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2591,6 +2591,7 @@ class ClaudeAgentSession implements AgentSession { this.activeForegroundQuery = this.query; this.activeForegroundInput = this.input; this.startQueryPump(); + this.runtimeSafety.notePromptSent(); this.input.push(sdkMessage); setTimeout(() => { if (this.activeForegroundTurnId === turnId) { diff --git a/packages/server/src/server/agent/providers/claude/runtime-safety.ts b/packages/server/src/server/agent/providers/claude/runtime-safety.ts index a21bf908ea..8688e93f45 100644 --- a/packages/server/src/server/agent/providers/claude/runtime-safety.ts +++ b/packages/server/src/server/agent/providers/claude/runtime-safety.ts @@ -6,6 +6,10 @@ import { toBackgroundWorkInputs, type ClaudeBackgroundTask } from "./background- /** Process-local background ownership. Completion delivery can follow an empty roster. */ export class ClaudeRuntimeSafety { private inventoryState: "unknown" | "supported" | "retired" = "unknown"; + // Background work starts inside a turn, and init only arrives with a turn. A process that + // has neither been sent a prompt nor reported init (a resumed chat that only answered + // control requests) owns no background work. + private turnSeen = false; private liveTasks: readonly ClaudeBackgroundTask[] = []; private published: AgentBackgroundWorkInput[] | undefined; private readonly awaitingNotifications = new Set(); @@ -15,6 +19,7 @@ export class ClaudeRuntimeSafety { reset(): void { this.inventoryState = "unknown"; + this.turnSeen = false; this.liveTasks = []; this.published = undefined; this.awaitingNotifications.clear(); @@ -28,6 +33,11 @@ export class ClaudeRuntimeSafety { this.inventoryState = "retired"; } + /** Called before a prompt is written to this process. */ + notePromptSent(): void { + this.turnSeen = true; + } + /** Returns a full replacement only when the visible inventory changes. */ observe(message: SDKMessage): AgentBackgroundWorkInput[] | undefined { if (this.inventoryState === "retired") return undefined; @@ -93,6 +103,7 @@ export class ClaudeRuntimeSafety { ); } if (message.subtype === "init") { + this.turnSeen = true; const version = parseClaudeCodeVersion(message.claude_code_version ?? ""); if ( version && @@ -115,6 +126,7 @@ export class ClaudeRuntimeSafety { } canStopRuntime(): boolean { + if (this.inventoryState === "unknown" && !this.turnSeen) return true; return ( this.inventoryState === "supported" && this.liveTasks.length === 0 &&