Skip to content

refactor(transport): SSOT phase 3 — clean reconnect - #440

Open
dimakis wants to merge 15 commits into
mainfrom
refactor/transport-ssot-p3
Open

refactor(transport): SSOT phase 3 — clean reconnect#440
dimakis wants to merge 15 commits into
mainfrom
refactor/transport-ssot-p3

Conversation

@dimakis

@dimakis dimakis commented Jul 4, 2026

Copy link
Copy Markdown
Owner

Summary

  • Remove ownership dance from handleReconnect — reattach/rekey/zombie cleanup removed (~60 lines). handleSendV2 already handles all of this on the first user message, making it redundant on reconnect.
  • Remove periodic sync — 5s timer in ConnectionRegistry that retried missed events is eliminated (~130 lines). Cursor-based replay on welcome covers the gap.
  • Fire-and-forget reconnect POST — client no longer defers _connected until the reconnect POST succeeds (~100 lines). Connection is marked immediately on welcome; the POST is advisory.
  • Remove ReconnectMessage from WS union — reconnect is REST-only, not dispatched via WebSocket.

Net: -1,043 lines across 9 files. All 177 affected tests pass.

Context

Phase 3 of the Transport SSOT series (P0 #431, P1 #433). The reconnect "ownership dance" duplicated logic already handled by handleSendV2 on the first user message. Periodic sync was a safety net made redundant by cursor replay on welcome.

What's kept

  • /reconnect REST endpoint + handleReconnect handler (watch + cursor reset + EventStore replay + boot context + suspend resume)
  • Client-side doPost('reconnect', ...) on welcome (fire-and-forget)
  • ConnectionRegistry.resetCursor() and cursor tracking in broadcast()

Test plan

  • npx vitest run — all 177 tests pass (ws-handler-v2, connection-registry, sse-connection)
  • Manual: kill server mid-chat, restart → verify auto-reconnect + event replay
  • Manual: background iOS app, foreground → verify suspend/resume
  • Manual: send message after reconnect → verify session resumes via handleSendV2

🤖 Generated with Claude Code

dimakis and others added 6 commits July 25, 2026 13:09
handleReconnect no longer does reattach/rekey/zombie cleanup — that's
all handled by handleSendV2 on the first user message. Reconnect now
only does: watch + cursor replay + suspend resume + boot context.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Periodic sync (5s timer retrying missed events) is redundant now that
reconnect replays via EventStore cursor on welcome. Removes setEventStore,
startPeriodicSync, stopPeriodicSync, EventStoreAdapter interface, and
all associated tests and wiring.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reconnect POST no longer defers _connected — the client marks connected
immediately on welcome. handleSendV2 handles ownership on first message,
and replayed events arrive via SSE regardless of POST outcome.

Removes doReconnectPost, scheduleReconnect, reconnectTimer, and
reconnectDelayMs. Replaces 12 deferred/stale/failure tests with 4
fire-and-forget tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reconnect is REST-only — keep the schema export for the REST handler
but exclude it from IncomingWsMessageV2. The WS dispatcher already
ignores it with a comment explaining why.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tion

Stale session test now asserts remove is NOT called (deferred to handleSendV2).
Suspend resume test clears reattachChat mock to avoid bleed from prior tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add missing setSessionState mock to routes and suspend-routes tests.
Remove reconnect from WS union test since P3 moved it to REST-only.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
P3 removed ReconnectMessage from the WS union (reconnect is now
REST-only), but the switch case was left behind causing a type error.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@dimakis dimakis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Centaur Review

Found 5 issue(s) (1 critical) (3 warning).

packages/protocol/src/ws-schemas-v2.ts

Sound simplification overall, but removing ReconnectMessage from the WS union breaks the WS transport fallback (MitzoConnection still sends reconnect over WS). The fire-and-forget design also introduces a subtle cursor race between send and reconnect POST arrival order.

  • 🔴 regressions (L129): ReconnectMessage removed from WS union breaks the WS transport fallback. MitzoConnection (packages/client/src/connection.ts:228) still sends { type: 'reconnect' } over WS on reconnect. Since it's no longer in the discriminated union, the server's dispatchV2Message Zod parse rejects it and the message is silently dropped. This means WS clients get no event replay, no cursor reset, and no boot_context re-send on reconnect. Either update MitzoConnection to send reconnect via REST POST (like SseConnection does), or keep ReconnectMessage in the WS union with a no-op handler. [fixable]

server/ws-handler-v2.ts

Sound simplification overall, but removing ReconnectMessage from the WS union breaks the WS transport fallback (MitzoConnection still sends reconnect over WS). The fire-and-forget design also introduces a subtle cursor race between send and reconnect POST arrival order.

  • 🔵 style (L915): Dead code: case 'reconnect': can never be reached because reconnect was removed from the IncomingWsMessageV2 union. The Zod parse on line 900 rejects reconnect messages before this switch. Remove this case to avoid confusion. [fixable]

packages/client/src/sse-connection.ts

Sound simplification overall, but removing ReconnectMessage from the WS union breaks the WS transport fallback (MitzoConnection still sends reconnect over WS). The fire-and-forget design also introduces a subtle cursor race between send and reconnect POST arrival order.

  • 🟡 unsafe_assumptions (L211): Fire-and-forget reconnect POST means sends can arrive at the server before handleReconnect runs. handleSendV2 calls watch() (line 541 of ws-handler-v2.ts) but does NOT call resetCursor(), so the cursor starts at 0 for that connection+session. If a broadcast fires during the race window (between send and reconnect POST processing), the cursor advances. When handleReconnect then calls resetCursor(lastSeq), it could roll the cursor backward, potentially causing duplicate event delivery on the next reconnect. Consider adding a resetCursor call in the handleSendV2 takeover/reattach path, or documenting why this is acceptable. [fixable]

packages/client/src/__tests__/sse-connection.test.ts

Sound simplification overall, but removing ReconnectMessage from the WS union breaks the WS transport fallback (MitzoConnection still sends reconnect over WS). The fire-and-forget design also introduces a subtle cursor race between send and reconnect POST arrival order.

  • 🟡 missing_tests: The test for 'sends reconnect POST fire-and-forget on reconnect welcome' verifies the POST is sent, but there's no test for the case where a send is queued AND the reconnect POST races with it. Specifically: queue a send during reconnect, receive welcome, verify both reconnect and send are dispatched without ordering guarantees (the new 'flushes pending sends immediately' test covers this but doesn't test that the server can handle either arriving first).

packages/client/__tests__/connection.test.ts

Sound simplification overall, but removing ReconnectMessage from the WS union breaks the WS transport fallback (MitzoConnection still sends reconnect over WS). The fire-and-forget design also introduces a subtle cursor race between send and reconnect POST arrival order.

  • 🟡 regressions: The connection.test.ts tests at line 197 ('reconnects after close and sends hello + reconnect with tracked sessions') still assert that reconnect is sent over WS and expect it to work. These tests pass because they only check the client-side behavior (message is sent), but they're now testing a path that's broken server-side (reconnect over WS is silently dropped). These tests give false confidence. [fixable]

Reviewed at fe40257


// ReconnectMessage is handled via REST POST (not WS) — exported for
// the REST handler but excluded from the WS union.
export const IncomingWsMessageV2 = z.discriminatedUnion('type', [

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 regressions: ReconnectMessage removed from WS union breaks the WS transport fallback. MitzoConnection (packages/client/src/connection.ts:228) still sends { type: 'reconnect' } over WS on reconnect. Since it's no longer in the discriminated union, the server's dispatchV2Message Zod parse rejects it and the message is silently dropped. This means WS clients get no event replay, no cursor reset, and no boot_context re-send on reconnect. Either update MitzoConnection to send reconnect via REST POST (like SseConnection does), or keep ReconnectMessage in the WS union with a no-op handler. [fixable]

Comment thread server/ws-handler-v2.ts
case 'hello':
// Already handled at routing layer, ignore duplicate
break;
case 'reconnect':

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 style: Dead code: case 'reconnect': can never be reached because reconnect was removed from the IncomingWsMessageV2 union. The Zod parse on line 900 rejects reconnect messages before this switch. Remove this case to avoid confusion. [fixable]

Comment thread packages/client/src/sse-connection.ts Outdated
this._connected = true;
this.flushPendingSends();
this.listener?.({ type: '_open' });
this.doPost('reconnect', {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 unsafe_assumptions: Fire-and-forget reconnect POST means sends can arrive at the server before handleReconnect runs. handleSendV2 calls watch() (line 541 of ws-handler-v2.ts) but does NOT call resetCursor(), so the cursor starts at 0 for that connection+session. If a broadcast fires during the race window (between send and reconnect POST processing), the cursor advances. When handleReconnect then calls resetCursor(lastSeq), it could roll the cursor backward, potentially causing duplicate event delivery on the next reconnect. Consider adding a resetCursor call in the handleSendV2 takeover/reattach path, or documenting why this is acceptable. [fixable]

… crashes

Without this try/catch, an unhandled error from the transport layer
kills the server, losing all in-memory state and triggering replay
storms on client reconnect.

Cherry-picked from #396 (now closed as superseded by P3).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@dimakis

dimakis commented Jul 25, 2026

Copy link
Copy Markdown
Owner Author

Rebased onto main, fixed CI (dead reconnect case in WS handler switch, missing setSessionState mocks in tests, removed reconnect from protocol union test). Cherry-picked interrupt guard from #396. Ready for review.

@dimakis

dimakis commented Jul 25, 2026

Copy link
Copy Markdown
Owner Author

Centaur Review

Found 6 issue(s) (2 warning).

server/index.ts

Clean simplification that removes ~400 lines of reconnect complexity. The main risk is the fire-and-forget reconnect POST: if it fails, the server never replays missed events or resets cursors, yet the client considers itself connected and flushes queued sends. The old code had explicit failure recovery (scheduleReconnect); the new code relies on EventSource auto-reconnect which only fires on connection loss, not POST failure.

  • 🔵 style (L984): Stale comment: // Stop periodic sync + clear state — periodic sync was removed in this PR. Should be // Clear connection state or similar. [fixable]

packages/client/src/sse-connection.ts

Clean simplification that removes ~400 lines of reconnect complexity. The main risk is the fire-and-forget reconnect POST: if it fails, the server never replays missed events or resets cursors, yet the client considers itself connected and flushes queued sends. The old code had explicit failure recovery (scheduleReconnect); the new code relies on EventSource auto-reconnect which only fires on connection loss, not POST failure.

  • 🟡 unsafe_assumptions (L210): Fire-and-forget reconnect POST has no visibility into failure. If the POST to /api/chat/reconnect fails (network error, 500), the server never runs handleReconnect — meaning no EventStore replay, no cursor reset, no suspend resume. The client marks itself connected and flushes pending sends, but the server hasn't restored session state. Unlike before (where failure triggered scheduleReconnect), there's now no recovery path. EventSource auto-reconnect would fire a new welcome, but only if the connection actually drops — a POST failure alone won't trigger it. Consider at minimum logging the failure in doPost for reconnect calls. [fixable]
  • 🟡 regressions (L218): Pending sends are now flushed immediately on welcome, before the reconnect POST completes. Previously, sends were held until the server confirmed the reconnect (cursor reset, replay, reattach). Now a queued send message can race with the reconnect POST — the server may process the send before handleReconnect has reset cursors and replayed missed events. This is mitigated by handleSendV2 doing ownership on first message, but the ordering guarantee (reconnect-then-send) is no longer enforced client-side.

packages/client/src/__tests__/sse-connection.test.ts

Clean simplification that removes ~400 lines of reconnect complexity. The main risk is the fire-and-forget reconnect POST: if it fails, the server never replays missed events or resets cursors, yet the client considers itself connected and flushes queued sends. The old code had explicit failure recovery (scheduleReconnect); the new code relies on EventSource auto-reconnect which only fires on connection loss, not POST failure.

  • 🔵 missing_tests: The PR removed 12 tests covering reconnect failure/retry/race scenarios and added only 3 simplified replacements. There's no test verifying behavior when EventSource auto-reconnects (fires multiple welcome events rapidly) — duplicate reconnect POSTs could be sent. A test for the auto-reconnect scenario would increase confidence in the fire-and-forget design. [fixable]

packages/harness/src/connection-registry.ts

Clean simplification that removes ~400 lines of reconnect complexity. The main risk is the fire-and-forget reconnect POST: if it fails, the server never replays missed events or resets cursors, yet the client considers itself connected and flushes queued sends. The old code had explicit failure recovery (scheduleReconnect); the new code relies on EventSource auto-reconnect which only fires on connection loss, not POST failure.

  • 🔵 regressions (L133): Comment says reconnect replay will cover the gap when broadcast send fails, but if the reconnect POST itself fails (fire-and-forget), there's no replay either. The comment is accurate for the normal case but understates the failure mode. [fixable]

server/__tests__/routes.test.ts

Clean simplification that removes ~400 lines of reconnect complexity. The main risk is the fire-and-forget reconnect POST: if it fails, the server never replays missed events or resets cursors, yet the client considers itself connected and flushes queued sends. The old code had explicit failure recovery (scheduleReconnect); the new code relies on EventSource auto-reconnect which only fires on connection loss, not POST failure.

  • 🔵 style (L112): Added setSessionState: vi.fn() mock but no test in this file exercises it. Likely added to satisfy a TypeScript interface requirement — harmless but worth confirming it's needed.

Reviewed at 263f7bc

…nt cursor race

- Restore ReconnectMessage in WS union and handler (WS clients still
  send reconnect over WS until P4 removes WS transport)
- Document why cursor race between fire-and-forget reconnect POST
  and handleSendV2 is benign (single-threaded + client seq dedup)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@dimakis

dimakis commented Jul 25, 2026

Copy link
Copy Markdown
Owner Author

All three findings addressed: restored WS reconnect in union + handler, documented cursor race as benign (single-threaded + client seq dedup), cherry-picked interrupt guard from #396. Please re-review.

@dimakis

dimakis commented Jul 25, 2026

Copy link
Copy Markdown
Owner Author

Centaur Review

Found 6 issue(s) (3 warning).

packages/client/src/sse-connection.ts

Solid simplification that removes ~300 lines of complex reconnect-POST-gating logic. The fire-and-forget approach is architecturally sound given EventStore replay and client-side seq dedup, but the silent failure mode of reconnect POSTs (no retry, no logging, no event replay on failure) deserves at minimum a warning log and documentation of the recovery path.

  • 🟡 unsafe_assumptions (L219): Client marks _connected = true and flushes pending sends before the reconnect POST completes. If the POST fails (network error, server 500), the server never processes handleReconnect — no cursor reset, no event replay, no boot context re-send. The client thinks it's connected and sends messages into a session the server hasn't re-established context for. The comment in doPost says 'SSE stream will reconnect and replay' but that only applies to EventSource-level reconnect, not the reconnect POST itself. The 48h detach TTL means the session won't be killed, but the user may see a gap in replayed events until they trigger another reconnect cycle.
  • 🟡 unsafe_assumptions (L210): Reconnect POST and pending sends (flushPendingSends at line 220) both fire as concurrent fire-and-forget POSTs. No ordering guarantee exists — the server may process a queued send before the reconnect POST. The comment at ws-handler-v2.ts:542-546 acknowledges this and claims Node's event loop prevents true interleaving, but HTTP requests from the same client can arrive on different TCP connections and be processed out of order at the Express layer. In practice, handleSendV2 does call watch() and handles ownership independently, so this may be benign, but it's worth noting that cursor won't be set correctly until the reconnect POST arrives.
  • 🔵 style (L266): The empty catch block in doPost (line 266-268) silently swallows all errors including reconnect POST failures. Consider at minimum a console.warn for reconnect failures specifically, since a failed reconnect POST means the server won't replay events or re-send boot context. The comment 'POST failures non-fatal' understates the impact for reconnect specifically. [fixable]

server/ws-handler-v2.ts

Solid simplification that removes ~300 lines of complex reconnect-POST-gating logic. The fire-and-forget approach is architecturally sound given EventStore replay and client-side seq dedup, but the silent failure mode of reconnect POSTs (no retry, no logging, no event replay on failure) deserves at minimum a warning log and documentation of the recovery path.

  • 🟡 regressions (L255): Removing reattach from handleReconnect means a detached session stays detached until the user sends a message or interrupt. Previously, reconnect would reattach immediately, cancelling the detach timer (indirectly via reattachChat). Now, if a user reconnects and passively observes an agent turn in progress (no send/interrupt), the session remains detached. The 48h detach TTL (from session-registry.ts) is long enough that this is unlikely to cause session death in practice, but the agent's transport stays stale — new events flow via connRegistry.broadcast() (watch-based), not via the session's attached transport. If any code path uses the session's attached transport instead of broadcasting, those events would be lost.
  • 🔵 style (L542): The 5-line comment block (542-546) explaining why resetCursor isn't called from handleSendV2 references 'Node's single-threaded event loop prevents true interleaving' which is misleading — two HTTP requests (reconnect POST and send POST) can arrive as separate events on the event loop and interleave at the await boundaries. The actual safety net is client-side seq dedup (mentioned last), which deserves to be the lead explanation. [fixable]

packages/client/src/__tests__/sse-connection.test.ts

Solid simplification that removes ~300 lines of complex reconnect-POST-gating logic. The fire-and-forget approach is architecturally sound given EventStore replay and client-side seq dedup, but the silent failure mode of reconnect POSTs (no retry, no logging, no event replay on failure) deserves at minimum a warning log and documentation of the recovery path.

  • 🔵 missing_tests: The tests removed 11 async reconnect scenarios (deferred connect, stale POST guard, POST failure recovery, queued send survival across retries, etc.) and replaced them with 3 simpler synchronous tests. While this matches the simplified fire-and-forget design, there's no test verifying that a failed reconnect POST doesn't prevent event replay via SSE — the core assumption that makes fire-and-forget safe. A test showing SSE events still arrive after reconnect POST failure would document this guarantee. [fixable]

Reviewed at 66ef2cf

- Add warn log for failed POSTs (was silent catch)
- Retry reconnect POST on next EventSource reconnect if it fails
- Flush pending sends AFTER reconnect POST (ordering guarantee)
- Reattach detached sessions on reconnect (was deferred to send)
- Fix comment to lead with client-side seq dedup, not event loop
- Add test for SSE event delivery after reconnect POST failure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@dimakis dimakis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Centaur Review

Found 7 issue(s) (1 critical) (5 warning).

packages/client/src/sse-connection.ts

Critical bug: doPost doesn't check res.ok, so HTTP 500 from the reconnect endpoint silently clears the retry state — a regression from the old doReconnectPost which explicitly handled non-ok responses. Secondary issues: _pendingReconnectSessions is not cleared on disconnect() and never reconciled with seqBySession, and the new retry mechanism has zero test coverage.

  • 🔴 bugs (L274): doPost does not check res.ok. fetch() only rejects on network errors — an HTTP 500 resolves the promise successfully. The reconnect POST .then() handler (line 224) clears _pendingReconnectSessions on resolve, so a server 500 is treated as success: cursor is never reset, replay never happens, and the retry mechanism is silently disarmed. The old doReconnectPost explicitly checked res.ok and called scheduleReconnect() on non-ok responses. Fix: check res.ok in doPost and throw on non-ok, or check it at the reconnect call site. [fixable]
  • 🟡 bugs (L60): disconnect() does not clear _pendingReconnectSessions. If a reconnect POST failed (setting _pendingReconnectSessions), then the user disconnects and later reconnects, the stale sessions array will be retried on the next welcome — even if those sessions no longer exist locally (cleared via clearSession). Add this._pendingReconnectSessions = null; in disconnect(). [fixable]
  • 🟡 unsafe_assumptions (L212): _pendingReconnectSessions is used as a retry payload but is never reconciled with seqBySession. If a session is cleared (clearSession) while _pendingReconnectSessions holds a reference to it, the next welcome retries a reconnect POST for a session the client no longer tracks. The stale entry will cause unnecessary server work and log noise. Consider filtering _pendingReconnectSessions against current seqBySession keys before retrying. [fixable]
  • 🔵 style (L86): Bare .catch(() => {}) on four doPost calls silently swallows errors. Now that doPost logs a console.warn before throwing, the catch is functionally correct, but the pattern is fragile — a future caller might assume doPost doesn't throw. Consider having doPost not throw (log-only) for fire-and-forget calls, and a separate doPostOrThrow for the reconnect path that needs error discrimination.

packages/client/src/__tests__/sse-connection.test.ts

Critical bug: doPost doesn't check res.ok, so HTTP 500 from the reconnect endpoint silently clears the retry state — a regression from the old doReconnectPost which explicitly handled non-ok responses. Secondary issues: _pendingReconnectSessions is not cleared on disconnect() and never reconciled with seqBySession, and the new retry mechanism has zero test coverage.

  • 🟡 missing_tests: No test covers the HTTP 500 (non-ok) response case for reconnect POST. The old suite had two tests for this (stays disconnected when reconnect POST fails, recovers after failed reconnect when EventSource auto-reconnects). With the new fire-and-forget model, the behavior for non-ok responses is different from network errors — but no test verifies which path is taken. Given the doPost bug above, a 500 silently clears _pendingReconnectSessions. [fixable]
  • 🟡 missing_tests: No test verifies the _pendingReconnectSessions retry-on-next-welcome mechanism — the key new behavior this PR introduces. A test should verify: (1) reconnect POST fails → _pendingReconnectSessions retained, (2) EventSource auto-reconnects → next welcome retries with the saved sessions, (3) success clears _pendingReconnectSessions. [fixable]

server/ws-handler-v2.ts

Critical bug: doPost doesn't check res.ok, so HTTP 500 from the reconnect endpoint silently clears the retry state — a regression from the old doReconnectPost which explicitly handled non-ok responses. Secondary issues: _pendingReconnectSessions is not cleared on disconnect() and never reconciled with seqBySession, and the new retry mechanism has zero test coverage.

  • 🟡 regressions (L259): handleReconnect now only reattaches sessions where ownerConnection === connectionId. When a user's device gets a new connectionId (e.g. app restart), the session's clientId still has the old connectionId prefix. In this scenario, reconnect won't reattach (owner mismatch), and the agent's transport stays stale until the user sends a message (triggering handleSendV2 takeover). During this gap, the agent may try to write to a dead transport — permission prompts, for example, would be lost. The old code handled this by checking ownerGone and reattaching eagerly. [fixable]

Reviewed at 13153d6

}, this.config.reconnectDelayMs);
}

private async doPost(endpoint: string, body: Record<string, unknown>): Promise<void> {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 bugs: doPost does not check res.ok. fetch() only rejects on network errors — an HTTP 500 resolves the promise successfully. The reconnect POST .then() handler (line 224) clears _pendingReconnectSessions on resolve, so a server 500 is treated as success: cursor is never reset, replay never happens, and the retry mechanism is silently disarmed. The old doReconnectPost explicitly checked res.ok and called scheduleReconnect() on non-ok responses. Fix: check res.ok in doPost and throw on non-ok, or check it at the reconnect call site. [fixable]

@@ -60,10 +59,6 @@ export class SseConnection implements ChatConnection {

disconnect(): void {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bugs: disconnect() does not clear _pendingReconnectSessions. If a reconnect POST failed (setting _pendingReconnectSessions), then the user disconnects and later reconnects, the stale sessions array will be retried on the next welcome — even if those sessions no longer exist locally (cleared via clearSession). Add this._pendingReconnectSessions = null; in disconnect(). [fixable]

Comment thread packages/client/src/sse-connection.ts Outdated
// Fire reconnect POST if reconnecting with sessions, or retry a
// previously failed reconnect. handleSendV2 handles ownership on first
// message, and replayed events arrive via SSE regardless.
const sessions =

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 unsafe_assumptions: _pendingReconnectSessions is used as a retry payload but is never reconciled with seqBySession. If a session is cleared (clearSession) while _pendingReconnectSessions holds a reference to it, the next welcome retries a reconnect POST for a session the client no longer tracks. The stale entry will cause unnecessary server work and log noise. Consider filtering _pendingReconnectSessions against current seqBySession keys before retrying. [fixable]


if (this._connected && this._connectionId) {
this.doPost(endpoint, msg);
this.doPost(endpoint, msg).catch(() => {});

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 style: Bare .catch(() => {}) on four doPost calls silently swallows errors. Now that doPost logs a console.warn before throwing, the catch is functionally correct, but the pattern is fragile — a future caller might assume doPost doesn't throw. Consider having doPost not throw (log-only) for fire-and-forget calls, and a separate doPostOrThrow for the reconnect path that needs error discrimination.

Comment thread server/ws-handler-v2.ts
newClientId,
});
}
if (found && ctx.sessionRegistry.isActive(found.clientId)) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 regressions: handleReconnect now only reattaches sessions where ownerConnection === connectionId. When a user's device gets a new connectionId (e.g. app restart), the session's clientId still has the old connectionId prefix. In this scenario, reconnect won't reattach (owner mismatch), and the agent's transport stays stale until the user sends a message (triggering handleSendV2 takeover). During this gap, the agent may try to write to a dead transport — permission prompts, for example, would be lost. The old code handled this by checking ownerGone and reattaching eagerly. [fixable]

@dimakis dimakis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Centaur Review

Found 6 issue(s) (3 warning).

packages/client/src/sse-connection.ts

Sound simplification — fire-and-forget reconnect and periodic sync removal are architecturally justified. Main concern is _pendingReconnectSessions not being cleared in disconnect(), which can cause post-teardown side effects, plus missing test coverage for the new retry mechanism.

  • 🟡 bugs (L60): disconnect() does not clear _pendingReconnectSessions. If disconnect() is called while a reconnect POST is in-flight, the .then() callback can still fire and call flushPendingSends() after the connection is torn down. Additionally, the stale sessions array persists in memory. Add this._pendingReconnectSessions = null; to disconnect(). [fixable]
  • 🟡 unsafe_assumptions (L223): The reconnect POST's .then() callback clears _pendingReconnectSessions and flushes sends without checking whether _connectionId has changed since the POST was initiated. If a new welcome arrives while the POST is in-flight (EventSource auto-reconnect), the new welcome captures _pendingReconnectSessions (line 213), starts a second POST, and then the stale .then() clears the flag. This is benign only if the server handles duplicate reconnect POSTs with different connection IDs idempotently — which it does (handleReconnect is stateless per call) — but the double flushPendingSends() could cause sends to fire twice. Consider capturing _connectionId before the POST and comparing in the callback. [fixable]

server/ws-handler-v2.ts

Sound simplification — fire-and-forget reconnect and periodic sync removal are architecturally justified. Main concern is _pendingReconnectSessions not being cleared in disconnect(), which can cause post-teardown side effects, plus missing test coverage for the new retry mechanism.

  • 🔵 style (L260): Uses found.clientId.split(':')[0] while every other ownership check in this file (lines 519, 681, 819, 868) uses the getOwnerConnection() utility defined at line 93. Use getOwnerConnection(found.clientId) for consistency — they return the same result but the utility is the convention and would track any format changes. [fixable]
  • 🟡 regressions (L259): handleReconnect now only reattaches sessions owned by the reconnecting connection (ownerConnection === connectionId). Previously, it also handled cross-connection reattach when the owner was gone. This is deferred to handleSendV2, but creates a window where a non-owner reconnecting client sees events via broadcast while the agent's transport remains stale. Passive observers (watch-only, never send) will never trigger the handleSendV2 ownership path. This is an intentional design choice (documented in comments), but worth noting as a behavioral change.

packages/client/src/__tests__/sse-connection.test.ts

Sound simplification — fire-and-forget reconnect and periodic sync removal are architecturally justified. Main concern is _pendingReconnectSessions not being cleared in disconnect(), which can cause post-teardown side effects, plus missing test coverage for the new retry mechanism.

  • 🔵 missing_tests: No test verifies that disconnect() during an in-flight reconnect POST doesn't cause side effects (the old test bails out if disconnect() called during in-flight reconnect POST was removed). The fire-and-forget model changes the expected behavior, but there should be a test confirming that the .then() callback doesn't flush sends or emit _open after disconnect(). [fixable]
  • 🔵 missing_tests: No test verifies the retry path: reconnect POST fails (setting _pendingReconnectSessions), then a new welcome arrives and retries with the stored sessions. The _pendingReconnectSessions retry mechanism is the key new behavior replacing the removed scheduleReconnect, but it has no dedicated test. [fixable]

Reviewed at 1737898

@@ -60,10 +59,6 @@ export class SseConnection implements ChatConnection {

disconnect(): void {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bugs: disconnect() does not clear _pendingReconnectSessions. If disconnect() is called while a reconnect POST is in-flight, the .then() callback can still fire and call flushPendingSends() after the connection is torn down. Additionally, the stale sessions array persists in memory. Add this._pendingReconnectSessions = null; to disconnect(). [fixable]

this._connected = true;
if (sessions) {
this._pendingReconnectSessions = sessions;
this.doPost('reconnect', { type: 'reconnect', sessions }).then(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 unsafe_assumptions: The reconnect POST's .then() callback clears _pendingReconnectSessions and flushes sends without checking whether _connectionId has changed since the POST was initiated. If a new welcome arrives while the POST is in-flight (EventSource auto-reconnect), the new welcome captures _pendingReconnectSessions (line 213), starts a second POST, and then the stale .then() clears the flag. This is benign only if the server handles duplicate reconnect POSTs with different connection IDs idempotently — which it does (handleReconnect is stateless per call) — but the double flushPendingSends() could cause sends to fire twice. Consider capturing _connectionId before the POST and comparing in the callback. [fixable]

Comment thread server/ws-handler-v2.ts Outdated
});
}
if (found && ctx.sessionRegistry.isActive(found.clientId)) {
const ownerConnection = found.clientId.split(':')[0];

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 style: Uses found.clientId.split(':')[0] while every other ownership check in this file (lines 519, 681, 819, 868) uses the getOwnerConnection() utility defined at line 93. Use getOwnerConnection(found.clientId) for consistency — they return the same result but the utility is the convention and would track any format changes. [fixable]

Comment thread server/ws-handler-v2.ts
newClientId,
});
}
if (found && ctx.sessionRegistry.isActive(found.clientId)) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 regressions: handleReconnect now only reattaches sessions owned by the reconnecting connection (ownerConnection === connectionId). Previously, it also handled cross-connection reattach when the owner was gone. This is deferred to handleSendV2, but creates a window where a non-owner reconnecting client sees events via broadcast while the agent's transport remains stale. Passive observers (watch-only, never send) will never trigger the handleSendV2 ownership path. This is an intentional design choice (documented in comments), but worth noting as a behavioral change.

- Check res.ok in doPost (RED: HTTP 500 was treated as success)
- Clear _pendingReconnectSessions on disconnect (YELLOW: stale retry)
- Filter pending reconnect sessions against seqBySession (YELLOW: cleared sessions retried)
- Reattach detached sessions when owner connection is gone (YELLOW: device restart gap)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@dimakis

dimakis commented Jul 25, 2026

Copy link
Copy Markdown
Owner Author

Centaur Review

Found 8 issue(s) (2 critical) (4 warning).

server/ws-handler-v2.ts

The fire-and-forget reconnect simplification introduces send-before-reconnect race conditions predicated on a client-side seq dedup mechanism that does not exist — this is the critical gap that needs addressing before merge.

  • 🔴 bugs (L566): Comment claims 'client-side seq dedup (store.ts) drops events with seq <= lastProcessedSeq' but this mechanism does not exist. No code in the client package (store.ts, protocol-parser.ts, sse-connection.ts, connection.ts) filters incoming events by seq. seqBySession only tracks the last seen seq for reconnect coordination — events are always forwarded unconditionally to the listener. When send POST arrives before reconnect POST (a real possibility since _connected=true fires immediately on welcome), handleSendV2 calls watch() without resetCursor(), and subsequent broadcasts may deliver duplicate events to a client that cannot deduplicate them. [fixable]
  • 🟡 regressions (L271): handleReconnect now reattaches without rekeying. When a device restarts (new connectionId), the session is reattached to the new transport but clientId stays as old-conn:sess-1. The subsequent handleSendV2 call will detect ownership mismatch and do a full takeover (reattach + rekey), causing a redundant double-reattach. More importantly, if the user never sends (passive observer), the stale clientId persists for the session's lifetime, and log entries will attribute agent activity to the old connection. [fixable]

packages/client/src/sse-connection.ts

The fire-and-forget reconnect simplification introduces send-before-reconnect race conditions predicated on a client-side seq dedup mechanism that does not exist — this is the critical gap that needs addressing before merge.

  • 🔴 bugs (L299): doPost comment states 'Client-side seq dedup prevents duplicate delivery' but no seq dedup exists on the client. This same false claim appears in ws-handler-v2.ts:566. The architectural assumption that duplicates are harmless is incorrect — the message reducer processes every event unconditionally, so duplicate block_delta/block_start events will corrupt the UI state (double text, duplicate tool blocks). [fixable]
  • 🟡 unsafe_assumptions (L226): Setting _connected = true before the reconnect POST resolves means send() (line 86-88) can fire user messages to the server before handleReconnect runs. The queued sends in flushPendingSends() are correctly ordered (they wait for the .then), but NEW send() calls from the user bypass the queue entirely. The reconnect POST body comment (line 232-234) acknowledges ordering matters but only controls pre-queued sends, not live calls. [fixable]
  • 🟡 regressions (L240): When the reconnect POST fails, flushPendingSends() is called immediately (line 240). These queued messages will POST to the server, but the server never ran handleReconnect for this cycle — no cursor reset, no replay. The server's handleSendV2 handles ownership independently, but the sends arrive without the context the reconnect would have established (boot_context replay, cursor positioning). Previously, a failed reconnect POST kept the client disconnected, naturally preventing premature sends.

packages/client/src/__tests__/sse-connection.test.ts

The fire-and-forget reconnect simplification introduces send-before-reconnect race conditions predicated on a client-side seq dedup mechanism that does not exist — this is the critical gap that needs addressing before merge.

  • 🟡 missing_tests: No test covers the race where a user calls send() between _connected = true and reconnect POST completion. The existing 'flushes pending sends after reconnect POST completes' test only covers pre-queued sends. A test should verify that a live send() call during the reconnect POST window does not cause ordering violations. [fixable]

server/index.ts

The fire-and-forget reconnect simplification introduces send-before-reconnect race conditions predicated on a client-side seq dedup mechanism that does not exist — this is the critical gap that needs addressing before merge.

  • 🔵 style (L984): Stale comment: connRegistry.dispose(); // Stop periodic sync + clear state — periodic sync was removed in this PR. Four additional stale 'periodic sync' references remain in connection-registry.ts:113, connection-registry.test.ts:222/:281, and ws-handler-v2.ts:412. [fixable]

server/__tests__/ws-handler-v2.test.ts

The fire-and-forget reconnect simplification introduces send-before-reconnect race conditions predicated on a client-side seq dedup mechanism that does not exist — this is the critical gap that needs addressing before merge.

  • 🔵 missing_tests: The deleted 'handleReconnect ownership guard' test block covered the case where a different connection owns the session AND is still alive. The old behavior correctly refused reattach. The new code (ws-handler-v2.ts:263-264) checks ownerGone but there's no test verifying that reattach is skipped when the original owner is still registered in connRegistry. [fixable]

Reviewed at 76c4040

@dimakis dimakis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Centaur Review

Found 5 issue(s) (1 warning).

packages/client/src/sse-connection.ts

Clean simplification that removes periodic sync and defers ownership operations to handleSendV2. Architecturally sound — seq dedup guarantees correctness. One minor inefficiency where retry reconnect uses stale lastSeq, and one style inconsistency with getOwnerConnection.

  • 🟡 bugs (L215): When _pendingReconnectSessions retries on the next welcome, the stored lastSeq values are from the original failed attempt. Between failure and retry, seqBySession may have advanced via trackSeq() as SSE events arrived. The filter (line 215-216) only checks seqBySession.has() for existence but doesn't refresh lastSeq from the current map. Consider replacing the pending entries' lastSeq with current values: pending.map(s => ({ ...s, lastSeq: this.seqBySession.get(s.sessionId)! })). Functionally safe due to seq dedup but causes unnecessary replay of events the client already has. [fixable]
  • 🔵 unsafe_assumptions (L226): Setting _connected = true before the reconnect POST completes means send() (line 86-88) will fire user messages immediately via doPost(), potentially arriving at the server before the reconnect POST. The comment at ws-handler-v2.ts:563-569 documents this is safe due to client-side seq dedup, but there is no server-side ordering guarantee. If a user message arrives before handleReconnect sets up the cursor, broadcast() events may be delivered at cursor=0 until the reconnect POST arrives. This is self-healing but could cause a burst of duplicate events.

server/ws-handler-v2.ts

Clean simplification that removes periodic sync and defers ownership operations to handleSendV2. Architecturally sound — seq dedup guarantees correctness. One minor inefficiency where retry reconnect uses stale lastSeq, and one style inconsistency with getOwnerConnection.

  • 🔵 style (L260): Uses found.clientId.split(':')[0] instead of the existing getOwnerConnection(found.clientId) helper (defined at line 93 and used elsewhere in the same file, e.g. lines 527, 689, 827). Inconsistent — use the helper for uniformity and to benefit from its indexOf fast-path. [fixable]
  • 🔵 regressions (L259): Reconnect no longer calls rekeyChat() — ownership transfer is deferred to handleSendV2. Between reconnect and first send, the session's clientId still references the old connectionId while the transport points to the new connection. This is fine for event delivery (transport is reattached), but if the agent emits logs or metrics using clientId, they'll reference the stale connectionId until the first send triggers rekey. This is a deliberate design choice (documented in the test renames) — flagging for awareness, not as a bug.

packages/client/src/__tests__/sse-connection.test.ts

Clean simplification that removes periodic sync and defers ownership operations to handleSendV2. Architecturally sound — seq dedup guarantees correctness. One minor inefficiency where retry reconnect uses stale lastSeq, and one style inconsistency with getOwnerConnection.

  • 🔵 missing_tests: No test verifies that _pendingReconnectSessions are filtered correctly when a session is cleared via clearSession() between the failed reconnect POST and the retry welcome. The filter at line 215 depends on this, and a test like 'retried reconnect excludes sessions cleared between attempts' would validate the guard. [fixable]

Comment thread packages/client/src/sse-connection.ts Outdated
// message, and replayed events arrive via SSE regardless.
// Filter pending retries against current seqBySession — sessions may
// have been cleared (clearSession) since the retry was queued.
const pending = this._pendingReconnectSessions?.filter((s) =>

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bugs: When _pendingReconnectSessions retries on the next welcome, the stored lastSeq values are from the original failed attempt. Between failure and retry, seqBySession may have advanced via trackSeq() as SSE events arrived. The filter (line 215-216) only checks seqBySession.has() for existence but doesn't refresh lastSeq from the current map. Consider replacing the pending entries' lastSeq with current values: pending.map(s => ({ ...s, lastSeq: this.seqBySession.get(s.sessionId)! })). Functionally safe due to seq dedup but causes unnecessary replay of events the client already has. [fixable]

Comment thread packages/client/src/sse-connection.ts Outdated
lastSeq,
}))
: null);
this._connected = true;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 unsafe_assumptions: Setting _connected = true before the reconnect POST completes means send() (line 86-88) will fire user messages immediately via doPost(), potentially arriving at the server before the reconnect POST. The comment at ws-handler-v2.ts:563-569 documents this is safe due to client-side seq dedup, but there is no server-side ordering guarantee. If a user message arrives before handleReconnect sets up the cursor, broadcast() events may be delivered at cursor=0 until the reconnect POST arrives. This is self-healing but could cause a burst of duplicate events.

Comment thread server/ws-handler-v2.ts Outdated
});
}
if (found && ctx.sessionRegistry.isActive(found.clientId)) {
const ownerConnection = found.clientId.split(':')[0];

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 style: Uses found.clientId.split(':')[0] instead of the existing getOwnerConnection(found.clientId) helper (defined at line 93 and used elsewhere in the same file, e.g. lines 527, 689, 827). Inconsistent — use the helper for uniformity and to benefit from its indexOf fast-path. [fixable]

Comment thread server/ws-handler-v2.ts
newClientId,
});
}
if (found && ctx.sessionRegistry.isActive(found.clientId)) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 regressions: Reconnect no longer calls rekeyChat() — ownership transfer is deferred to handleSendV2. Between reconnect and first send, the session's clientId still references the old connectionId while the transport points to the new connection. This is fine for event delivery (transport is reattached), but if the agent emits logs or metrics using clientId, they'll reference the stale connectionId until the first send triggers rekey. This is a deliberate design choice (documented in the test renames) — flagging for awareness, not as a bug.

- Refresh lastSeq from current seqBySession on pending retry (YELLOW: stale seq causes unnecessary replay)
- Guard reconnect POST callback against stale connectionId (YELLOW: double-flush on rapid reconnect)
- Use getOwnerConnection() helper instead of .split(':')[0] (BLUE: consistency)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@dimakis

dimakis commented Jul 25, 2026

Copy link
Copy Markdown
Owner Author

Centaur Review

Found 8 issue(s) (5 warning).

packages/client/src/sse-connection.ts

The fire-and-forget reconnect simplification removes significant complexity but introduces a send-ordering race (sends can bypass the reconnect POST) and a transport/clientId mismatch window — both depend on client-side seq dedup for correctness, which works but should be tested and documented.

  • 🟡 bugs (L228): Setting _connected = true before the reconnect POST completes means send() calls between welcome and POST completion bypass the pending queue and hit the server directly. This can cause user messages to arrive before handleReconnect runs (cursor reset, replay), violating the ordering invariant documented in lines 239-240. The old code deferred _connected until POST success specifically to prevent this. The comment on line 239 says "Flush pending sends AFTER reconnect so the server processes handleReconnect (cursor reset, replay) before user messages" — but only queued sends are gated behind the POST; new send() calls during the POST's in-flight window go straight through. [fixable]
  • 🟡 regressions (L248): flushPendingSends() is called in both the .then() and .catch() callbacks of the reconnect POST. When the POST fails (server never ran handleReconnect — no cursor reset, no replay), flushing sends means user messages are delivered without the server having set up the session watch/cursor. Previously, failed POSTs left sends queued and scheduled a delayed reconnect. The comment says "handleSendV2 handles ownership independently" but the concern is cursor state and replay, not ownership. [fixable]

packages/client/src/__tests__/sse-connection.test.ts

The fire-and-forget reconnect simplification removes significant complexity but introduces a send-ordering race (sends can bypass the reconnect POST) and a transport/clientId mismatch window — both depend on client-side seq dedup for correctness, which works but should be tested and documented.

  • 🟡 missing_tests (L366): The test 'flushes pending sends after reconnect POST completes' only verifies sends queued before the welcome are flushed after the POST. There is no test for a send() called after welcome (when _connected = true) but while the reconnect POST is still in-flight — this is the race window from the bug above. If the design intentionally allows this (relying on server-side seq dedup), a test should document that intent. [fixable]

packages/harness/src/connection-registry.ts

The fire-and-forget reconnect simplification removes significant complexity but introduces a send-ordering race (sends can bypass the reconnect POST) and a transport/clientId mismatch window — both depend on client-side seq dedup for correctness, which works but should be tested and documented.

  • 🔵 style (L113): Stale comment: "Updates delivery cursor on success so periodic sync can retry failures" — periodic sync has been removed. Should say something like "Updates delivery cursor on success so reconnect replay covers the correct range." [fixable]

server/index.ts

The fire-and-forget reconnect simplification removes significant complexity but introduces a send-ordering race (sends can bypass the reconnect POST) and a transport/clientId mismatch window — both depend on client-side seq dedup for correctness, which works but should be tested and documented.

  • 🔵 style (L984): Stale comment: connRegistry.dispose(); // Stop periodic sync + clear state — periodic sync is removed, comment should just say // Clear state. [fixable]

packages/harness/__tests__/connection-registry.test.ts

The fire-and-forget reconnect simplification removes significant complexity but introduces a send-ordering race (sends can bypass the reconnect POST) and a transport/clientId mismatch window — both depend on client-side seq dedup for correctness, which works but should be tested and documented.

  • 🔵 style (L222): Two test comments still reference periodic sync (lines ~222 and ~281): "can't inspect directly, but periodic sync will use it" and "verified by periodic sync behavior". These should be updated since periodic sync no longer exists. [fixable]

server/ws-handler-v2.ts

The fire-and-forget reconnect simplification removes significant complexity but introduces a send-ordering race (sends can bypass the reconnect POST) and a transport/clientId mismatch window — both depend on client-side seq dedup for correctness, which works but should be tested and documented.

  • 🟡 unsafe_assumptions (L563): The comment on handleSendV2 says "No resetCursor here — handleReconnect (fire-and-forget POST) sets cursor to lastSeq when it arrives." But the reconnect POST and send POST arrive on separate event loop ticks in any order. If send arrives first and the server calls watch() (line 562) + broadcast() before the reconnect POST arrives, the cursor starts at 0 (default), so broadcasts of events the client already has will be delivered. The comment says "duplicate delivery is always harmless thanks to seq dedup" — this is true for correctness, but it's worth noting this is a bandwidth/performance trade-off, not zero-cost.
  • 🟡 bugs (L271): Reconnect reattaches detached sessions (refreshes transport) but no longer rekeys the clientId. If the reconnecting connection has a different ID than the original owner (device restart: ownerGone = true), the clientId stays as old-conn:sess-1 while the transport belongs to new-conn. Subsequent handleSendV2 will rekey on the first send, but between reattach and first send, getOwnerConnection(found.clientId) returns old-conn — any code checking ownership during this window sees a stale owner. The comment says ownership is deferred to handleSendV2, but the reattach here creates an intermediate state where transport and clientId disagree. [fixable]

- Defer _connected until reconnect POST succeeds (YELLOW: prevents
  sends from bypassing queue during in-flight window)
- Don't flush pending sends on POST failure (YELLOW: server hasn't
  set up cursor/replay, sends stay queued for next attempt)
- Add test for send queuing during reconnect POST in-flight (YELLOW)
- Fix 4 stale "periodic sync" comments (BLUE: removed in P3)
- Document cursor-at-0 bandwidth trade-off in handleSendV2 (YELLOW)
- Document transport/clientId mismatch window on reattach (YELLOW)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@dimakis dimakis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Centaur Review

Found 7 issue(s) (4 warning).

packages/client/src/sse-connection.ts

Well-structured simplification that replaces periodic sync with on-demand reconnect replay. The stale-callback guard and zombie-deferral design are sound, but the stale-callback race and zombie-reattach window deserve test coverage, and the doPost silent-resolve on null connectionId is a subtle contract mismatch.

  • 🟡 bugs (L291): doPost silently resolves (doesn't throw) when _connectionId is null. When called from the reconnect .then() chain (line 236), if _connectionId were somehow cleared between the welcome event and the fetch, the success handler would fire without the POST ever being sent — marking _connected=true and flushing sends into the void. In practice this path is unreachable (connectionId is set at line 208 before the POST), but the contract is misleading: callers assume doPost either succeeds or throws, yet this early return is a silent success. [fixable]
  • 🔵 style (L220): The session-selection logic (lines 217-227) is dense: a null-coalescing chain of filtered/mapped pending sessions falling back to a ternary for fresh sessions. Consider extracting to a private method like getReconnectSessions() for readability. [fixable]

server/ws-handler-v2.ts

Well-structured simplification that replaces periodic sync with on-demand reconnect replay. The stale-callback guard and zombie-deferral design are sound, but the stale-callback race and zombie-reattach window deserve test coverage, and the doPost silent-resolve on null connectionId is a subtle contract mismatch.

  • 🟡 unsafe_assumptions (L571): The comment explains that between watch() (line 570) and the reconnect POST arriving, the cursor starts at 0, so broadcasts may deliver duplicates. This relies entirely on client-side seq dedup. If a client implementation doesn't dedup (e.g. a future non-Zustand consumer), it will see duplicate events. The comment documents the trade-off well, but the gap is structural — consider adding a TODO for P4 to unify cursor reset.
  • 🟡 regressions (L267): handleReconnect now reattaches detached sessions without checking EventStore state. Previously, it cross-referenced storeState (ENDED/CLOSING) to detect zombies and called registry.remove() before attempting reattach. Now it reattaches any active+detached session unconditionally. If a session's EventStore state is ENDED but the SessionRegistry hasn't been cleaned up yet (race window), handleReconnect will reattach a zombie. The zombie is only caught later if the user sends a message (handleSendV2 line 586). For observe-only reconnects (no send), the zombie stays reattached until the next tick or server restart. [fixable]

packages/client/src/__tests__/sse-connection.test.ts

Well-structured simplification that replaces periodic sync with on-demand reconnect replay. The stale-callback guard and zombie-deferral design are sound, but the stale-callback race and zombie-reattach window deserve test coverage, and the doPost silent-resolve on null connectionId is a subtle contract mismatch.

  • 🟡 missing_tests: The stale-callback guard for racing welcome events is no longer tested. The old test 'ignores stale reconnect POST when a newer welcome arrives' verified that when two welcome events arrive in quick succession, only the second (current) POST's callback marks connected. The new code still has this guard (line 238/245: connectionId !== postConnectionId), but no test exercises it. This was a real-world race condition worth covering. [fixable]
  • 🔵 missing_tests: No test verifies that SSE events (es.onmessage) update seqBySession while the reconnect POST is in-flight. The old test 'dispatches SSE events to listener while reconnect POST is in-flight' was removed. The new code refreshes lastSeq from seqBySession on retry (line 219), so verifying that SSE events advance the seq map during the in-flight window is important for the retry-with-fresh-seq guarantee. [fixable]

packages/protocol/src/ws-schemas-v2.ts

Well-structured simplification that replaces periodic sync with on-demand reconnect replay. The stale-callback guard and zombie-deferral design are sound, but the stale-callback race and zombie-reattach window deserve test coverage, and the doPost silent-resolve on null connectionId is a subtle contract mismatch.

  • 🔵 style (L127): The added comment says 'WS clients still send it over WS until P4 removes the WS chat transport' — this is a phase-plan reference that will become stale. Consider phrasing it as current behavior ('WS clients may also send reconnect over WS') without the roadmap reference. [fixable]

Comment thread packages/client/src/sse-connection.ts Outdated
}

private async doPost(endpoint: string, body: Record<string, unknown>): Promise<void> {
if (!this._connectionId) return;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bugs: doPost silently resolves (doesn't throw) when _connectionId is null. When called from the reconnect .then() chain (line 236), if _connectionId were somehow cleared between the welcome event and the fetch, the success handler would fire without the POST ever being sent — marking _connected=true and flushing sends into the void. In practice this path is unreachable (connectionId is set at line 208 before the POST), but the contract is misleading: callers assume doPost either succeeds or throws, yet this early return is a silent success. [fixable]

Comment thread packages/client/src/sse-connection.ts Outdated
const pending = this._pendingReconnectSessions
?.filter((s) => this.seqBySession.has(s.sessionId))
.map((s) => ({ ...s, lastSeq: this.seqBySession.get(s.sessionId)! }));
const sessions =

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 style: The session-selection logic (lines 217-227) is dense: a null-coalescing chain of filtered/mapped pending sessions falling back to a ternary for fresh sessions. Consider extracting to a private method like getReconnectSessions() for readability. [fixable]

Comment thread server/ws-handler-v2.ts
}
applySkillPolicy(activeClientId);
ctx.connRegistry.watch(connectionId, sessionId);
// No resetCursor here — handleReconnect (fire-and-forget POST) sets

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 unsafe_assumptions: The comment explains that between watch() (line 570) and the reconnect POST arriving, the cursor starts at 0, so broadcasts may deliver duplicates. This relies entirely on client-side seq dedup. If a client implementation doesn't dedup (e.g. a future non-Zustand consumer), it will see duplicate events. The comment documents the trade-off well, but the gap is structural — consider adding a TODO for P4 to unify cursor reset.

Comment thread server/ws-handler-v2.ts
ctx.sessionRegistry.remove(found!.clientId);
}
if (found && running && !ctx.sessionRegistry.isAttached(found.clientId)) {
if (found && ctx.sessionRegistry.isActive(found.clientId)) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 regressions: handleReconnect now reattaches detached sessions without checking EventStore state. Previously, it cross-referenced storeState (ENDED/CLOSING) to detect zombies and called registry.remove() before attempting reattach. Now it reattaches any active+detached session unconditionally. If a session's EventStore state is ENDED but the SessionRegistry hasn't been cleaned up yet (race window), handleReconnect will reattach a zombie. The zombie is only caught later if the user sends a message (handleSendV2 line 586). For observe-only reconnects (no send), the zombie stays reattached until the next tick or server restart. [fixable]


// ─── Union ──────────────────────────────────────────────────────────────────

// ReconnectMessage is primarily handled via REST POST (SSE transport), but WS

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 style: The added comment says 'WS clients still send it over WS until P4 removes the WS chat transport' — this is a phase-plan reference that will become stale. Consider phrasing it as current behavior ('WS clients may also send reconnect over WS') without the roadmap reference. [fixable]

- Throw on doPost when connectionId is null (YELLOW: silent success)
- Add P4 TODO for cursor reset unification (YELLOW: dedup assumption)
- Check EventStore state before reattaching on reconnect (YELLOW: zombie)
- Extract getReconnectSessions() for readability (BLUE: dense logic)
- Remove roadmap reference from WS reconnect comment (BLUE: stale)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant