WIP cleanup: split branch stash into 8 focused commits #2
@@ -16,6 +16,33 @@ import {
|
|||||||
type UiMessage,
|
type UiMessage,
|
||||||
} from "./transcript";
|
} from "./transcript";
|
||||||
|
|
||||||
|
// Auto-reconnect budget for transport failures (Cloudflare/Traefik culling an
|
||||||
|
// idle SSE stream, transient network flap). The reducer dedupes replayed
|
||||||
|
// events by seq, so re-attaching with resumeFrom is safe even if the server
|
||||||
|
// replays events we already saw.
|
||||||
|
const RECONNECT_MAX_ATTEMPTS = 5;
|
||||||
|
const RECONNECT_BASE_MS = 500;
|
||||||
|
const RECONNECT_MAX_MS = 8000;
|
||||||
|
|
||||||
|
type StreamOutcome = "clean" | "aborted" | "error";
|
||||||
|
|
||||||
|
// Sleep that resolves early if the abort signal fires. Returns true if the
|
||||||
|
// wait was cut short by an abort so callers can bail out of retry loops.
|
||||||
|
function sleepUnlessAborted(ms: number, signal: AbortSignal): Promise<boolean> {
|
||||||
|
if (signal.aborted) return Promise.resolve(true);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
resolve(false);
|
||||||
|
}, ms);
|
||||||
|
const onAbort = () => {
|
||||||
|
clearTimeout(t);
|
||||||
|
resolve(true);
|
||||||
|
};
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChatHandle {
|
export interface ChatHandle {
|
||||||
state: TranscriptState;
|
state: TranscriptState;
|
||||||
send: (text: string) => Promise<void>;
|
send: (text: string) => Promise<void>;
|
||||||
@@ -43,14 +70,14 @@ export function useChat(
|
|||||||
const lastSeqRef = useRef(0);
|
const lastSeqRef = useRef(0);
|
||||||
lastSeqRef.current = state.lastSeq;
|
lastSeqRef.current = state.lastSeq;
|
||||||
|
|
||||||
const readStream = useCallback(
|
// One pass through the SSE stream. Never mutates abortRef — the retry loop
|
||||||
|
// in readStream owns lifecycle so it can share a controller across attempts.
|
||||||
|
const openStream = useCallback(
|
||||||
async (
|
async (
|
||||||
body: Record<string, unknown>,
|
body: Record<string, unknown>,
|
||||||
|
controller: AbortController,
|
||||||
emit: (action: TranscriptAction) => void,
|
emit: (action: TranscriptAction) => void,
|
||||||
) => {
|
): Promise<StreamOutcome> => {
|
||||||
abortRef.current?.abort();
|
|
||||||
const controller = new AbortController();
|
|
||||||
abortRef.current = controller;
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/gateway?clawId=${encodeURIComponent(clawId)}`,
|
`/api/gateway?clawId=${encodeURIComponent(clawId)}`,
|
||||||
@@ -61,12 +88,17 @@ export function useChat(
|
|||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!response.ok || !response.body) {
|
// 4xx from the gateway is definitive (auth, not-found, bad request).
|
||||||
|
// Retrying won't help — surface it to the reducer immediately.
|
||||||
|
if (response.status >= 400 && response.status < 500) {
|
||||||
emit({
|
emit({
|
||||||
kind: "transport_error",
|
kind: "transport_error",
|
||||||
message: `gateway returned ${response.status}`,
|
message: `gateway returned ${response.status}`,
|
||||||
});
|
});
|
||||||
return;
|
return "clean";
|
||||||
|
}
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
return "error";
|
||||||
}
|
}
|
||||||
const parser = createSseParser();
|
const parser = createSseParser();
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
@@ -80,18 +112,55 @@ export function useChat(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (done) {
|
if (done) {
|
||||||
break;
|
return "clean";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
if (!controller.signal.aborted) {
|
return controller.signal.aborted ? "aborted" : "error";
|
||||||
emit({ kind: "transport_error", message: String(error) });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[clawId, sessionKey],
|
[clawId, sessionKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const readStream = useCallback(
|
||||||
|
async (
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
emit: (action: TranscriptAction) => void,
|
||||||
|
) => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
let attempt = 0;
|
||||||
|
let currentBody = body;
|
||||||
|
for (;;) {
|
||||||
|
const outcome = await openStream(currentBody, controller, emit);
|
||||||
|
if (outcome !== "error") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
attempt += 1;
|
||||||
|
if (attempt > RECONNECT_MAX_ATTEMPTS) {
|
||||||
|
emit({
|
||||||
|
kind: "transport_error",
|
||||||
|
message: `stream failed after ${RECONNECT_MAX_ATTEMPTS} reconnect attempts`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const delay = Math.min(
|
||||||
|
RECONNECT_MAX_MS,
|
||||||
|
RECONNECT_BASE_MS * 2 ** (attempt - 1),
|
||||||
|
);
|
||||||
|
const aborted = await sleepUnlessAborted(delay, controller.signal);
|
||||||
|
if (aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Every retry resumes from the last event we saw — the reducer's
|
||||||
|
// seq dedup makes overlap harmless.
|
||||||
|
currentBody = { resumeFrom: lastSeqRef.current };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[openStream],
|
||||||
|
);
|
||||||
|
|
||||||
const send = useCallback(
|
const send = useCallback(
|
||||||
async (text: string) => {
|
async (text: string) => {
|
||||||
dispatch({ kind: "send", text });
|
dispatch({ kind: "send", text });
|
||||||
|
|||||||
Reference in New Issue
Block a user