Skip to content

Commit 5753794

Browse files
centdixclaude
andauthored
fix: reset web-chat worktree status from owned claude -p run (#273)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6b04e83 commit 5753794

4 files changed

Lines changed: 150 additions & 0 deletions

File tree

backend/src/__tests__/claude-conversation-stream-service.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,4 +292,63 @@ describe("ClaudeConversationStreamService", () => {
292292
activeTurnId: null,
293293
});
294294
});
295+
296+
it("invokes onRunSettled exactly once when a run completes", () => {
297+
const claude = new FakeClaudeCliGateway();
298+
const service = new ClaudeConversationStreamService({ claude });
299+
let settled = 0;
300+
301+
expect(service.startRun({
302+
conversationId: "session-1",
303+
turnId: "claude-turn:turn-1",
304+
cwd: "/tmp/worktree",
305+
prompt: "Ship it",
306+
sessionId: "session-1",
307+
onRunSettled: () => {
308+
settled += 1;
309+
},
310+
})).toEqual({ ok: true });
311+
expect(settled).toBe(0);
312+
313+
claude.callbacks?.onComplete?.("session-1");
314+
expect(settled).toBe(1);
315+
316+
// A late/duplicate completion signal must not re-fire the callback.
317+
claude.callbacks?.onComplete?.("session-1");
318+
expect(settled).toBe(1);
319+
});
320+
321+
it("invokes onRunSettled when a run errors or is interrupted", () => {
322+
const erroring = new FakeClaudeCliGateway();
323+
const errorService = new ClaudeConversationStreamService({ claude: erroring });
324+
let erroredSettled = 0;
325+
errorService.startRun({
326+
conversationId: "session-1",
327+
turnId: "claude-turn:turn-1",
328+
cwd: "/tmp/worktree",
329+
prompt: "Ship it",
330+
sessionId: "session-1",
331+
onRunSettled: () => {
332+
erroredSettled += 1;
333+
},
334+
});
335+
erroring.callbacks?.onError?.("boom");
336+
expect(erroredSettled).toBe(1);
337+
338+
const interrupting = new FakeClaudeCliGateway();
339+
const interruptService = new ClaudeConversationStreamService({ claude: interrupting });
340+
let interruptedSettled = 0;
341+
interruptService.startRun({
342+
conversationId: "session-2",
343+
turnId: "claude-turn:turn-2",
344+
cwd: "/tmp/worktree",
345+
prompt: "Ship it",
346+
resumeSessionId: "session-2",
347+
onRunSettled: () => {
348+
interruptedSettled += 1;
349+
},
350+
});
351+
interruptService.interrupt("session-2");
352+
expect(interruptedSettled).toBe(1);
353+
});
295354
});
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { describe, expect, it } from "bun:test";
2+
import type {
3+
ClaudeCliGateway,
4+
ClaudeCliRunCallbacks,
5+
ClaudeCliRunHandle,
6+
} from "../adapters/claude-cli";
7+
import { ClaudeConversationStreamService } from "../services/claude-conversation-stream-service";
8+
import { ProjectRuntime } from "../services/project-runtime";
9+
10+
class FakeClaudeCliGateway implements Pick<ClaudeCliGateway, "sendMessage"> {
11+
callbacks: ClaudeCliRunCallbacks | null = null;
12+
13+
sendMessage(
14+
params: Parameters<ClaudeCliGateway["sendMessage"]>[0],
15+
callbacks: ClaudeCliRunCallbacks,
16+
): ClaudeCliRunHandle {
17+
this.callbacks = callbacks;
18+
return {
19+
completion: Promise.resolve(),
20+
interrupt: () => {},
21+
sessionId: Promise.resolve(params.sessionId ?? params.resumeSessionId ?? "session-1"),
22+
};
23+
}
24+
}
25+
26+
// Mirrors the server's busy gate (isBusyAgentStatus): a worktree is "busy" for
27+
// web chat only while its agent lifecycle is starting/running.
28+
const isBusy = (lifecycle: string): boolean => lifecycle === "starting" || lifecycle === "running";
29+
30+
describe("Claude web-chat streaming lifecycle", () => {
31+
it("marks the worktree finished when a streamed claude -p turn ends so the next web message is allowed", () => {
32+
const runtime = new ProjectRuntime();
33+
runtime.upsertWorktree({ worktreeId: "wt-1", branch: "feat", path: "/tmp/wt" });
34+
const claude = new FakeClaudeCliGateway();
35+
const stream = new ClaudeConversationStreamService({ claude });
36+
37+
// The same wiring the server uses in sendClaudeStreamingMessage: drive the
38+
// worktree lifecycle from the owned claude -p run instead of the lossy hook.
39+
const setLifecycle = (lifecycle: "running" | "stopped"): void => {
40+
runtime.applyEvent({ type: "agent_status_changed", worktreeId: "wt-1", branch: "feat", lifecycle });
41+
};
42+
const status = (): string => runtime.getWorktree("wt-1")!.agent.lifecycle;
43+
44+
// Web message #1: server starts the owned claude -p run and marks it running.
45+
expect(stream.startRun({
46+
conversationId: "session-1",
47+
turnId: "claude-turn:turn-1",
48+
cwd: "/tmp/wt",
49+
prompt: "first",
50+
sessionId: "session-1",
51+
onRunSettled: () => setLifecycle("stopped"),
52+
})).toEqual({ ok: true });
53+
setLifecycle("running");
54+
55+
// While streaming, the worktree reads as busy — a 2nd web message is gated.
56+
expect(isBusy(status())).toBe(true);
57+
58+
// The claude -p turn ends (stream result line) — the owned end-turn signal.
59+
claude.callbacks?.onComplete?.("session-1");
60+
61+
// Lands on the finished state (maps to "done" in the UI, same as a terminal
62+
// turn finishing) so the next web message passes the busy gate.
63+
expect(status()).toBe("stopped");
64+
expect(isBusy(status())).toBe(false);
65+
});
66+
});

backend/src/server.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,22 @@ async function setAgentTerminalStale(worktree: WorktreeSnapshot, stale: boolean)
706706
}
707707
}
708708

709+
/** Drive the worktree agent lifecycle from the backend-owned `claude -p` web
710+
* chat run. The hook-based lifecycle (UserPromptSubmit/Stop) is best-effort and
711+
* can be lost or reordered, leaving the worktree stuck "running" so the busy
712+
* gate blocks the next web message even though nothing is running. The owned
713+
* run's start/settle is authoritative, so we set it directly. */
714+
function setWorktreeAgentLifecycle(worktree: WorktreeSnapshot, lifecycle: "running" | "stopped"): void {
715+
const state = projectRuntime.getWorktreeByBranch(worktree.branch);
716+
if (!state) return;
717+
projectRuntime.applyEvent({
718+
type: "agent_status_changed",
719+
worktreeId: state.worktreeId,
720+
branch: state.branch,
721+
lifecycle,
722+
});
723+
}
724+
709725
async function resolveClaudeStreamingLaunchContext(
710726
worktree: WorktreeSnapshot,
711727
): Promise<{
@@ -778,11 +794,13 @@ async function sendClaudeStreamingMessage(input: {
778794
permissionMode: launchContext.data.permissionMode,
779795
...(hasExistingSession ? { resumeSessionId: sessionId } : { sessionId }),
780796
...(!hasExistingSession && launchContext.data.systemPrompt ? { systemPrompt: launchContext.data.systemPrompt } : {}),
797+
onRunSettled: () => setWorktreeAgentLifecycle(input.worktree, "stopped"),
781798
});
782799
if (!started.ok) {
783800
return errorResponse(started.error, 409);
784801
}
785802

803+
setWorktreeAgentLifecycle(input.worktree, "running");
786804
await setAgentTerminalStale(input.worktree, true);
787805
return jsonResponse({
788806
conversationId: sessionId,

backend/src/services/claude-conversation-stream-service.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ export interface ClaudeConversationStreamRunInput {
2020
resumeSessionId?: string | null;
2121
sessionId?: string | null;
2222
systemPrompt?: string | null;
23+
/** Fired exactly once when the owned `claude -p` run settles (completes,
24+
* errors, or is interrupted). The server uses this reliable end-of-turn
25+
* signal to reset the worktree lifecycle, instead of the lossy Stop hook. */
26+
onRunSettled?: () => void;
2327
}
2428

2529
interface ActiveClaudeRun {
@@ -29,6 +33,7 @@ interface ActiveClaudeRun {
2933
liveMessages: Map<string, AgentsUiConversationMessageDraft>;
3034
completed: boolean;
3135
pruneTimer: ReturnType<typeof setTimeout> | null;
36+
onRunSettled: (() => void) | null;
3237
}
3338

3439
export interface ClaudeConversationStreamServiceDependencies {
@@ -72,6 +77,7 @@ export class ClaudeConversationStreamService {
7277
liveMessages: new Map(),
7378
completed: false,
7479
pruneTimer: null,
80+
onRunSettled: input.onRunSettled ?? null,
7581
};
7682
this.runs.set(input.conversationId, run);
7783

@@ -250,6 +256,7 @@ export class ClaudeConversationStreamService {
250256
}
251257

252258
this.notifyStatus(run, false);
259+
run.onRunSettled?.();
253260

254261
run.pruneTimer = setTimeout(() => {
255262
const current = this.runs.get(run.conversationId);

0 commit comments

Comments
 (0)