a turn waiting on a person is not a stalled turn
Reported as: leave an AskUserQuestion unanswered for a while and the chat becomes unusable — refresh, send another message, the agent is stuck. armStall knew about compaction and about background tasks but not about a pending gate. Parking on a question or a plan means emitting nothing until somebody answers; that is what parking IS. So the watchdog could not tell it from a wedged turn, and at 10 minutes against gates that wait 20, it always won: 10m the turn is declared dead, isGenerating goes false, an error is emitted, the session is torn down 20m the gate's own timeout would have resolved it truthfully — never reached That left a ten-minute window to answer. Miss it and the card is still on screen with nothing behind it, and clicking an answer does nothing at all: an answer's only receipt is the tool:result the gate emits when it unblocks, and the gate is gone. From the outside that is precisely "unusable, refresh, send another message". A session with an outstanding question or plan is now left alone by the watchdog. Not unbounded — each gate holds its own 20-minute timer and resolves with a sentence written for the agent — and the timer is re-armed on both resolve paths, the answer and the timeout, so a turn that genuinely wedges AFTER a question is still caught. armStall skips itself while any gate remains, so answering one of two questions keeps the reprieve. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -463,6 +463,30 @@ function armStall(session: PersistentSession): void {
|
||||
session.stallTimer = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
// ── A turn WAITING ON A PERSON is not a stalled turn ──
|
||||
//
|
||||
// Parking on `AskUserQuestion` or a plan gate means emitting nothing until somebody answers — that is
|
||||
// what parking is. This timer could not tell that from a wedged turn, and at 10 minutes against gates
|
||||
// that wait 20, it always won:
|
||||
//
|
||||
// 10m the turn is declared dead, `isGenerating` goes false, and the session is torn down
|
||||
// 20m the gate's own timeout would have resolved it truthfully — never reached
|
||||
//
|
||||
// The card stays on screen with nothing behind it, and clicking an answer does nothing, because an
|
||||
// answer's only receipt is the `tool:result` that the gate emits when it unblocks — and the gate is
|
||||
// gone. Reported as "the chat becomes unusable, I have to refresh and send another message", which is
|
||||
// exactly what it would look like from the outside.
|
||||
//
|
||||
// So a session with an outstanding question or plan is left alone here. It is not unbounded: each gate
|
||||
// holds its own 20-minute timer and resolves with a sentence written for the agent, after which the
|
||||
// turn speaks again and this re-arms on the next event.
|
||||
const waitingOnAPerson = session.pendingQuestions.size > 0 || session.pendingPlans.size > 0;
|
||||
if (waitingOnAPerson) {
|
||||
session.stallTimer = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const compacting = session.compactStartedAt !== undefined;
|
||||
session.stallTimer = setTimeout(
|
||||
() => {
|
||||
@@ -1030,6 +1054,9 @@ async function questionGate(session: PersistentSession, toolCallId: string): Pro
|
||||
return new Promise<string>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
session.pendingQuestions.delete(toolCallId);
|
||||
// Re-arm: the turn is about to speak again, and from here a silence IS a stall. `armStall` skips
|
||||
// itself while any gate is still outstanding, so a second unanswered question keeps the reprieve.
|
||||
armStall(session);
|
||||
console.warn(`[claude:question:${session.sessionKey}] no answer in 20m`);
|
||||
resolve(
|
||||
'The user has not answered yet — they may be away from the screen. The question is still on theirs, ' +
|
||||
@@ -1067,6 +1094,9 @@ export function answerClaudeQuestion(sessionKey: string, userId: number, toolCal
|
||||
if (!session || !pending) return false;
|
||||
clearTimeout(pending.timer);
|
||||
session.pendingQuestions.delete(toolCallId);
|
||||
// The turn resumes now, so silence stops being legitimate and the watchdog goes back on. `armStall`
|
||||
// skips itself while another gate is outstanding, so answering one of two questions keeps the reprieve.
|
||||
armStall(session);
|
||||
pending.resolve(answer);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
// The rule the fix encodes, exercised as the predicate rather than through a live harness: a session
|
||||
// waiting on a person must not be treated as stalled.
|
||||
const stallApplies = (isGenerating: boolean, questions: number, plans: number) =>
|
||||
isGenerating && questions === 0 && plans === 0;
|
||||
|
||||
describe('stall watchdog vs a parked turn', () => {
|
||||
test('a generating turn with nothing pending is watched', () => {
|
||||
expect(stallApplies(true, 0, 0)).toBe(true);
|
||||
});
|
||||
test('a turn parked on a question is NOT stalled — it is waiting', () => {
|
||||
expect(stallApplies(true, 1, 0)).toBe(false);
|
||||
});
|
||||
test('a turn parked on a plan decision is not either', () => {
|
||||
expect(stallApplies(true, 0, 1)).toBe(false);
|
||||
});
|
||||
test('answering one of two questions keeps the reprieve', () => {
|
||||
expect(stallApplies(true, 1, 0)).toBe(false);
|
||||
});
|
||||
test('a turn that is not generating is never stalled', () => {
|
||||
expect(stallApplies(false, 0, 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user