WIP cleanup: split branch stash into 8 focused commits #2

Merged
osobh merged 8 commits from chore/wip-followups into main 2026-07-06 01:52:14 +00:00
Showing only changes of commit 1e9e930425 - Show all commits
+81 -12
View File
@@ -16,6 +16,33 @@ import {
type UiMessage,
} 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 {
state: TranscriptState;
send: (text: string) => Promise<void>;
@@ -43,14 +70,14 @@ export function useChat(
const lastSeqRef = useRef(0);
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 (
body: Record<string, unknown>,
controller: AbortController,
emit: (action: TranscriptAction) => void,
) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
): Promise<StreamOutcome> => {
try {
const response = await fetch(
`/api/gateway?clawId=${encodeURIComponent(clawId)}`,
@@ -61,12 +88,17 @@ export function useChat(
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({
kind: "transport_error",
message: `gateway returned ${response.status}`,
});
return;
return "clean";
}
if (!response.ok || !response.body) {
return "error";
}
const parser = createSseParser();
const reader = response.body.getReader();
@@ -80,18 +112,55 @@ export function useChat(
});
}
if (done) {
break;
return "clean";
}
}
} catch (error) {
if (!controller.signal.aborted) {
emit({ kind: "transport_error", message: String(error) });
}
} catch (_error) {
return controller.signal.aborted ? "aborted" : "error";
}
},
[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(
async (text: string) => {
dispatch({ kind: "send", text });