Skip to content

Commit 5d93835

Browse files
authored
fix(sidecar): end Claude turn once pending background tasks drain (#922)
* fix(sidecar): end Claude turn once pending background tasks drain Previously, when a Claude turn's completed result arrived while background tasks were still pending, the result was swallowed and the session never closed — task_updated events kept dripping in indefinitely after all tasks finished. Now the swallowed completed result is replayed as soon as the pending task count reaches zero, and turnEnded is idempotent so the turn closes exactly once. * chore: add changeset
1 parent bc79e48 commit 5d93835

3 files changed

Lines changed: 194 additions & 33 deletions

File tree

.changeset/calm-turns-close.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"helmor": patch
3+
---
4+
5+
Fix Claude sessions never finishing their turn after background tasks completed, leaving the conversation stuck streaming indefinitely.

sidecar/src/claude/background-resume.test.ts

Lines changed: 154 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ import type { SendMessageParams } from "../session-manager.js";
2323
// outer variable so each test can swap the SDK message stream.
2424
let scenario: SDKMessage[] = [];
2525
let hangAfterScenario = false;
26+
let queryCloseCount = 0;
27+
const releaseHangingQueries = new Set<() => void>();
2628
const previousBgDrainTimeout = process.env.HELMOR_CLAUDE_BG_DRAIN_TIMEOUT_MS;
29+
const LONG_BG_DRAIN_TIMEOUT_MS = "60000";
2730

2831
function makeQuery(messages: SDKMessage[]) {
2932
let closed = false;
@@ -47,14 +50,21 @@ function makeQuery(messages: SDKMessage[]) {
4750
const heartbeat = setInterval(() => {}, 1000);
4851
try {
4952
await new Promise<void>((resolve) => {
50-
releaseHang = resolve;
53+
const release = () => {
54+
releaseHangingQueries.delete(release);
55+
resolve();
56+
};
57+
releaseHang = release;
58+
releaseHangingQueries.add(release);
5159
});
5260
} finally {
5361
clearInterval(heartbeat);
62+
if (releaseHang) releaseHangingQueries.delete(releaseHang);
5463
}
5564
}
5665
},
5766
close() {
67+
queryCloseCount += 1;
5868
closed = true;
5969
releaseHang?.();
6070
},
@@ -159,6 +169,17 @@ function taskStarted(taskId = "t1"): SDKMessage {
159169
} as unknown as SDKMessage;
160170
}
161171

172+
function taskUpdated(taskId = "t1", status = "completed"): SDKMessage {
173+
return {
174+
type: "system",
175+
subtype: "task_updated",
176+
task_id: taskId,
177+
patch: { status },
178+
session_id: "s1",
179+
uuid: `tu-${taskId}-${status}`,
180+
} as unknown as SDKMessage;
181+
}
182+
162183
function baseParams(): SendMessageParams {
163184
return {
164185
sessionId: "s1",
@@ -179,15 +200,38 @@ beforeAll(async () => {
179200
});
180201

181202
afterEach(() => {
203+
for (const release of releaseHangingQueries) release();
204+
releaseHangingQueries.clear();
182205
scenario = [];
183206
hangAfterScenario = false;
207+
queryCloseCount = 0;
184208
if (previousBgDrainTimeout === undefined) {
185209
delete process.env.HELMOR_CLAUDE_BG_DRAIN_TIMEOUT_MS;
186210
} else {
187211
process.env.HELMOR_CLAUDE_BG_DRAIN_TIMEOUT_MS = previousBgDrainTimeout;
188212
}
189213
});
190214

215+
async function expectSettles<T>(
216+
promise: Promise<T>,
217+
label: string,
218+
): Promise<T> {
219+
let timeout: ReturnType<typeof setTimeout> | undefined;
220+
try {
221+
return await Promise.race([
222+
promise,
223+
new Promise<never>((_resolve, reject) => {
224+
timeout = setTimeout(
225+
() => reject(new Error(`${label} timed out`)),
226+
500,
227+
);
228+
}),
229+
]);
230+
} finally {
231+
if (timeout) clearTimeout(timeout);
232+
}
233+
}
234+
191235
describe("ClaudeSessionManager backgrounded-task resume (#891)", () => {
192236
test("filters the pause result, resumes, and ends exactly once", async () => {
193237
scenario = [
@@ -256,7 +300,7 @@ describe("ClaudeSessionManager run_in_background drain (completed with pending b
256300
(m as { terminal_reason?: string }).terminal_reason === "completed",
257301
).length;
258302

259-
test("defers `completed` while a bg task is pending, resumes on task_notification, ends once", async () => {
303+
test("defers `completed` while a bg task is pending, replays it on task_notification, ends once", async () => {
260304
scenario = [
261305
assistant("dispatching"),
262306
taskStarted("bg1"),
@@ -274,22 +318,23 @@ describe("ClaudeSessionManager run_in_background drain (completed with pending b
274318

275319
// One terminal end — the intermediate `completed` must not fire it.
276320
expect(spy.ends).toBe(1);
277-
// Only the FINAL `completed` reaches the pipeline (one result per turn).
321+
// Only one `completed` reaches the pipeline (one result per turn).
278322
expect(completedResultCount(spy)).toBe(1);
279-
// Continuation flows through: notification + the post-resume assistant.
323+
// Notification flows through before the deferred terminal result is replayed.
280324
const subtypes = spy.passthroughs.map(
281325
(m) => (m as { subtype?: string }).subtype,
282326
);
283-
expect(subtypes).toContain("task_notification");
284-
const assistantTexts = spy.passthroughs
285-
.filter((m) => (m as { type?: string }).type === "assistant")
286-
.map(
287-
(m) =>
288-
(m as { message?: { content?: { text?: string }[] } }).message
289-
?.content?.[0]?.text,
290-
);
291-
expect(assistantTexts).toContain("synthesizing");
292-
// Usage recorded at the deferred pause AND the terminal result.
327+
const notificationIndex = subtypes.findIndex(
328+
(subtype) => subtype === "task_notification",
329+
);
330+
const replayedCompletedIndex = spy.passthroughs.findIndex(
331+
(m) =>
332+
(m as { type?: string }).type === "result" &&
333+
(m as { terminal_reason?: string }).terminal_reason === "completed",
334+
);
335+
expect(notificationIndex).toBeGreaterThanOrEqual(0);
336+
expect(replayedCompletedIndex).toBe(notificationIndex + 1);
337+
// Usage recorded at the deferred pause and when replayed as terminal.
293338
expect(spy.contextUsageUpdates).toBeGreaterThanOrEqual(2);
294339
});
295340

@@ -378,4 +423,99 @@ describe("ClaudeSessionManager run_in_background drain (completed with pending b
378423
.filter(Boolean);
379424
expect(reasons).toContain("max_turns"); // passed through, not deferred
380425
});
426+
427+
test("deferred completed closes immediately when task_notification drains the last pending task even if the iterator hangs", async () => {
428+
process.env.HELMOR_CLAUDE_BG_DRAIN_TIMEOUT_MS = LONG_BG_DRAIN_TIMEOUT_MS;
429+
hangAfterScenario = true;
430+
scenario = [
431+
assistant("dispatching"),
432+
taskStarted("bg1"),
433+
result("completed"),
434+
taskNotification("bg1"),
435+
];
436+
const spy = makeSpyEmitter();
437+
await expectSettles(
438+
new ClaudeSessionManager().sendMessage(
439+
"req-bg-notify-close",
440+
baseParams(),
441+
spy.emitter,
442+
),
443+
"task_notification drain",
444+
);
445+
446+
expect(spy.ends).toBe(1);
447+
expect(queryCloseCount).toBe(1);
448+
expect(completedResultCount(spy)).toBe(1);
449+
expect(
450+
spy.passthroughs.some(
451+
(m) => (m as { subtype?: string }).subtype === "task_notification",
452+
),
453+
).toBe(true);
454+
});
455+
456+
test("deferred completed closes when killed task_updated drains the last pending task without notification", async () => {
457+
process.env.HELMOR_CLAUDE_BG_DRAIN_TIMEOUT_MS = LONG_BG_DRAIN_TIMEOUT_MS;
458+
hangAfterScenario = true;
459+
scenario = [
460+
assistant("dispatching"),
461+
taskStarted("bg1"),
462+
result("completed"),
463+
taskUpdated("bg1", "killed"),
464+
];
465+
const spy = makeSpyEmitter();
466+
await expectSettles(
467+
new ClaudeSessionManager().sendMessage(
468+
"req-bg-killed-close",
469+
baseParams(),
470+
spy.emitter,
471+
),
472+
"killed task_updated drain",
473+
);
474+
475+
expect(spy.ends).toBe(1);
476+
expect(queryCloseCount).toBe(1);
477+
expect(completedResultCount(spy)).toBe(1);
478+
expect(
479+
spy.passthroughs.some(
480+
(m) => (m as { subtype?: string }).subtype === "task_updated",
481+
),
482+
).toBe(true);
483+
});
484+
485+
test("deferred completed replay is idempotent when another completed result arrives before finalization", async () => {
486+
const firstCompleted = {
487+
...result("completed"),
488+
uuid: "r-completed-first-0-0",
489+
} as SDKMessage;
490+
const secondCompleted = {
491+
...result("completed"),
492+
uuid: "r-completed-second-0-0",
493+
} as SDKMessage;
494+
scenario = [
495+
assistant("dispatching"),
496+
taskStarted("bg1"),
497+
firstCompleted,
498+
secondCompleted,
499+
taskNotification("bg1"),
500+
];
501+
const spy = makeSpyEmitter();
502+
await new ClaudeSessionManager().sendMessage(
503+
"req-bg-idempotent",
504+
baseParams(),
505+
spy.emitter,
506+
);
507+
508+
expect(spy.ends).toBe(1);
509+
expect(queryCloseCount).toBe(1);
510+
const completedResults = spy.passthroughs.filter(
511+
(m) =>
512+
(m as { type?: string }).type === "result" &&
513+
(m as { terminal_reason?: string }).terminal_reason === "completed",
514+
);
515+
expect(completedResults).toHaveLength(1);
516+
expect((completedResults[0] as { uuid?: string }).uuid).toBe(
517+
"r-completed-second-0-0",
518+
);
519+
expect(spy.contextUsageUpdates).toBeGreaterThanOrEqual(3);
520+
});
381521
});

sidecar/src/claude/session-manager.ts

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -714,11 +714,36 @@ export class ClaudeSessionManager implements SessionManager {
714714
const pendingBgTasks = new Map<string, PendingBgTask>();
715715
let bgDrainTimer: ReturnType<typeof setTimeout> | null = null;
716716
let bgDrainTimedOut = false;
717+
let deferredCompletedResult: SDKMessage | null = null;
718+
let turnEnded = false;
717719
const clearBgDrainTimer = () => {
718720
if (bgDrainTimer === null) return;
719721
clearTimeout(bgDrainTimer);
720722
bgDrainTimer = null;
721723
};
724+
const endTurnOnce = () => {
725+
if (turnEnded) return false;
726+
turnEnded = true;
727+
emitter.end(requestId);
728+
return true;
729+
};
730+
const passthroughTerminalResult = (terminalResult: SDKMessage) => {
731+
if (turnEnded) return false;
732+
deferredCompletedResult = null;
733+
clearBgDrainTimer();
734+
emitter.passthrough(requestId, terminalResult);
735+
const meta = buildClaudeStoredMeta(terminalResult, model ?? "");
736+
if (meta) {
737+
emitter.contextUsageUpdated(requestId, sessionId, JSON.stringify(meta));
738+
}
739+
endTurnOnce();
740+
return true;
741+
};
742+
const passthroughDeferredCompletedIfReady = () => {
743+
if (!deferredCompletedResult || pendingBgTasks.size > 0) return false;
744+
const terminalResult = deferredCompletedResult;
745+
return passthroughTerminalResult(terminalResult);
746+
};
722747
const summarizePendingBgTasks = () =>
723748
Array.from(pendingBgTasks.values())
724749
.slice(0, 12)
@@ -841,12 +866,12 @@ export class ClaudeSessionManager implements SessionManager {
841866
});
842867
} else if (subtype === "task_notification") {
843868
pendingBgTasks.delete(taskId);
844-
if (pendingBgTasks.size === 0) clearBgDrainTimer();
845869
} else if (subtype === "task_updated") {
846870
const pending = pendingBgTasks.get(taskId);
847871
const status = terminalTaskUpdateStatus(message);
848872
if (pending && status) {
849873
pending.terminalStatus = status;
874+
pendingBgTasks.delete(taskId);
850875
}
851876
}
852877
} else if (subtype === "task_notification" && toolUseId) {
@@ -856,7 +881,6 @@ export class ClaudeSessionManager implements SessionManager {
856881
break;
857882
}
858883
}
859-
if (pendingBgTasks.size === 0) clearBgDrainTimer();
860884
}
861885
}
862886
// A `completed` result while background tasks are still pending is
@@ -867,6 +891,7 @@ export class ClaudeSessionManager implements SessionManager {
867891
// The final `completed` (pending drained) and any error terminal
868892
// fall through to the terminal branch below.
869893
if (isCompletedResult(message) && pendingBgTasks.size > 0) {
894+
deferredCompletedResult = message;
870895
ensureBgDrainTimer();
871896
const meta = buildClaudeStoredMeta(message, model ?? "");
872897
if (meta) {
@@ -878,33 +903,23 @@ export class ClaudeSessionManager implements SessionManager {
878903
}
879904
continue;
880905
}
906+
if (isTerminalResult(message)) {
907+
passthroughTerminalResult(message);
908+
return;
909+
}
881910
// AskUserQuestion tool_use blocks pass through INTACT — the Rust
882911
// adapter renders them as the persistent Q&A card (and merges
883912
// the tool_result answers into it), so stripping them here
884913
// would lose the card on finalize/persist/reload.
885914
emitter.passthrough(requestId, message);
886-
if (isTerminalResult(message)) {
887-
// Terminal result (success OR error) — both shapes carry
888-
// `usage`/`modelUsage`, so both should update the ring.
889-
// Bail on the first one we see; any steer() still in its
890-
// image-load await will find `promptSource.closed` via
891-
// the finally block below and return false.
892-
const meta = buildClaudeStoredMeta(message, model ?? "");
893-
if (meta) {
894-
emitter.contextUsageUpdated(
895-
requestId,
896-
sessionId,
897-
JSON.stringify(meta),
898-
);
899-
}
900-
emitter.end(requestId);
915+
if (passthroughDeferredCompletedIfReady()) {
901916
return;
902917
}
903918
}
904-
if (!this.turns.isAbortRequested(sessionId)) emitter.end(requestId);
919+
if (!this.turns.isAbortRequested(sessionId)) endTurnOnce();
905920
} catch (err) {
906921
if (bgDrainTimedOut) {
907-
if (!this.turns.isAbortRequested(sessionId)) emitter.end(requestId);
922+
if (!this.turns.isAbortRequested(sessionId)) endTurnOnce();
908923
return;
909924
}
910925
if (isAbortError(err)) {
@@ -1497,6 +1512,7 @@ function isTerminalTaskStatus(status: string): boolean {
14971512
status === "completed" ||
14981513
status === "failed" ||
14991514
status === "cancelled" ||
1515+
status === "killed" ||
15001516
status === "canceled" ||
15011517
status === "errored"
15021518
);

0 commit comments

Comments
 (0)