Skip to content

fix(transport): deliver first prompts reliably across reconnect - #455

Merged
dimakis merged 3 commits into
mainfrom
fix/reconnect-first-send
Sep 7, 2026
Merged

fix(transport): deliver first prompts reliably across reconnect#455
dimakis merged 3 commits into
mainfrom
fix/reconnect-first-send

Conversation

@dimakis

@dimakis dimakis commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Returning to Mitzo could strand the first prompt: the client queued it based on stale running state, ignored POST failures, and reconnect could rekey the session while the query loop retained its original key.

This change keeps runtime identity stable and makes prompt delivery independent of SSE readiness. A durable command receipt allocates the session ID before dispatch, so a lost acknowledgement can be retried without starting another session. The client retains unacknowledged prompts in a per-tab outbox, retries with the same ID, bounds hung requests, and shows delivery status. Reconnect restores tracked sessions even when a prompt was accepted before the first stream welcome.

Includes the always-send removal from #445; retains cursor replay and periodic sync rather than adopting #440 wholesale. See docs/design/prompt-delivery.md for the contract and boundaries. Server restart recovery surfaces ambiguous execution as interrupted rather than repeating possible tool side effects.

Validation:

  • 3,287 tests passed across 215 files (full Vitest suite; 4 workers; signing disabled for temporary Git fixtures).
  • Server/client/frontend type checks, lint (no errors), and production frontend build passed.
  • Integration test uses the real HTTP router, EventStore, outbox, registry, and query loop: suspend, reconnect, one prompt, lost HTTP acknowledgement, automatic retry, one SDK input, and response on the new stream.
  • Live browser/physical iPhone acceptance remains pending; the host is locked.

@dimakis

dimakis commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

Centaur Review

Found 10 issue(s) (2 critical) (5 warning).

packages/client/src/sse-connection.ts

Solid architectural improvement (durable outbox replaces fragile timer-based queue), but the outbox lacks cleanup on session switch, doReconnectPost is called without a re-entrancy guard from the notify callback, and sendError can get stuck after switching sessions.

  • 🔴 bugs (L69): Outbox notify callback calls doReconnectPost on every _send_accepted while already connected, with no re-entrancy or in-flight guard. If two sends are accepted in quick succession, two concurrent reconnect POSTs fire, both can succeed, and both emit _open + flushPendingSends(). The doReconnectPost staleness guard (line 334: this.es !== welcomeEs) does not catch this because the ES instance hasn't changed — only concurrent calls against the same welcome would need a busy flag. [fixable]
  • 🟡 bugs (L156): clearPendingSends() increments sendScope but does not drain or stop the SendOutbox. Entries already enqueued in the outbox continue to pump and deliver to the server after switchSession/newSession. The scope field on existing entries preserves the old scope value, but the outbox has no mechanism to discard entries from a previous scope. Old messages (possibly with a null sessionId that will allocate a new session) are delivered silently after the user has moved on. [fixable]
  • 🔵 regressions (L436): Visibility change now unconditionally calls checkAndReconnect(true), which tears down and rebuilds the EventSource even when the SSE stream is perfectly healthy. On desktop browsers with frequent tab switching, this creates unnecessary connection churn (close → new TCP → welcome → reconnect POST → replay). The previous behavior only reconnected if not already connected. Consider gating the forced reconnect on mobile/Capacitor detection or adding a minimum interval between forced reconnects. [fixable]

packages/client/src/store.ts

Solid architectural improvement (durable outbox replaces fragile timer-based queue), but the outbox lacks cleanup on session switch, doReconnectPost is called without a re-entrancy guard from the notify callback, and sendError can get stuck after switching sessions.

  • 🔴 bugs (L253): switchSession and newSession reset messages to INITIAL_MESSAGES_STATE but never set sendError: null. If a retrying outbox message set sendError to a string (e.g. 'Reconnecting…'), then the user switches sessions, the subsequent _send_accepted event's visible check (line 673-675) finds no matching messageId in the now-empty messages array, so sendError is never cleared. The stale error banner stays visible in the new session until the next sendMessage call. [fixable]

packages/client/src/send-outbox.ts

Solid architectural improvement (durable outbox replaces fragile timer-based queue), but the outbox lacks cleanup on session switch, doReconnectPost is called without a re-entrancy guard from the notify callback, and sendError can get stuck after switching sessions.

  • 🟡 bugs (L112): Acknowledgment validation treats a missing/undefined sessionId as invalid (typeof undefined !== 'string' && undefined !== null is true), throwing into the retry path. If the server returns {accepted: true, clientMsgId: '...'} without a sessionId field, the outbox retries forever with exponential backoff capped at 10s, blocking all subsequent entries (head-of-line). The check should treat undefined the same as null: receipt.sessionId != null && typeof receipt.sessionId !== 'string'. [fixable]
  • 🟡 unsafe_assumptions (L93): If the server returns a 2xx response with a non-JSON body (e.g. HTML from a load balancer or reverse proxy), response.json() throws a SyntaxError. This falls into the catch/retry path, retrying indefinitely since the server will keep returning the same non-JSON response. Combined with the head-of-line blocking design, this wedges the entire outbox. Consider catching JSON parse errors separately and treating them as definitive failures. [fixable]
  • 🟡 unsafe_assumptions (L102): If stop() is called while pump() is between the successful await (line 82) and the !this.active check (line 102), the method returns early without dequeuing the entry or calling persist(). The message was already accepted by the server, but it remains in entries and in storage. On the next start(), the message is re-sent — a duplicate. The server deduplicates via clientMsgId, so this is not data-corrupting, but the client never receives the _send_accepted notification, leaving sendError stuck. [fixable]

server/ws-handler-v2.ts

Solid architectural improvement (durable outbox replaces fragile timer-based queue), but the outbox lacks cleanup on session switch, doReconnectPost is called without a re-entrancy guard from the notify callback, and sendError can get stuck after switching sessions.

  • 🟡 bugs (L766): startChat in the handleInterruptV2 resume path (line 766) is not .catch()-ed. Both resume and create paths in handleSendV2 chain .catch() (lines 613, 642), but the equivalent path in handleInterruptV2 does not. If the SDK query fails, this produces an unhandled promise rejection that may crash the process and gives no error feedback to the client. [fixable]

packages/client/src/__tests__/send-outbox.test.ts

Solid architectural improvement (durable outbox replaces fragile timer-based queue), but the outbox lacks cleanup on session switch, doReconnectPost is called without a re-entrancy guard from the notify callback, and sendError can get stuck after switching sessions.

  • 🔵 missing_tests: No test covers the head-of-line blocking scenario when the server returns a non-JSON 2xx response, or when the receipt has sessionId: undefined (vs. null). These edge cases cause infinite retry loops in production. A test for clearPendingSends not draining outbox entries after session switch is also missing.

server/send-command.ts

Solid architectural improvement (durable outbox replaces fragile timer-based queue), but the outbox lacks cleanup on session switch, doReconnectPost is called without a re-entrancy guard from the notify callback, and sendError can get stuck after switching sessions.

  • 🔵 style (L30): Lines 30-31 re-read getSendCommand immediately after synchronous dispatch to check for errors set by the transport's send handler. This is a roundabout way to detect synchronous errors that were already thrown — the only path that sets error without throwing is the ad-hoc transport in chat-rest-handler.ts. Consider having dispatch throw directly rather than using the database as a side-channel for synchronous error signaling. [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 5 issue(s) (2 warning).

server/chat-rest-handler.ts

Well-designed durable prompt delivery system with solid test coverage. No critical bugs found; the main concerns are around edge-case test gaps in the outbox and minor UX polish for transient delivery status.

  • 🟡 unsafe_assumptions (L105): When no SSE stream exists (no X-Connection-ID header), the delegate SseTransport has isOpen()=false. For a brand-new session that has no watchers yet, events emitted synchronously during handleSendV2 (before the outbox-triggered replay adds a watcher) are sent to the delegate, which silently drops them. Startup errors from the async startChat() .catch() handler would be lost until the client's SSE stream connects and replays. The failSendCommand path in transport.send covers error events, but non-error early events (like session_state_changed) may be missed.

packages/client/src/send-outbox.ts

Well-designed durable prompt delivery system with solid test coverage. No critical bugs found; the main concerns are around edge-case test gaps in the outbox and minor UX polish for transient delivery status.

  • 🟡 bugs (L108): When stop() is called during an in-flight fetch and the fetch completes successfully before the abort takes effect, if (!this.active) return discards the successful response without shifting the entry or notifying _send_accepted. The entry remains in the queue and will be re-POSTed on next start(). The server deduplicates correctly, but the user sees no acceptance notification until the retry succeeds. The test 'acknowledges a stopped in-flight prompt on restart' covers the lifecycle but does not assert that the first successful response is captured. [fixable]

packages/client/src/__tests__/send-outbox.test.ts

Well-designed durable prompt delivery system with solid test coverage. No critical bugs found; the main concerns are around edge-case test gaps in the outbox and minor UX polish for transient delivery status.

  • 🔵 missing_tests: Several outbox paths lack test coverage: (1) 429/5xx server responses triggering retry, (2) the 100-entry queue limit returning false from enqueue, (3) receipt validation failure (mismatched clientMsgId or non-boolean accepted) entering the retry path, (4) _send_pending notification details (retrying flag). These are edge cases but they exercise distinct code paths in pump(). [fixable]

packages/client/src/sse-connection.ts

Well-designed durable prompt delivery system with solid test coverage. No critical bugs found; the main concerns are around edge-case test gaps in the outbox and minor UX polish for transient delivery status.

  • 🔵 style (L473): checkAndReconnect(true) on every desktop visibilitychange to 'visible' tears down and rebuilds a potentially healthy EventSource. The WS transport uses force=false for browser events, reserving force=true for Capacitor hooks. The design doc explicitly defends this choice (EventSource readyState doesn't prove the connection survived suspension), so this is intentional, but it adds unnecessary latency on desktop tab switches where the connection is usually fine. Consider checking es.readyState before forcing, or using a short heartbeat to detect stale connections. [fixable]

packages/client/src/store.ts

Well-designed durable prompt delivery system with solid test coverage. No critical bugs found; the main concerns are around edge-case test gaps in the outbox and minor UX polish for transient delivery status.

  • 🔵 style (L680): The sendError field is set to 'Sending…' for the initial _send_pending notification, which is a transient status indicator, not an error. Using the same field for both status and errors means the UI cannot distinguish them visually. Consider a separate sendStatus field or a structured {status, message} object if you want to style these differently in the future. [fixable]

handleSendV2(connectionId, transport, msg, ctx);
res.status(202).json({ ok: true });
const receipt = acceptSendCommand(ctx.eventStore, msg, (command, sessionId) => {
const delegate = new SseTransport(connectionId, sseRegistry);

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: When no SSE stream exists (no X-Connection-ID header), the delegate SseTransport has isOpen()=false. For a brand-new session that has no watchers yet, events emitted synchronously during handleSendV2 (before the outbox-triggered replay adds a watcher) are sent to the delegate, which silently drops them. Startup errors from the async startChat() .catch() handler would be lost until the client's SSE stream connects and replays. The failSendCommand path in transport.send covers error events, but non-error early events (like session_state_changed) may be missed.

Comment thread packages/client/src/send-outbox.ts Outdated
timeout = setTimeout(() => abort.abort(), this.config.timeoutMs ?? 15000);
}),
]);
if (!this.active) 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: When stop() is called during an in-flight fetch and the fetch completes successfully before the abort takes effect, if (!this.active) return discards the successful response without shifting the entry or notifying _send_accepted. The entry remains in the queue and will be re-POSTed on next start(). The server deduplicates correctly, but the user sees no acceptance notification until the retry succeeds. The test 'acknowledges a stopped in-flight prompt on restart' covers the lifecycle but does not assert that the first successful response is captured. [fixable]

Comment thread packages/client/src/sse-connection.ts Outdated
this.boundOnVisibility = () => {
if (document.visibilityState === 'visible') {
this.checkAndReconnect();
this.checkAndReconnect(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.

🔵 style: checkAndReconnect(true) on every desktop visibilitychange to 'visible' tears down and rebuilds a potentially healthy EventSource. The WS transport uses force=false for browser events, reserving force=true for Capacitor hooks. The design doc explicitly defends this choice (EventSource readyState doesn't prove the connection survived suspension), so this is intentional, but it adds unnecessary latency on desktop tab switches where the connection is usually fine. Consider checking es.readyState before forcing, or using a short heartbeat to detect stale connections. [fixable]

Comment thread packages/client/src/store.ts Outdated
.messages.messages.some((m) => m.messageId === msg.clientMsgId);
if (visible) {
store.setState({
sendError:

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 sendError field is set to 'Sending…' for the initial _send_pending notification, which is a transient status indicator, not an error. Using the same field for both status and errors means the UI cannot distinguish them visually. Consider a separate sendStatus field or a structured {status, message} object if you want to style these differently in the future. [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

LGTM — no issues found.

@dimakis
dimakis merged commit 8c4f1ac into main Sep 7, 2026
1 check passed
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