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 <noreply@anthropic.com>
This commit is contained in:
Brandon Elliott
2026-11-07 16:54:27 +00:00
committed by GitHub
co-authored by Brandon Elliott Claude Opus 5.5
parent ab8e1e61ff
commit 8a14d7b2c3
3 changed files with 64 additions and 0 deletions
@@ -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<IteratorResult<SDKMessage>>(() => {}),
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 };
@@ -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) {
@@ -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<string>();
@@ -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 &&