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:
@@ -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) => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user