chat: durable Postgres event queue + cursor replay on reconnect (Phase 2+3)

Completes the turn/session decoupling so nothing is lost across disconnects.

Phase 2 (durability):
- New chat_session_events table (global monotonic id = cursor) + queries
  appendChatEvent / getChatEventsSince / pruneChatEventsOlderThan.
- Every durable outbound ServerMessage now goes through emitToSession: appended
  to the queue (even while the client is disconnected) and delivered live with
  its seq. Streaming deltas stay ephemeral (live-only, never persisted).

Phase 3 (resilient transport):
- New 'resume-cursor' client message → handleResumeCursor re-binds the socket to
  the (still-live) session (cancels idle-GC via attachWs) and replays every event
  since the client's cursor.
- useChatWebSocket already auto-reconnects; added an onOpen hook. useChat tracks
  the max seq and, on every (re)connect with an established session, sends
  resume-cursor — so a dropped connection self-heals with no manual navigate
  away/back, and background task notifications that landed while offline replay.

Verified end-to-end: disconnect after a turn's result but before a background
task finishes, reconnect with the cursor → the missed task:notification is
replayed from Postgres, no duplicates.

Note: the DB is managed via drizzle push/direct DDL (no __drizzle_migrations
table), so 0001 was applied directly; the generated migration is committed for
the record.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 00:12:46 +00:00
co-authored by Claude Opus 4.8
parent 449f28b1e5
commit 6b3eb247a3
11 changed files with 1792 additions and 19 deletions
+7 -1
View File
@@ -3,9 +3,12 @@ import { useState, useEffect, useRef } from 'react';
type UseChatWebSocketParams = {
url: string;
onMessage: (data: unknown) => void;
// Called on every (re)connect once the socket is OPEN — used to send a resume-cursor so a reconnect
// re-binds to the session and replays missed events (no manual navigate-away/back).
onOpen?: () => void;
};
export const useChatWebSocket = ({ url, onMessage }: UseChatWebSocketParams) => {
export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketParams) => {
const [isConnected, setIsConnected] = useState(false);
const socketRef = useRef<WebSocket | null>(null);
@@ -14,6 +17,8 @@ export const useChatWebSocket = ({ url, onMessage }: UseChatWebSocketParams) =>
const isCleaningUpRef = useRef(false);
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage;
const onOpenRef = useRef(onOpen);
onOpenRef.current = onOpen;
const connect = () => {
if (isCleaningUpRef.current) return;
@@ -26,6 +31,7 @@ export const useChatWebSocket = ({ url, onMessage }: UseChatWebSocketParams) =>
if (socketRef.current !== socket) return;
setIsConnected(true);
retryRef.current = 0;
onOpenRef.current?.();
});
socket.addEventListener('message', (ev) => {
+20 -2
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useChatWebSocket } from 'hooks/useChatWebSocket';
import { useClient } from 'hooks/useClient';
import { useSettings } from 'state/useSettings';
@@ -48,6 +48,12 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
const [cwd, setCwd] = useState<string | null>(null);
const [thinkingLevel, setThinkingLevel] = useState<string | null>(null);
// Resilient-transport cursor: the max durable-event `seq` seen. On (re)connect we send it as a
// resume-cursor so the server replays anything we missed (e.g. a background task:notification that
// landed while briefly disconnected). sendRef breaks the hook ↔ onOpen circular dependency.
const cursorRef = useRef(0);
const sendRef = useRef<(d: Record<string, unknown>) => void>(() => {});
// Track if session has started (first message sent)
const [hasStarted, setHasStarted] = useState(!!preloadedMessages?.length);
@@ -101,6 +107,10 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
function handleMessage(data: unknown) {
const msg = data as ServerMessage;
// Advance the resume cursor for any durable (seq-carrying) event.
const seq = (data as { seq?: number }).seq;
if (typeof seq === 'number' && seq > cursorRef.current) cursorRef.current = seq;
switch (msg.type) {
case 'session:init':
sessionIdRef.current = msg.sessionId;
@@ -223,7 +233,15 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
}
}
const { isConnected, send } = useChatWebSocket({ url: wsUrl, onMessage: handleMessage });
// On every (re)connect, if a session is already established, re-bind + replay via resume-cursor.
// The first connect (no session yet) no-ops; the first turn establishes the session via session:init.
const onOpen = useCallback(() => {
const sid = sessionIdRef.current;
if (sid) sendRef.current({ type: 'resume-cursor', sessionId: sid, cursor: cursorRef.current });
}, []);
const { isConnected, send } = useChatWebSocket({ url: wsUrl, onMessage: handleMessage, onOpen });
sendRef.current = send;
// Clean up RAF on unmount
useEffect(() => {