mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 17:04:42 +00:00
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:
co-authored by
Brandon Elliott
Claude Opus 5.5
parent
ab8e1e61ff
commit
8a14d7b2c3
@@ -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 &&
|
||||
|
||||
Reference in New Issue
Block a user