// THE single owner of live session event ordering AND of runtime-derived // session status. This module — and only this module — opens runtime SSE // subscriptions, tracks per-session event cursors, reconnects, flushes the // text-delta coalescer, reduces runtime events into session state, and runs // the runtime-list poll that arbitrates running/idle. React integrates // through a thin binding (use-workspace-runtime-sync.ts); nothing else may // subscribe to runtime events, gate event seqs, or settle a session's // runtime status. (Turn-intent status — "starting", accept, abort — stays // with prompt-stream/engine; hydration status with loadAndReplay.) import { isAgentSettledEvent } from "@shared/agent/pi-events"; import { piSessionIdFromEvent } from "@/features/agent/messages"; import { listRuntimeSessions, loadRuntimeStatus, runtimeContextUsage, subscribeRuntimeEvents, type RuntimeEventPayload, type RuntimeEventSubscription, type RuntimeSessionSummary, type RuntimeStatus, } from "@/features/agent/runtime/api"; import { clearAwaitingEchoUserMessages, reduceSessionEvent, type SessionStreamContext, } from "@/features/agent/runtime/pi-event-applier"; import { acceptRuntimeSeq, adoptExternalCursor, commitRuntimeSeq, reconnectAfter, shouldSubscribeRuntimeEvents, type RuntimeCursor, } from "@/features/agent/runtime/runtime-cursor"; import { createEffectTextDeltaCoalescer } from "@/features/agent/runtime/effect-coalescer"; import { Effect, Fiber, Schedule } from "effect"; import type { Session, SessionId } from "@/features/agent/runtime/types"; import { publishRuntimeActivity } from "@/features/agent/session-index"; import { settleTurn } from "@/features/agent/runtime/session-status"; const RESUME_IDLE_RECONNECT_MS = 15_000; const RESUME_RECONNECT_DELAY_MS = 1_000; const RUNTIME_POLL_INTERVAL_MS = 5_000; const RUNTIME_POLL_IDLE_GRACE_MS = 10_000; export type SessionRuntimeBinding = { /** Single state commit boundary — one patchSession dispatch per call. */ commit: (sessionId: SessionId, patch: (session: Session) => Session) => void; /** Read the current session snapshot (never cached by the controller). */ getSession: (sessionId: SessionId) => Session | undefined; /** Read all current workspace sessions (the binding's live ref). */ getSessions: () => readonly Session[]; }; export type SessionRuntimeController = { bind(binding: SessionRuntimeBinding): void; unbind(): void; /** * Reconcile live SSE attachments against the session set: attach sessions * entering the live set, detach those leaving, recreate only when the * connection params (runtime/pi id) change. */ reconcile(sessions: readonly Session[]): void; /** * A `/turn` command was accepted. Pi's per-runtime event seq restarts ONLY * when the runtime itself restarts (piSessionId adoption on the second turn, * or a post-compaction/session swap) — `runtimeEventSeq` is the runtime's * current seq from the accept response. Rewind the gate to 0 only when that * seq sits below what we've already received (a genuine restart); otherwise * keep the cursor where it is. Rewinding on every steady-state turn would make * the next SSE reconnect re-apply the entire accumulated event log and * duplicate every prior turn — the 502-retry-storm message explosion. */ noteTurnAccepted(sessionId: SessionId, assistantId?: string, runtimeEventSeq?: number): void; /** * loadAndReplay hydrated the transcript from canonical + runtime logs up to * `committedSeq` (undefined when the runtime is idle): reattach from there * so EventSource does not replay already-rendered content. */ noteReplayHydrated(sessionId: SessionId, committedSeq: number | undefined): void; /** Apply any coalesced-but-unflushed deltas for a session right now. */ flush(sessionId: SessionId): void; /** * Reconcile every session against the runtime list right now, then restart * the steady poll. Called by the React binding when poll-relevant session * identity (membership / pi id / status) changes. */ pollNow(): void; /** Flush everything and close every SSE attachment (workspace unmount). */ closeAll(): void; /** * The connection key a session is currently addressed by on the runtime API. * Normally the session's own runtime key; after a restart adoption it is the * controller-internal override recorded by the poll's pi-session match. */ connectionKey(sessionId: SessionId): string; /** * Seed the connection-key override from a legacy persisted runtime id (a * pre-alias `rt-*` value read once from old localStorage state), so a session * that was RUNNING under that key across the upgrade reattaches to it. */ seedConnectionKey(sessionId: SessionId, runtimeKey: string): void; }; type Attachment = { key: string; close: () => void }; function resumeConnectionKey(connectionKey: string, piSessionId: string | null): string { return `${connectionKey}|${piSessionId ?? ""}`; } function patchRuntimeStatus(status: RuntimeStatus): Partial { return { ...(status.piSessionId ? { piSessionId: status.piSessionId } : {}), ...(status.modelId ? { modelId: status.modelId } : {}), ...(status.contextUsage !== undefined ? { contextUsage: status.contextUsage } : {}), }; } function sameRuntimePatch(session: Session, patch: Partial, status: string): boolean { return ( session.status === status && (patch.piSessionId === undefined || session.piSessionId === patch.piSessionId) && (patch.modelId === undefined || session.modelId === patch.modelId) && (patch.contextUsage === undefined || JSON.stringify(session.contextUsage ?? null) === JSON.stringify(patch.contextUsage ?? null)) ); } export function createSessionRuntimeController(): SessionRuntimeController { let binding: SessionRuntimeBinding | null = null; const cursors = new Map(); const streamContext: SessionStreamContext = { liveAssistantIds: new Map() }; const attachments = new Map(); let pollTimer: ReturnType | null = null; let pollEpoch = 0; const turnAcceptedAt = new Map(); // When the SSE delivered an authoritative `agent_settled` for a session. The // server's runtime list drops the just-finished runtime lazily, so for a few // seconds after the turn ends the poll can still see it as active. Without a // guard the poll's active branch re-promotes the session to "running", // fighting the SSE's idle and oscillating status (visible flicker + SSE // reopen churn). This stamp lets the active branch honor the finish grace. const turnFinishedAt = new Map(); // Ephemeral per-session connection-key overrides — reconnection plumbing, not // session state. Set when the poll's pi-session match finds this session's // runtime living under a DIFFERENT server key (a restart adoption, or a // legacy pre-alias `rt-*` key seeded across an upgrade); every runtime API // address for the session then uses the override instead of the session id. const connectionKeyOverrides = new Map(); const connectionKeyFor = (session: Session): string => connectionKeyOverrides.get(session.id) ?? session.id; // Sessions evicted from the workspace registry (closed panes, pruned // background sessions) must not leave app-lifetime entries behind in this // singleton's per-session maps. Only truly-gone ids are pruned — // idle-but-open sessions keep their cursor seed. const pruneStaleSessionEntries = (knownIds: ReadonlySet): void => { const maps: Array> = [ cursors, turnAcceptedAt, turnFinishedAt, connectionKeyOverrides, streamContext.liveAssistantIds, ]; for (const map of maps) { for (const sessionId of [...map.keys()]) { if (!knownIds.has(sessionId)) map.delete(sessionId); } } }; const commit = (sessionId: SessionId, patch: (session: Session) => Session) => { binding?.commit(sessionId, patch); }; const getSession = (sessionId: SessionId) => binding?.getSession(sessionId); // Stamp the committed cursor onto the session in the SAME commit that // applies the event's effects — content and cursor land atomically, so a // teardown can never persist a cursor ahead of rendered content. const stampSeq = (session: Session, seq: number | undefined): Session => { if (typeof seq !== "number") return session; if (typeof session.lastEventSeq === "number" && seq <= session.lastEventSeq) return session; return { ...session, lastEventSeq: seq }; }; const applyEvent = ( sessionId: SessionId, event: Record, seq?: number, decorate: (session: Session) => Session = (session) => session, ) => { commit(sessionId, (session) => decorate(stampSeq(reduceSessionEvent(session, streamContext, event), seq)), ); cursors.set( sessionId, commitRuntimeSeq(cursors.get(sessionId) ?? adoptExternalCursor(undefined), seq), ); }; // Text-delta coalescer is now an Effect program (effect-coalescer.ts): a // per-session pending snapshot drained on the animation-frame clock. The // imperative facade is unchanged so the controller's contract holds. const coalescer = createEffectTextDeltaCoalescer({ applyPiEvent: applyEvent, }); const enqueueEvent = ( sessionId: SessionId, event: Record, seq: number | undefined, ) => { if (coalescer.enqueuePiEvent(sessionId, event, { seq })) return; // Non-delta events flush any pending merge first so ordering is preserved. coalescer.flushNow(sessionId); applyEvent(sessionId, event, seq); }; // Receive gate: advance receivedSeq immediately (dedup + reconnect cursor); // committedSeq — and the persisted lastEventSeq — only advance when the // event's effects are actually committed (see applyEvent). // A turn can settle four ways: the authoritative `agent_settled` event, an // idle status frame, the liveness reconcile, and the runtime-list poll. The // live-target pin must drop on ALL of them — a pin that outlives its turn // retargets the NEXT turn's blocks onto a settled bubble (or onto a dead id, // where they are discarded silently while the seq cursor advances). That is // what erased everything after a follow-up message when the stream dropped. const dropLiveTarget = (sessionId: SessionId) => { streamContext.liveAssistantIds.delete(sessionId); }; const acceptSeq = (sessionId: SessionId, seq?: number): boolean => { const current = cursors.get(sessionId) ?? adoptExternalCursor(undefined); const decision = acceptRuntimeSeq(current, seq); if (decision.accept) cursors.set(sessionId, decision.cursor); return decision.accept; }; const adoptCursor = (sessionId: SessionId, committedSeq: number | undefined) => { coalescer.discard(sessionId); cursors.set(sessionId, adoptExternalCursor(committedSeq)); commit(sessionId, (session) => session.lastEventSeq === committedSeq ? session : { ...session, lastEventSeq: committedSeq }, ); }; const applyStatusPayload = ( sessionId: SessionId, payload: Extract, ) => { const idle = payload.phase === "done" || payload.phase === "idle"; if (idle) dropLiveTarget(sessionId); commit(sessionId, (session) => ({ ...session, piSessionId: payload.session?.piSessionId || session.piSessionId, contextUsage: runtimeContextUsage(payload.session, session.contextUsage), status: idle ? "idle" : session.status === "stopping" ? "stopping" : "running", activeAssistantId: idle ? undefined : session.activeAssistantId, })); }; const applyPiPayload = ( sessionId: SessionId, payload: Extract, ) => { const eventId = piSessionIdFromEvent(payload.event); if (!acceptSeq(sessionId, payload.seq)) return; if (isAgentSettledEvent(payload.event)) { // Record the authoritative end-of-turn so the runtime poll won't // resurrect "running" off a stale still-active list snapshot. turnFinishedAt.set(sessionId, Date.now()); // Flush pending deltas first, then settle the turn in ONE commit: // finalize tool blocks, stamp the cursor, and clear the live status // together. coalescer.flushNow(sessionId); applyEvent(sessionId, payload.event, payload.seq, (session) => ({ ...clearAwaitingEchoUserMessages(settleTurn(session)), piSessionId: eventId || session.piSessionId, })); // The turn is over: drop any mid-stream user-message redirect. The // liveAssistantIds override only bridges the React-commit lag WITHIN a // turn; left set, it would silently retarget the NEXT turn's events onto // this (now settled) bubble, so the next bubble renders empty — tool // calls and reasoning land off-screen and no final content appears. dropLiveTarget(sessionId); // The queue is NOT touched here. Pi owns it: it drains its own follow_up // queue server-side and tells us what happened through the delivered user // echo and `queue_update`. Dropping items at settle just erased the stack // a beat before pi delivered them. return; } // Status-only commit: promote to running and adopt the pi session id. The // assistant bubble itself is resolved inside reduceSessionEvent when the // event's effects commit (targeting lives in the reducer now), so this // leaves activeAssistantId to the reducer. commit(sessionId, (session) => (session.status === "running" || session.status === "stopping") && (!eventId || session.piSessionId === eventId) ? session : { ...session, piSessionId: eventId || session.piSessionId, status: session.status === "stopping" ? "stopping" : "running", }, ); enqueueEvent(sessionId, payload.event, payload.seq); }; // True while a session sits in its post-`agent_settled` grace: the SSE already // settled the turn to idle, but the server's runtime list can still report // the finished runtime as active for a beat. A newer accepted turn supersedes // the finish (genuine restart) and ends the grace early. const withinFinishGrace = (sessionId: SessionId, fetchStartedAt: number): boolean => { const finishedAt = turnFinishedAt.get(sessionId); if (finishedAt === undefined || fetchStartedAt - finishedAt >= RUNTIME_POLL_IDLE_GRACE_MS) return false; const acceptedAt = turnAcceptedAt.get(sessionId); return acceptedAt === undefined || acceptedAt <= finishedAt; }; // Restart adoption: the pi match found this session's runtime under a new // server key. Record the connection-key override (controller-internal — // reconnection plumbing, not session state), reset the cursor, and reopen an // existing attachment under the new key. const adoptConnectionKey = ( session: Session, nextConnectionKey: string, piSessionId: string | null, ) => { // Adopting a different server key means the session is now served by a // fresh runtime whose event seq restarts from 0. The cursor is keyed by // the stable sessionId and still holds the OLD runtime's seq, so a // reconnect would resume "after " and skip the new runtime's // early events. Reset it (as noteTurnAccepted does on a restart) before // the SSE reopens. adoptCursor(session.id, undefined); if (nextConnectionKey === session.id) { connectionKeyOverrides.delete(session.id); } else { connectionKeyOverrides.set(session.id, nextConnectionKey); } // The override is controller-internal — no session state changes, so the // React binding's reconcile will not fire. Reopen an existing attachment // under the new key ourselves; openAttachment connects from the // freshly-reset in-memory cursor. A session without an attachment // (idle -> running promotion) is picked up by the binding's reconcile // when the status commit lands. const attachment = attachments.get(session.id); if (attachment) { attachment.close(); attachments.set(session.id, openAttachment(session.id, nextConnectionKey, piSessionId)); } }; // Reconcile the workspace sessions against one runtime-list snapshot. The // poll is the second leg of status arbitration next to the SSE attachments: // it promotes sessions whose runtime is active (including adopting a new // connection key via the pi-session match) and idles sessions the runtime // no longer reports as active. const applyRuntimeList = (runtimeSessions: RuntimeSessionSummary[], fetchStartedAt: number) => { const byRuntime = new Map(runtimeSessions.map((entry) => [entry.sessionId, entry.status])); const byPi = new Map( runtimeSessions .filter((entry) => entry.status.piSessionId) .map((entry) => [ entry.status.piSessionId!, { serverKey: entry.sessionId, status: entry.status }, ]), ); const sessions = binding?.getSessions() ?? []; const sharedPiIds = collidingPiSessionIds(sessions); for (const session of sessions.filter((entry) => entry.status !== "loading")) { const connectionKey = connectionKeyFor(session); const direct = byRuntime.get(connectionKey); const piMatch = session.piSessionId && !sharedPiIds.has(session.piSessionId) ? byPi.get(session.piSessionId) : undefined; const status = direct ?? piMatch?.status; if (!status) continue; if (status.active === true) { // Post-finish grace (symmetric to the idle branch's accept grace): the // SSE's `agent_settled` is the authoritative end of a turn. For a few // seconds after it, the server's runtime list can still report the // just-finished runtime as active. Re-promoting to "running" off that // stale snapshot fights the SSE's idle and oscillates status — // flicker plus SSE reopen churn on every poll tick. Suppress the active // branch inside the grace window UNLESS a newer turn was accepted after // the finish (a genuine restart supersedes the finish and must recover). if (withinFinishGrace(session.id, fetchStartedAt)) continue; promoteFromRuntimeList(session, status, connectionKey, piMatch?.serverKey); } else if (session.status === "running" || session.status === "stopping") { idleFromRuntimeList(session, status, fetchStartedAt); } } }; // A piSessionId held by 2+ open sessions (forked/duplicated tab, pref copy, or // the mid-turn adoption window before one settles) can't disambiguate which // session a runtime entry belongs to. Trusting the pi reverse-index there // would let ONE runtime entry promote/idle AND repoint every session sharing // the id — direct two-session crosstalk. Collect the collided ids so the // caller falls back to the unambiguous direct runtime match for them. const collidingPiSessionIds = (sessions: readonly Session[]): Set => { const shared = new Set(); const seen = new Set(); for (const session of sessions) { if (!session.piSessionId) continue; if (seen.has(session.piSessionId)) shared.add(session.piSessionId); else seen.add(session.piSessionId); } return shared; }; // The runtime reports this session as active: adopt a new connection key if // the pi match moved it, then promote to running unless it is stopping. const promoteFromRuntimeList = ( session: Session, status: RuntimeStatus, connectionKey: string, matchedServerKey: string | undefined, ) => { const patch = patchRuntimeStatus(status); const nextConnectionKey = matchedServerKey ?? connectionKey; if (nextConnectionKey !== connectionKey) { adoptConnectionKey( session, nextConnectionKey, status.piSessionId ?? session.piSessionId ?? null, ); } commit(session.id, (current) => { const nextStatus = current.status === "stopping" ? "stopping" : "running"; if (sameRuntimePatch(current, patch, nextStatus)) return current; return { ...current, ...patch, status: nextStatus }; }); }; // Only a session the runtime once acknowledged (status "running") may be // idled by the poll. A freshly-sent "starting" turn is not yet in the // runtime list during prefill/TTFT; idling it here would hide the // working indicator for several seconds until the first token lands. // The prompt stream's own `finally` owns the starting->terminal // transition, so the poll must not race it. // // Accept-vs-poll grace: a list snapshot fetched before — or shortly // after — a `/turn` acceptance cannot speak for the new turn, so it // may not idle the session either. Only the idle branch is suppressed; // the active branch is the recovery path and must always apply. const idleFromRuntimeList = (session: Session, status: RuntimeStatus, fetchStartedAt: number) => { const acceptedAt = turnAcceptedAt.get(session.id); if (acceptedAt !== undefined && fetchStartedAt - acceptedAt < RUNTIME_POLL_IDLE_GRACE_MS) return; const patch = patchRuntimeStatus(status); dropLiveTarget(session.id); commit(session.id, (current) => { if (current.status !== "running" && current.status !== "stopping") return current; if (sameRuntimePatch(current, patch, "idle") && !current.activeAssistantId) { return current; } return { ...settleTurn(current), ...patch }; }); }; const stopPoll = () => { if (pollTimer !== null) { clearInterval(pollTimer); pollTimer = null; } // Invalidate any in-flight fetch: a stale snapshot from the previous // session registry must not apply after a fresher immediate reconcile. pollEpoch += 1; }; const pollOnce = () => { void Effect.runPromise( Effect.gen(function* () { const epoch = pollEpoch; const fetchStartedAt = Date.now(); const entries = yield* Effect.tryPromise({ try: () => listRuntimeSessions(), catch: (error) => error, }); if (epoch !== pollEpoch || !binding) return; publishRuntimeActivity(entries); applyRuntimeList(entries, fetchStartedAt); }), ); }; // One SSE attachment per live session: connect, reconnect with a fixed // delay, watchdog the stream, and probe runtime liveness on errors. const openAttachment = ( sessionId: SessionId, runtime: string, piSessionId: string | null, ): Attachment => { let closed = false; let reconnecting = false; let sub: RuntimeEventSubscription | null = null; let reconnectTimer: ReturnType | null = null; let lastPayloadAt = Date.now(); const cancelReconnect = () => { if (reconnectTimer !== null) clearTimeout(reconnectTimer); reconnectTimer = null; reconnecting = false; }; const reconnect = () => { if (closed || reconnecting) return; reconnecting = true; sub?.close(); // Capped fixed-delay reconnect on a real timer so close can interrupt it. reconnectTimer = setTimeout(() => { reconnectTimer = null; reconnecting = false; if (!closed) connect(); }, RESUME_RECONNECT_DELAY_MS); }; const reconcileLiveness = () => { void Effect.runPromise( Effect.gen(function* () { const status = yield* Effect.tryPromise({ try: () => loadRuntimeStatus(runtime, piSessionId), catch: () => null, }); if (closed) return; if (!status) { reconnect(); return; } if (status.active) { commit(sessionId, (session) => ({ ...session, piSessionId: status.piSessionId || session.piSessionId, contextUsage: runtimeContextUsage(status, session.contextUsage), status: session.status === "stopping" ? "stopping" : "running", })); reconnect(); return; } // A reconnect armed by a prior onError must not fire connect() after // we've decided this runtime is idle — it would reopen an SSE against // a session we just idled. cancelReconnect(); sub?.close(); coalescer.flushNow(sessionId); dropLiveTarget(sessionId); commit(sessionId, (session) => session.status === "running" || session.status === "starting" || session.status === "stopping" ? { ...settleTurn(session), contextUsage: runtimeContextUsage(status, session.contextUsage), } : session, ); }), ); }; const connect = () => { // (Re)connect from the highest RECEIVED seq — an unflushed coalesced // delta is still in memory, so replaying it would double-apply. const after = reconnectAfter(cursors.get(sessionId) ?? adoptExternalCursor(undefined)); sub = subscribeRuntimeEvents(runtime, after, piSessionId, { onPayload: (payload) => { if (closed) return; lastPayloadAt = Date.now(); if (payload.type === "status") applyStatusPayload(sessionId, payload); else applyPiPayload(sessionId, payload); }, onError: () => { if (closed) return; void reconcileLiveness(); }, }); }; connect(); const watchdogFiber = RESUME_IDLE_RECONNECT_MS > 0 ? (Effect.runFork( Effect.sync(() => { if (closed || Date.now() - lastPayloadAt < RESUME_IDLE_RECONNECT_MS) return; void reconcileLiveness(); }).pipe(Effect.repeat(Schedule.spaced(RESUME_IDLE_RECONNECT_MS))), ) as never) : null; return { key: resumeConnectionKey(runtime, piSessionId), close: () => { closed = true; if (reconnectTimer !== null) clearTimeout(reconnectTimer); if (watchdogFiber) void Effect.runPromise(Fiber.interrupt(watchdogFiber)); coalescer.flushNow(sessionId); sub?.close(); }, }; }; return { bind: (next) => { binding = next; }, unbind: () => { stopPoll(); binding = null; }, noteTurnAccepted: (sessionId, assistantId, runtimeEventSeq) => { turnAcceptedAt.set(sessionId, Date.now()); // A new turn supersedes any prior finish; drop the stamp so its own // eventual end owns the next grace window. turnFinishedAt.delete(sessionId); // Rewind the gate to 0 only on a genuine runtime restart — when the // runtime's reported seq is now below what we've already received. On a // steady-state turn the seq keeps climbing, so an unconditional rewind // would make the next reconnect re-apply the whole accumulated log (the // 502-retry-storm duplication). A missing seq falls back to the old // always-rewind behavior to preserve the dropped-second-turn guarantee. const received = (cursors.get(sessionId) ?? adoptExternalCursor(undefined)).receivedSeq ?? 0; if (runtimeEventSeq === undefined || runtimeEventSeq < received) { adoptCursor(sessionId, 0); } // A new turn's authoritative bubble is its optimistic activeAssistantId; // discard any stale mid-stream redirect left over from a prior turn that // settled without an agent_settled (e.g. idled by the runtime poll). if (assistantId) streamContext.liveAssistantIds.set(sessionId, assistantId); else streamContext.liveAssistantIds.delete(sessionId); }, noteReplayHydrated: (sessionId, committedSeq) => { // Replay mints fresh ids for every message, so any in-flight live-target // pin now points at a bubble that no longer exists. Drop it or post-replay // events would land on a dead id (silently discarded) while the seq cursor // still advances — the reopen-mid-turn content-drop. The reducer's target // resolution then falls back to the rebuilt transcript's own bubble. streamContext.liveAssistantIds.delete(sessionId); adoptCursor(sessionId, committedSeq); }, reconcile: (sessions) => { const desired = new Map< SessionId, { connectionKey: string; piSessionId: string | null; lastEventSeq: number | undefined } >(); for (const session of sessions) { if (shouldSubscribeRuntimeEvents(session.status)) { desired.set(session.id, { connectionKey: connectionKeyFor(session), piSessionId: session.piSessionId ?? null, lastEventSeq: session.lastEventSeq, }); } } for (const [sessionId, attachment] of [...attachments]) { const want = desired.get(sessionId); const key = want ? resumeConnectionKey(want.connectionKey, want.piSessionId) : ""; if (!want || attachment.key !== key) { attachment.close(); attachments.delete(sessionId); } } pruneStaleSessionEntries(new Set(sessions.map((session) => session.id))); for (const [sessionId, want] of desired) { if (attachments.has(sessionId)) continue; // Seed the gate from the persisted cursor ONLY on a genuine first attach // (no live cursor yet) — e.g. a session restored from storage as // "running". When an attachment is torn down and reopened for an // already-live session — a mid-turn piSessionId adoption changes the // connection key, so reconcile closes the old SSE and opens a new one — // the in-memory cursor already holds the highest RECEIVED seq. // Overwriting it with the persisted lastEventSeq, which only tracks // COMMITTED seqs and therefore lags, would rewind the gate and make the // reopened SSE re-deliver the backlog (the first-turn duplication). const existing = cursors.get(sessionId); if (!existing || (want.lastEventSeq ?? 0) > (existing.receivedSeq ?? 0)) { cursors.set(sessionId, adoptExternalCursor(want.lastEventSeq)); } attachments.set(sessionId, openAttachment(sessionId, want.connectionKey, want.piSessionId)); } }, flush: (sessionId) => coalescer.flushNow(sessionId), pollNow: () => { stopPoll(); if (!binding) return; // One immediate reconcile, then a steady interval. setInterval does not // fire an extra immediate iteration, so pollNow produces one fetch up front. void pollOnce(); pollTimer = setInterval(() => void pollOnce(), RUNTIME_POLL_INTERVAL_MS); }, closeAll: () => { stopPoll(); publishRuntimeActivity([]); for (const attachment of attachments.values()) attachment.close(); attachments.clear(); coalescer.clear(); // Workspace teardown: drop every per-session map so the app-lifetime // singleton doesn't retain one entry per session ever opened. Also drops // every live-target pin so a remount can't inherit a stale id. cursors.clear(); turnAcceptedAt.clear(); turnFinishedAt.clear(); connectionKeyOverrides.clear(); streamContext.liveAssistantIds.clear(); }, connectionKey: (sessionId) => connectionKeyOverrides.get(sessionId) ?? sessionId, seedConnectionKey: (sessionId, runtimeKey) => { // One-shot legacy seed: never clobber an override the poll already owns. if (!runtimeKey || runtimeKey === sessionId) return; if (connectionKeyOverrides.has(sessionId)) return; connectionKeyOverrides.set(sessionId, runtimeKey); }, }; } let singleton: SessionRuntimeController | null = null; /** Lazy app-wide controller instance (one per page lifetime). */ export function sessionRuntimeController(): SessionRuntimeController { singleton ??= createSessionRuntimeController(); return singleton; }