Skip to content

Commit 34b7a2d

Browse files
fix(frontend): preserve newer reconnect pointers during gap recovery (#6662)
* fix(frontend): preserve newer reconnect pointers during gap recovery * test(frontend): cover same-run replay-gap pointer restoration --------- Co-authored-by: TYY-2005 <334029581+TYY-2005@users.noreply.github.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
1 parent 04a45db commit 34b7a2d

4 files changed

Lines changed: 123 additions & 1 deletion

File tree

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,8 @@ DeerFlow still uses `Forwarded` / `X-Forwarded-*` headers to recover the browser
537537
> [!IMPORTANT]
538538
> The Gateway still owns active run tasks in process, so production defaults to a single Gateway worker (`GATEWAY_WORKERS=1`). Multi-worker deployments require Postgres, the Redis stream bridge (`stream_bridge.type: redis`), `run_ownership.heartbeat_enabled: true`, and `run_events.backend: db`; process-local memory/JSONL event stores cannot enforce singleton delivery receipts across workers. Kubernetes replicas run one worker per Pod, which the worker count cannot see: declare them with `deployment.multi_instance: true` (or `DEER_FLOW_MULTI_INSTANCE=1`, which deploy tooling such as a Helm chart can set from its replica count) so the startup gate enforces the same prerequisites instead of staying inert. Declared instances that store credentials (`channel_connections`) must also share one at-rest `DEER_FLOW_CREDENTIALS_KEY` (the Helm chart and `make up` generate it; back it up, see [backend/docs/CONFIGURATION.md](backend/docs/CONFIGURATION.md#credentials-encryption-key-deer_flow_credentials_key)). The bridge shares SSE delivery and bounded `Last-Event-ID` replay across workers. When a valid reconnect cursor has been trimmed, or a subscriber that already established an empty-stream wait falls behind before its first delivery, Memory and Redis emit a machine-readable SSE `gap` event instead of silently returning a partial replay; the Web UI reloads durable thread/event state and resumes from the retained tail. Lease reconciliation marks runs from dead workers as errors, persists their delivery receipts, publishes the terminal stream marker, schedules retained-stream cleanup, and updates the affected thread status. SSE, `/wait`, and internal stream consumers use `stream_bridge.heartbeat_interval_seconds` (default `15`) for idle liveness checks; changing it requires a Gateway restart. Malformed Redis reconnect IDs live-tail new events instead of replaying the retained buffer, and the rolling retained-buffer TTL (`stream_ttl_seconds`) remains a cleanup safety net rather than a run timeout. Failed-login counters and lockouts for `POST /api/v1/auth/login/local` (`auth.local.max_login_attempts` / `lockout_seconds`) are kept in the shared `login_throttle` table whenever the application database is SQLite or Postgres (`auth.local.throttle_storage: auto`, the default), so every replica enforces one lockout per client IP; `memory` keeps the historical per-process counter, which under N replicas hands an attacker N × `max_login_attempts` guesses and logs a startup warning. IM chat-to-thread bindings live in the shared `channel_thread_bindings` table whenever the application database is SQLite or Postgres (an existing `channels/store.json` is imported once at startup and renamed `store.json.migrated`); the remaining IM channel state (each instance's platform connections, follow-up buffers) still needs its own multi-worker coordination. To try this topology on one machine, `make dev-multi` starts two Gateways on shared Postgres, Redis and `DEER_FLOW_HOME`, and `make dev-multi-check` runs the cross-instance checks; see the local two-Gateway harness in [backend/docs/CONFIGURATION.md](backend/docs/CONFIGURATION.md#local-two-gateway-harness).
539539
>
540+
> During SSE replay-gap recovery, the Web UI preserves a reconnect pointer claimed by another run while durable state is loading. An older recovery cannot overwrite that pointer when it rejoins its own stream. Even a stale foreign pointer is preserved, so the next reload may first check that run before active-run recovery rejoins, adding a reconnect cycle.
541+
>
540542
> In single-process JSONL deployments, cancelling an admitted event-store mutation waits for its background file I/O, rollback, and bookkeeping to settle before releasing the thread write lock. This prevents an older cancelled write from recreating deleted records or rolling back a later successful write. Cancellation can therefore wait on slow storage; it does not stop an in-flight filesystem operation. Callers still waiting to acquire the lock can cancel without starting a mutation. A batch spanning multiple threads drains its current thread group before propagating cancellation; subsequent thread groups do not start.
541543
>
542544
> After a run publishes its terminal stream marker, its process-local `RunRecord` remains available for the existing five-minute grace period before cleanup; durable run history remains available through `RunStore`, while the stream bridge retains its delivery tail on its separate cleanup schedule.

‎frontend/src/AGENTS.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,12 @@ export on a page error or non-advancing cursor; demos keep their fixture path.
191191
- **Run stream options** are sanitized by `core/api/stream-mode.ts`: the Gateway-supported set is `values`, `messages-tuple`, `updates`, `debug`, `tasks`, `checkpoints`, and `custom`; any request containing an unsupported mode throws before HTTP instead of being partially forwarded or silently defaulting to `values`. `streamResumable` is retained by thread hooks only for SDK-side reconnect bookkeeping but stripped before the HTTP request because the Gateway does not accept that request option; actual replay uses the SSE `Last-Event-ID` cursor. The main chat client's initial and rejoined streams are forced to incremental `messages-tuple`, `updates`, and `custom` modes so SDK lazy tracking cannot add repeated full-state `values` snapshots; explicitly requested non-snapshot modes such as `debug`, `tasks`, and `checkpoints` are preserved. `values` remains supported outside this chat wrapper for explicit state inspection and by the replay-gap durable-state reload below. The backend file-tool chunk batcher is keyed to `messages-tuple`, not `values`, so omitting snapshots must not regress `write_file` / `str_replace` streaming into one SSE frame per model token. `core/threads/stream-state.ts` folds the user-visible non-message fields from `updates` with the matching DeerFlow reducer semantics, including `tool_artifacts` capture/consumption updates (same-handle replacement preserves order, retention directives trim the oldest entries, and the absolute ceiling is 1000) and rejects irrelevant frames before calling the SDK mutator, while the SDK's `messages-tuple` manager remains the sole owner of live message chunk assembly and deduplication. Keep this boundary aligned with the backend request schema; `messages` and `events` are not supported and must not be forwarded.
192192
- **Active-run recovery** in `core/threads/hooks.ts` discovers pending/running runs when a reopened chat has no tab-local reconnect pointer. Remember SDK-completed run IDs for the mounted hook, including submitted and natively reconnected runs: stale or delayed runs reads must not rejoin a completed run and interrupt follow-up suggestions. Keep recovery retries bounded and continue allowing a different active run to reconnect. The SDK's same-tab reconnect keeps its pointer on error and never retries, so a failed reconnect (not a failed submit) releases the pointer and recovery takes over with its own bounded retries.
193193
- **SSE replay gaps** are handled in `core/api/api-client.ts`, which wraps both initial and joined run streams because the upstream SDK ignores unknown event names. An id-less backend `gap` control frame clears stale reconnect metadata, emits an internal `stream_replay_gap` custom event, reloads durable thread values, and resumes after the server-provided retained tail when one exists (or rejoins without a cursor if the buffer is empty), with up to five recovery rejoins after the original stream (six total stream calls on an all-gap exhaustion path). The wrapper remains a lazy async iterable because the SDK consumes it with `for await`. `core/threads/hooks.ts` clears optimistic/transient/subtask state, invalidates durable history caches, and shows the localized recovery warning; never let a gap fall through as a normal stream finish or cancel the still-running backend run.
194+
Replay-gap recovery restores its tab-local run pointer only if the slot is empty
195+
or still belongs to that run; never overwrite another run's pointer after an
196+
asynchronous durable-state read. A stale foreign pointer may cost an extra
197+
run lookup/rejoin on reload; recovery adds no server probe. The same-named
198+
`rememberReconnectRun` in `core/threads/hooks.ts` stays unconditional:
199+
active-run rejoin makes a fresh claim that must win over stored metadata.
194200
- **Streaming Markdown rendering** is owned by `core/streamdown`: Streamdown's `animated` / `isAnimating` API handles incremental word animation, while the shared `streamdownRenderingPlugins` config registers the named code-highlighting and Mermaid plugins required by Streamdown 2.5. Keep wrappers and derived configs wired to that shared object; do not reintroduce a rehype plugin that wraps every word, because reparsing a growing block remounts old words and replays their animation.
195201
- Citation links in message and artifact Markdown must derive their `citation:` label from the full `ReactNode` children tree, since Streamdown may provide element or array children during streaming rather than a plain string.
196202
- Web-search steps accept either a provider result array or a Webz-style `{ query, returned_results, results }` envelope. Unwrap only in the web-search renderer; skip entries without string titles/URLs, preserve other tool payloads and apply the same link allowlist to both formats. UI regressions pin source titles/links and browser reloads, including malformed neighboring entries.

‎frontend/src/core/api/api-client.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,12 @@ function rememberReconnectRun(
328328
if (typeof window === "undefined" || !threadId) return;
329329

330330
try {
331-
window.sessionStorage.setItem(`lg:stream:${threadId}`, runId);
331+
const storage = window.sessionStorage;
332+
const key = `lg:stream:${threadId}`;
333+
const rememberedRunId = storage.getItem(key);
334+
// Another run may claim the pointer while durable state is loading.
335+
if (rememberedRunId !== null && rememberedRunId !== runId) return;
336+
storage.setItem(key, runId);
332337
} catch {
333338
// Ignore storage access failures so gap recovery remains usable.
334339
}

‎frontend/tests/unit/core/api/api-client.test.ts‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -899,8 +899,116 @@ test("recovers a join stream gap from durable state and resumes after the retain
899899
expect(new Headers(recoveryRequests[1]?.headers).get("Last-Event-ID")).toBe(
900900
"3-0",
901901
);
902+
expect(sessionStorage.getItem("lg:stream:thread-1")).toBe("run-1");
902903
});
903904

905+
test.each([
906+
["initial", "another run"],
907+
["join", "another run"],
908+
["initial", "the same run"],
909+
["join", "the same run"],
910+
])(
911+
"handles %s stream gap recovery when %s claims the reconnect pointer",
912+
async (kind, claimant) => {
913+
const sessionStorage = makeSessionStorage();
914+
const threadId = "thread-gap-pointer";
915+
const runId = "run-gap-pointer";
916+
const claimedRunId = claimant === "the same run" ? runId : "run-newer";
917+
const key = "lg:stream:" + threadId;
918+
sessionStorage.setItem(key, runId);
919+
const gap = {
920+
code: "stream_replay_gap",
921+
run_id: runId,
922+
requested_event_id: "1-0",
923+
earliest_available_event_id: "2-0",
924+
latest_available_event_id: "3-0",
925+
recovery: "reload_durable_state",
926+
};
927+
let startStateRead: () => void = () => undefined;
928+
let finishStateRead: (response: Response) => void = () => undefined;
929+
const stateReadStarted = new Promise<void>((resolve) => {
930+
startStateRead = resolve;
931+
});
932+
const stateResponse = new Promise<Response>((resolve) => {
933+
finishStateRead = resolve;
934+
});
935+
const recoveryHeaders: Headers[] = [];
936+
const fetchFn = rs.fn(async (url: string | URL, init?: RequestInit) => {
937+
const path = String(url);
938+
if (path.endsWith("/runs/" + runId)) {
939+
return new Response(JSON.stringify({ status: "running" }));
940+
}
941+
if (path.endsWith("/threads/" + threadId + "/state")) {
942+
startStateRead();
943+
return stateResponse;
944+
}
945+
if (
946+
path.endsWith("/runs/stream") ||
947+
(path.includes("/runs/" + runId + "/stream") &&
948+
!new Headers(init?.headers).has("Last-Event-ID"))
949+
) {
950+
return makeSSEResponse(
951+
"event: gap\ndata: " + JSON.stringify(gap) + "\n\n",
952+
{
953+
"Content-Location": "/threads/" + threadId + "/runs/" + runId,
954+
},
955+
);
956+
}
957+
if (path.includes("/runs/" + runId + "/stream")) {
958+
recoveryHeaders.push(new Headers(init?.headers));
959+
return makeSSEResponse("event: end\ndata: null\n\n");
960+
}
961+
throw new Error("Unexpected request: " + path);
962+
});
963+
rs.stubGlobal("window", {
964+
location: { origin: "http://localhost:2026" },
965+
sessionStorage,
966+
});
967+
rs.stubGlobal("fetch", fetchFn);
968+
969+
const client = getAPIClient(true);
970+
const stream =
971+
kind === "initial"
972+
? client.runs.stream(threadId, "lead_agent", {})
973+
: client.runs.joinStream(threadId, runId);
974+
await expect(stream.next()).resolves.toEqual({
975+
done: false,
976+
value: { event: "custom", data: { type: "stream_replay_gap", ...gap } },
977+
});
978+
expect(sessionStorage.getItem(key)).toBeNull();
979+
980+
const snapshot = stream.next();
981+
await stateReadStarted;
982+
sessionStorage.setItem(key, claimedRunId);
983+
sessionStorage.setItem.mockClear();
984+
sessionStorage.removeItem.mockClear();
985+
finishStateRead(new Response(JSON.stringify({ values: { messages: [] } })));
986+
987+
await expect(snapshot).resolves.toEqual({
988+
done: false,
989+
value: { event: "values", data: { messages: [] } },
990+
});
991+
await expect(stream.next()).resolves.toEqual({
992+
done: false,
993+
value: { event: "end", data: null },
994+
});
995+
expect(recoveryHeaders).toHaveLength(1);
996+
expect(recoveryHeaders[0]?.get("Last-Event-ID")).toBe("3-0");
997+
expect(sessionStorage.getItem(key)).toBe(claimedRunId);
998+
if (claimedRunId === runId) {
999+
expect(sessionStorage.setItem).toHaveBeenCalledTimes(1);
1000+
expect(sessionStorage.setItem).toHaveBeenCalledWith(key, runId);
1001+
} else {
1002+
expect(sessionStorage.setItem).not.toHaveBeenCalled();
1003+
}
1004+
expect(sessionStorage.removeItem).not.toHaveBeenCalled();
1005+
await expect(stream.next()).resolves.toEqual({
1006+
done: true,
1007+
value: undefined,
1008+
});
1009+
},
1010+
);
1011+
9041012
test("recovers a gap emitted by the initial run stream", async () => {
9051013
const sessionStorage = makeSessionStorage();
9061014
const gap = {
@@ -962,6 +1070,7 @@ test("recovers a gap emitted by the initial run stream", async () => {
9621070
{ event: "end", data: null },
9631071
]);
9641072
expect(recoveryHeaders[0]?.get("Last-Event-ID")).toBe("5-0");
1073+
expect(sessionStorage.getItem("lg:stream:thread-2")).toBe("run-2");
9651074
});
9661075

9671076
test("recovers from stream replay gap with null retained bounds", async () => {

0 commit comments

Comments
 (0)