fix(server): report idle sessions as not-running on reconnect#402
fix(server): report idle sessions as not-running on reconnect#402dimakis wants to merge 1 commit into
Conversation
dimakis
left a comment
There was a problem hiding this comment.
Centaur Review
Found 3 issue(s) (1 warning).
server/ws-handler-v2.ts
Core fix is correct and well-tested, but the same idle-session running-state logic is missing from handleSwitchSession, which sends the same running field to the same client queue mechanism — leaving the 5-second delay bug reachable via the switch_session path.
- 🟡 regressions: handleSwitchSession (line 429) computes
runningwithout the new lastSpeaker idle check. Both reconnected and session_switched drive SET_RUNNING in the client store, which controls whether sendMessage queues behind a 5-second fallback timer. An idle session (lastSpeaker=assistant) will be reported as running=false on reconnect but running=true on switch_session, causing the same queuing delay this PR fixes — just via a different code path.[fixable]
server/__tests__/ws-handler-v2.test.ts
Core fix is correct and well-tested, but the same idle-session running-state logic is missing from handleSwitchSession, which sends the same running field to the same client queue mechanism — leaving the 5-second delay bug reachable via the switch_session path.
- 🔵 missing_tests (L1148): The new idle-session tests don't verify that reattachChat is NOT called when running is downgraded to false by the lastSpeaker check. The zombie test (line 1117) explicitly asserts
expect(reattachChat).not.toHaveBeenCalled(), but the new idle tests skip this. Since the code at line 284 gates reattach onrunning, andrunningis now false for idle sessions, it would be good to confirm the reattach is correctly skipped — especially since the session is still alive (unlike zombies where it's removed).[fixable] - 🔵 missing_tests (L1148): No test covers the case where storeMeta exists, isActive is true, but lastSpeaker is null (brand-new session that hasn't had any turns yet). The implementation handles it correctly (null !== 'assistant' → running stays true), but an explicit test would document this edge case and guard against future regressions.
[fixable]
| expect(reattachChat).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('reports running=false when session is active but idle (lastSpeaker=assistant)', () => { |
There was a problem hiding this comment.
🔵 missing_tests: The new idle-session tests don't verify that reattachChat is NOT called when running is downgraded to false by the lastSpeaker check. The zombie test (line 1117) explicitly asserts expect(reattachChat).not.toHaveBeenCalled(), but the new idle tests skip this. Since the code at line 284 gates reattach on running, and running is now false for idle sessions, it would be good to confirm the reattach is correctly skipped — especially since the session is still alive (unlike zombies where it's removed). [fixable]
| expect(reattachChat).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('reports running=false when session is active but idle (lastSpeaker=assistant)', () => { |
There was a problem hiding this comment.
🔵 missing_tests: No test covers the case where storeMeta exists, isActive is true, but lastSpeaker is null (brand-new session that hasn't had any turns yet). The implementation handles it correctly (null !== 'assistant' → running stays true), but an explicit test would document this edge case and guard against future regressions. [fixable]
handleReconnect conflated "query loop alive" with "agent actively processing a turn". isActive() returns true for sessions whose query loop is alive but idle between turns (waiting in `for await`). This caused the reconnected message to report running=true, making the frontend queue the first user message behind a 5-second fallback timer instead of sending it immediately — the "two messages to get a response after reattach" bug. Fix: check lastSpeaker from EventStore. When lastSpeaker=assistant the agent completed its last turn and is idle, so report running=false. When lastSpeaker=user the agent hasn't answered yet, so running=true. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
318797d to
459283a
Compare
Centaur ReviewFound 5 issue(s) (2 critical) (2 warning).
|
Centaur ReviewFound 5 issue(s) (2 critical) (1 warning).
|
dimakis
left a comment
There was a problem hiding this comment.
Centaur Review
Found 5 issue(s) (2 critical) (2 warning).
server/ws-handler-v2.ts
The running field is never added to the reconnected summary sent to clients — all three new tests will fail. Additionally, the client-side parser ignores this field, so even with a fix, client-side changes are needed to achieve the stated goal.
- 🔴 bugs (L267): The
runningfield is never added to thereconnectedsummary message. Thesummariesarray (line 233) is typedArray<{ sessionId: string; replayed: number }>and thesummaries.push()at line 321 only pushessessionIdandreplayed. The new code modifies a localrunningvariable but never propagates it to the client-facing message. All three new tests will fail becausesummary.sessions[0].runningisundefined, nottrue/false. Thesummaries.push()must includerunningfor this fix to work.[fixable] - 🟡 regressions (L278): Setting
running = falsefor idle sessions prevents the reattach block (line 270:if (found && running && ...)) from executing on reconnect. The session stays detached and on the 10-minute TTL abort timer. The reattach only happens lazily on the nexthandleSendV2call. If the user reconnects but doesn't send a message quickly, the session could be aborted. Consider whether idle sessions should still be reattached on reconnect even if reported as not-running.[fixable] - 🟡 bugs (L267): Even if
runningwere added to the summary, the client-side protocol parser (packages/client/src/protocol-parser.ts:129-131) explicitly ignores it: 'Running state derived from replayed session_state_changed events, not from the reconnected message payload.' A client-side test also confirms this ('reconnected does not dispatch SET_RUNNING'). The stated goal (client sends messages immediately instead of queuing behind a 5-second timer) won't be achieved without client-side changes to consume therunningfield.[fixable]
server/__tests__/ws-handler-v2.test.ts
The running field is never added to the reconnected summary sent to clients — all three new tests will fail. Additionally, the client-side parser ignores this field, so even with a fix, client-side changes are needed to achieve the stated goal.
- 🔴 regressions (L1114): The existing test at line 1064 (
'reconnected summary does not include running field') explicitly assertsexpect(summary.sessions[0]).not.toHaveProperty('running'). The three new tests directly contradict this by assertingrunningIS present with specific boolean values. One of these test groups must be updated — either the existing test is removed (ifrunningis being added) or the new tests are wrong.[fixable] - 🔵 missing_tests (L1114): No test verifies the behavioral side-effect: that idle sessions (lastSpeaker=assistant) skip the reattach logic on reconnect. The current tests only check the summary payload. A test should verify that
reattachChatis NOT called for idle sessions even when the session is detached.[fixable]
| @@ -267,6 +267,22 @@ export function handleReconnect( | |||
| }); | |||
There was a problem hiding this comment.
🔴 bugs: The running field is never added to the reconnected summary message. The summaries array (line 233) is typed Array<{ sessionId: string; replayed: number }> and the summaries.push() at line 321 only pushes sessionId and replayed. The new code modifies a local running variable but never propagates it to the client-facing message. All three new tests will fail because summary.sessions[0].running is undefined, not true/false. The summaries.push() must include running for this fix to work. [fixable]
| if (running) { | ||
| const storeMeta = ctx.eventStore.getSession(entry.sessionId); | ||
| if (storeMeta?.lastSpeaker === 'assistant') { | ||
| running = false; |
There was a problem hiding this comment.
🟡 regressions: Setting running = false for idle sessions prevents the reattach block (line 270: if (found && running && ...)) from executing on reconnect. The session stays detached and on the 10-minute TTL abort timer. The reattach only happens lazily on the next handleSendV2 call. If the user reconnects but doesn't send a message quickly, the session could be aborted. Consider whether idle sessions should still be reattached on reconnect even if reported as not-running. [fixable]
| @@ -267,6 +267,22 @@ export function handleReconnect( | |||
| }); | |||
There was a problem hiding this comment.
🟡 bugs: Even if running were added to the summary, the client-side protocol parser (packages/client/src/protocol-parser.ts:129-131) explicitly ignores it: 'Running state derived from replayed session_state_changed events, not from the reconnected message payload.' A client-side test also confirms this ('reconnected does not dispatch SET_RUNNING'). The stated goal (client sends messages immediately instead of queuing behind a 5-second timer) won't be achieved without client-side changes to consume the running field. [fixable]
| @@ -1114,6 +1114,104 @@ describe('handleReconnect reconnected summary (P1)', () => { | |||
| expect(reattachChat).not.toHaveBeenCalled(); | |||
There was a problem hiding this comment.
🔴 regressions: The existing test at line 1064 ('reconnected summary does not include running field') explicitly asserts expect(summary.sessions[0]).not.toHaveProperty('running'). The three new tests directly contradict this by asserting running IS present with specific boolean values. One of these test groups must be updated — either the existing test is removed (if running is being added) or the new tests are wrong. [fixable]
| @@ -1114,6 +1114,104 @@ describe('handleReconnect reconnected summary (P1)', () => { | |||
| expect(reattachChat).not.toHaveBeenCalled(); | |||
There was a problem hiding this comment.
🔵 missing_tests: No test verifies the behavioral side-effect: that idle sessions (lastSpeaker=assistant) skip the reattach logic on reconnect. The current tests only check the summary payload. A test should verify that reattachChat is NOT called for idle sessions even when the session is detached. [fixable]
Summary
handleReconnectusedisActive()(session exists in registry) to determinerunningstate, but this returnstruefor sessions whose query loop is alive but idle between turns. The frontend interpretsrunning=trueas "agent is busy" and queues messages behind a 5-second fallback timer instead of sending immediately — causing the persistent "two messages needed after reattach" bug.isActive/storeMeta.isActivechecks, also checklastSpeakerfrom EventStore.lastSpeaker=assistantmeans the agent completed its turn (idle) →running=false.lastSpeaker=usermeans the agent hasn't answered yet →running=true.Context
This bug has been the target of 13+ PRs (#320, #348, #353, #360, #362, #367, #368, #371, #372, #381, #384, #386, #392) fixing symptoms at different layers (SSE race conditions, iOS WS stale readyState, idempotency, state machine infra). None addressed the semantic mismatch: "query loop alive" ≠ "agent actively processing." The 5-second
PENDING_SEND_TIMEOUT_MStimer instore.ts:355is literally a band-aid for this — its comment says "stale running state after reconnect."Test plan
🤖 Generated with Claude Code