import { ORCHESTRATION_WS_METHODS, type EnvironmentId as EnvironmentIdType, type OrchestrationThread, type OrchestrationThreadDetailPage, type OrchestrationThreadDetailSnapshot, type OrchestrationThreadStreamItem, type ThreadId as ThreadIdType,} from "@t3tools/contracts";import * as Cause from "effect/Cause";import * as Context from "effect/Context";import * as Effect from "effect/Effect";import * as Option from "effect/Option";import * as Queue from "effect/Queue";import * as Ref from "effect/Ref";import * as Semaphore from "effect/Semaphore";import * as Stream from "effect/Stream";import * as SubscriptionRef from "effect/SubscriptionRef";import { Atom } from "effect/unstable/reactivity"; import { EnvironmentRegistry } from "../connection/registry.ts";import { connectionProjectionPhase } from "../connection/model.ts";import { EnvironmentSupervisor } from "../connection/supervisor.ts";import * as ConnectionWakeups from "../connection/wakeups.ts";import { EnvironmentCacheStore } from "../platform/persistence.ts";import { subscribeDynamic } from "../rpc/client.ts";import { ThreadSnapshotLoader, type ThreadSnapshotWindow } from "./threadSnapshotHttp.ts";import { parseThreadKey, threadKey } from "./entities.ts";import { applyThreadDetailEvent } from "./threadReducer.ts";import { THREAD_SNAPSHOT_IDLE_TTL_MS } from "./threadRetention.ts";import { followStreamInEnvironment } from "./runtime.ts";import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadPageState, type EnvironmentThreadState, type EnvironmentThreadStatus,} from "./threadState.ts"; function statusWithoutLiveData(data: Option.Option<OrchestrationThread>): EnvironmentThreadStatus { return Option.isSome(data) ? "cached" : "empty";} /** * Turn window sizes for paginated thread loads: the initial page covers the * last 10 user-anchored turns (subagent/fan-out turns ride along), each * "load earlier" tap fetches 20 more. Sized so first paint on the heaviest * observed threads stays around 100K gzipped while median threads load fully. */export const INITIAL_THREAD_USER_TURN_LIMIT = 10;const OLDER_THREAD_PAGE_USER_TURN_LIMIT = 20; function pageStateFromSnapshot( page: OrchestrationThreadDetailPage | undefined,): Option.Option<EnvironmentThreadPageState> { return page === undefined ? Option.none() : Option.some({ beforeCursor: page.beforeCursor, hasMore: page.hasMore, loadingOlder: false, });} interface ThreadOlderTurnRequestRegistry { /** * Registers the live state machine for a thread. Returns the deregistration * cleanup; registration lives exactly as long as the machine's scope, and a * successor machine for the same thread simply replaces the entry. */ readonly register: (key: string, handler: () => void) => () => void; readonly request: (key: string) => boolean;} function makeThreadOlderTurnRequestRegistry(): ThreadOlderTurnRequestRegistry { const handlers = new Map<string, () => void>(); return { register: (key, handler) => { handlers.set(key, handler); return () => { if (handlers.get(key) === handler) { handlers.delete(key); } }; }, request: (key) => { const handler = handlers.get(key); if (handler === undefined) { return false; } handler(); return true; }, };} const defaultOlderTurnRequestRegistry = makeThreadOlderTurnRequestRegistry(); /** * Channel from UI actions to the live per-thread state machines. The machines * resolve it from the Effect environment (overridable in tests); the default * instance is shared with the sync `requestOlderThreadTurns` entry point so * the apps get working wiring without providing anything. */class ThreadOlderTurnRequests extends Context.Reference<ThreadOlderTurnRequestRegistry>( "@t3tools/client-runtime/state/threads/ThreadOlderTurnRequests", { defaultValue: () => defaultOlderTurnRequestRegistry },) {} /** * Asks the live state machine for `threadId` to fetch the next older page. * Returns false when no machine is live or no fetch was started (no cursor, * already loading); callers render from `EnvironmentThreadState.page` and can * treat false as "nothing to do". */export function requestOlderThreadTurns( environmentId: EnvironmentIdType, threadId: ThreadIdType,): boolean { return defaultOlderTurnRequestRegistry.request(threadKey({ environmentId, threadId }));} function formatThreadError(cause: Cause.Cause<unknown>): string { const error = Cause.squash(cause); return error instanceof Error && error.message.trim().length > 0 ? error.message : "Could not synchronize the thread.";} function shouldPersistThread(thread: OrchestrationThread): boolean { const status = thread.session?.status; return status !== "starting" && status !== "running";} interface ThreadResumeSnapshot { readonly state: EnvironmentThreadState; readonly sequence: number; readonly persisted: boolean;} interface ThreadResumeCache { snapshot: ThreadResumeSnapshot | undefined; owner: object | undefined;} function matchesThreadSnapshot( current: ThreadResumeSnapshot, thread: OrchestrationThread | null, sequence: number, page: Pick<EnvironmentThreadPageState, "beforeCursor" | "hasMore"> | undefined,): boolean { if (current.sequence !== sequence || Option.getOrNull(current.state.data) !== thread) return false; const currentPage = Option.getOrUndefined(current.state.page); return currentPage === undefined ? page === undefined : page !== undefined && currentPage.beforeCursor === page.beforeCursor && currentPage.hasMore === page.hasMore;} // A retained "live" state stays live: the cursor resume that follows only// replays what the thread missed, and on servers that send the completion// marker the first replayed event moves the status to "synchronizing" on its// own. Downgrading here would flash a sync label on every return to a// recently viewed thread.function cachedThreadState(value: EnvironmentThreadState): EnvironmentThreadState { return { ...value, status: value.status === "deleted" || (value.status === "live" && Option.isSome(value.data)) ? value.status : statusWithoutLiveData(value.data), error: Option.none(), page: Option.map(value.page, (page) => ({ ...page, loadingOlder: false })), };} export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make")(function* ( threadId: ThreadIdType, resumeCache?: ThreadResumeCache,) { const supervisor = yield* EnvironmentSupervisor; const cache = yield* EnvironmentCacheStore; const snapshotLoader = yield* ThreadSnapshotLoader; const wakeups = yield* Effect.serviceOption(ConnectionWakeups.ConnectionWakeups); const environmentId = supervisor.target.environmentId; const retained = resumeCache?.snapshot; const owner = {}; if (resumeCache) resumeCache.owner = owner; const cached = retained === undefined ? yield* cache.loadThread(environmentId, threadId).pipe( Effect.catch((error) => Effect.logWarning("Could not load cached thread.").pipe( Effect.annotateLogs({ environmentId, threadId, error: error.message, }), Effect.as(Option.none<OrchestrationThreadDetailSnapshot>()), ), ), ) : Option.none<OrchestrationThreadDetailSnapshot>(); const cachedThread = Option.map(cached, (snapshot) => snapshot.thread); const initialState: EnvironmentThreadState = retained ? cachedThreadState(retained.state) : { data: cachedThread, status: statusWithoutLiveData(cachedThread), error: Option.none(), // A cached windowed snapshot restores its page cursor so "load earlier" // works while rendering from cache; a cached full snapshot has no page. page: Option.flatMap(cached, (snapshot) => pageStateFromSnapshot(snapshot.page)), }; const state = yield* SubscriptionRef.make(initialState); // Seed the resume cursor from the cached snapshot so a warm cache can catch up // via `afterSequence` instead of re-downloading the full thread body. const initialSequence = retained?.sequence ?? Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence }); const lastSequence = yield* SubscriptionRef.make(initialSequence); let committed: ThreadResumeSnapshot = { state: initialState, sequence: initialSequence, persisted: retained?.persisted ?? Option.isSome(cached), }; if (resumeCache?.owner === owner) resumeCache.snapshot = committed; const awaitingCompletion = yield* Ref.make(false); // Bumped whenever loaded history may have been rewritten out from under an // in-flight older-page fetch (snapshot replacement, revert, deletion). A // page response captured under an older epoch is discarded, not merged. const historyEpoch = yield* Ref.make(0); // Serializes stream-item application against older-page staleness checks + // merges. Without it, a revert or snapshot processed between loadOlderTurns' // epoch check and its merge could still slip resurrected history in. const applyLock = yield* Semaphore.make(1); // Save only completed data/cursor updates. A canceled scope must not cache // a cursor whose event has not reached the data yet. const remember = Effect.gen(function* () { const current = yield* SubscriptionRef.get(state); const sequence = yield* SubscriptionRef.get(lastSequence); committed = { state: current, sequence, persisted: committed.persisted && matchesThreadSnapshot( committed, Option.getOrNull(current.data), sequence, Option.getOrUndefined(current.page), ), }; if (resumeCache?.owner === owner) resumeCache.snapshot = committed; }); // Whether the connected server accepts windowed reads; set per subscription // from the session config. Gates loadOlderTurns so a reconnect to a // pre-pagination server never sends unsupported window parameters. const paginationSupported = yield* Ref.make(false); // An older page whose thread watermark is ahead of the live state, parked // until the subscription catches up (see mergeOlderPage's caller). At most // one can exist because loadOlderTurns no-ops while loadingOlder is true. const pendingOlderPage = yield* Ref.make<{ readonly snapshot: OrchestrationThreadDetailSnapshot; readonly epoch: number; } | null>(null); const persistence = yield* Queue.sliding<OrchestrationThreadDetailSnapshot>(1); const persist = Effect.fn("EnvironmentThreadState.persist")(function* ( snapshot: OrchestrationThreadDetailSnapshot, ) { if (resumeCache !== undefined && resumeCache.owner !== owner) return; if ( committed.persisted && matchesThreadSnapshot(committed, snapshot.thread, snapshot.snapshotSequence, snapshot.page) ) return; yield* cache.saveThread(environmentId, snapshot).pipe( Effect.tap(() => Effect.sync(() => { if ( !matchesThreadSnapshot( committed, snapshot.thread, snapshot.snapshotSequence, snapshot.page, ) ) return; committed = { ...committed, persisted: true }; if (resumeCache?.owner === owner) resumeCache.snapshot = committed; }), ), Effect.catch((error) => Effect.logWarning("Could not persist the thread cache.").pipe( Effect.annotateLogs({ environmentId, threadId, error: error.message, }), ), ), ); }); yield* Stream.fromQueue(persistence).pipe( Stream.debounce("500 millis"), Stream.runForEach(persist), Effect.forkScoped, ); const setConnecting = SubscriptionRef.update(state, (current) => current.status === "deleted" || Option.isSome(current.error) ? current : { ...current, status: "synchronizing" as const, error: Option.none(), }, ); const setReady = SubscriptionRef.update(state, (current) => current.status === "live" || current.status === "deleted" || Option.isSome(current.error) ? current : { ...current, status: "synchronizing" as const, error: Option.none(), }, ); const setDisconnected = Effect.gen(function* () { yield* Ref.set(awaitingCompletion, false); // The capability belongs to the session that advertised it. During a // reconnect, a new prepared connection can exist before the new session's // config arrives; leaving the old value would let loadOlderTurns send // window parameters to a server that may not accept them (review // finding). makeSubscribeInput re-sets it from the next session's config. yield* Ref.set(paginationSupported, false); yield* SubscriptionRef.update(state, (current) => ({ ...current, status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), })); }); const setStreamError = (message: string) => Ref.set(awaitingCompletion, false).pipe( Effect.andThen( SubscriptionRef.update(state, (current) => ({ ...current, status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), error: Option.some(message), })), ), ); const setThread = Effect.fn("EnvironmentThreadState.setThread")(function* ( thread: OrchestrationThread, // "keep" preserves the current page state (live events touch only loaded // recent turns); a snapshot or merged page passes its own page state. page: Option.Option<EnvironmentThreadPageState> | "keep", ) { const waiting = yield* Ref.get(awaitingCompletion); yield* SubscriptionRef.update(state, (current) => ({ data: Option.some(thread), // Buffered values from the failed attempt can still arrive after its error. status: Option.isSome(current.error) ? ("cached" as const) : waiting ? ("synchronizing" as const) : ("live" as const), error: current.error, page: page === "keep" ? current.page : page, })); // Active threads can update many times per second and retain large tool // payloads. The server remains the source of truth while a turn is active; // persist once it settles so cache encoding stays off the streaming path. if (shouldPersistThread(thread)) { const snapshotSequence = yield* SubscriptionRef.get(lastSequence); const currentPage = yield* SubscriptionRef.get(state).pipe(Effect.map((value) => value.page)); yield* Queue.offer(persistence, { snapshotSequence, thread, // Persist the window boundary with the window's content so a cache // restore can keep paging from where the loaded history ends. ...Option.match(currentPage, { onNone: () => ({}), onSome: (value) => ({ page: { beforeCursor: value.beforeCursor, hasMore: value.hasMore, snapshotSequence, }, }) as const, }), }); } }); const setDeleted = Effect.fn("EnvironmentThreadState.setDeleted")(function* () { yield* Ref.set(awaitingCompletion, false); yield* Ref.update(historyEpoch, (epoch) => epoch + 1); yield* SubscriptionRef.set(state, { data: Option.none(), status: "deleted", error: Option.none(), page: Option.none(), }); yield* remember; if (resumeCache !== undefined && resumeCache.owner !== owner) return; yield* cache.removeThread(environmentId, threadId).pipe( Effect.catch((error) => Effect.logWarning("Could not remove the cached thread.").pipe( Effect.annotateLogs({ environmentId, threadId, error: error.message, }), ), ), ); }); // Body of applyItem, running under applyLock. const applyItemLocked = Effect.fn("EnvironmentThreadState.applyItemLocked")(function* ( item: OrchestrationThreadStreamItem, ) { if (item.kind === "synchronized") { yield* Ref.set(awaitingCompletion, false); yield* SubscriptionRef.update(state, (current) => Option.isSome(current.data) && current.status !== "deleted" && Option.isNone(current.error) ? { ...current, status: "live" as const, error: Option.none() } : current, ); return; } if (item.kind === "snapshot") { // A fresh snapshot replaces all loaded history, including older // pages: a turn reverted while disconnected would otherwise survive // in the preserved history with no event left to remove it. The // epoch bump discards any older-page fetch racing this snapshot. yield* Ref.update(historyEpoch, (epoch) => epoch + 1); yield* SubscriptionRef.set(lastSequence, item.snapshot.snapshotSequence); yield* setThread(item.snapshot.thread, pageStateFromSnapshot(item.snapshot.page)); return; } const sequence = yield* SubscriptionRef.get(lastSequence); if (item.event.sequence <= sequence) { return; } yield* SubscriptionRef.set(lastSequence, item.event.sequence); const current = yield* SubscriptionRef.get(state); if (Option.isNone(current.data)) { if (item.event.type === "thread.deleted") { yield* setDeleted(); } return; } if (item.event.type === "thread.reverted") { // A revert rewrites loaded history (whole turns disappear), so an // older-page fetch in flight may straddle the removed range; the epoch // bump discards it. The stored page cursor stays valid: cursors are an // (anchor, turnId) keyset derived from event content, which survives // the revert projector's row rewrite, so no refresh is needed — the // revert reducer's turn filtering fully handles loaded history. yield* Ref.update(historyEpoch, (epoch) => epoch + 1); } const result = applyThreadDetailEvent(current.data.value, item.event); if (result.kind === "updated") { yield* setThread(result.thread, "keep"); } else if (result.kind === "deleted") { yield* setDeleted(); } // The event may have advanced the live state past a parked page's // watermark; merge it as soon as that happens. yield* tryMergePendingOlderPage(); }); // Merges a parked older page once the live state has caught up to the // page's thread watermark, or discards it if history was rewritten // (epoch advanced) while it waited. Must run under applyLock. const tryMergePendingOlderPage = Effect.fn("EnvironmentThreadState.tryMergePendingOlderPage")( function* () { const pending = yield* Ref.get(pendingOlderPage); if (pending === null) { return; } const epochNow = yield* Ref.get(historyEpoch); if (epochNow !== pending.epoch) { yield* Ref.set(pendingOlderPage, null); yield* SubscriptionRef.update(state, (value) => ({ ...value, page: Option.map(value.page, (existing) => ({ ...existing, loadingOlder: false })), })); return; } const watermark = pending.snapshot.page?.threadSequence;Lines 1–500 of 892. Load more
import { ORCHESTRATION_WS_METHODS, type EnvironmentId as EnvironmentIdType, type OrchestrationThread, type OrchestrationThreadDetailPage, type OrchestrationThreadDetailSnapshot, type OrchestrationThreadStreamItem, type ThreadId as ThreadIdType, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { Atom } from "effect/unstable/reactivity"; import { EnvironmentRegistry } from "../connection/registry.ts"; import { connectionProjectionPhase } from "../connection/model.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; import * as ConnectionWakeups from "../connection/wakeups.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; import { subscribeDynamic } from "../rpc/client.ts"; import { ThreadSnapshotLoader, type ThreadSnapshotWindow } from "./threadSnapshotHttp.ts"; import { parseThreadKey, threadKey } from "./entities.ts"; import { applyThreadDetailEvent } from "./threadReducer.ts"; import { THREAD_SNAPSHOT_IDLE_TTL_MS } from "./threadRetention.ts"; import { followStreamInEnvironment } from "./runtime.ts"; import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadPageState, type EnvironmentThreadState, type EnvironmentThreadStatus, } from "./threadState.ts"; function statusWithoutLiveData(data: Option.Option<OrchestrationThread>): EnvironmentThreadStatus { return Option.isSome(data) ? "cached" : "empty"; } /** * Turn window sizes for paginated thread loads: the initial page covers the * last 10 user-anchored turns (subagent/fan-out turns ride along), each * "load earlier" tap fetches 20 more. Sized so first paint on the heaviest * observed threads stays around 100K gzipped while median threads load fully. */ export const INITIAL_THREAD_USER_TURN_LIMIT = 10; const OLDER_THREAD_PAGE_USER_TURN_LIMIT = 20; function pageStateFromSnapshot( page: OrchestrationThreadDetailPage | undefined, ): Option.Option<EnvironmentThreadPageState> { return page === undefined ? Option.none() : Option.some({ beforeCursor: page.beforeCursor, hasMore: page.hasMore, loadingOlder: false, }); } interface ThreadOlderTurnRequestRegistry { /** * Registers the live state machine for a thread. Returns the deregistration * cleanup; registration lives exactly as long as the machine's scope, and a * successor machine for the same thread simply replaces the entry. */ readonly register: (key: string, handler: () => void) => () => void; readonly request: (key: string) => boolean; } function makeThreadOlderTurnRequestRegistry(): ThreadOlderTurnRequestRegistry { const handlers = new Map<string, () => void>(); return { register: (key, handler) => { handlers.set(key, handler); return () => { if (handlers.get(key) === handler) { handlers.delete(key); } }; }, request: (key) => { const handler = handlers.get(key); if (handler === undefined) { return false; } handler(); return true; }, }; } const defaultOlderTurnRequestRegistry = makeThreadOlderTurnRequestRegistry(); /** * Channel from UI actions to the live per-thread state machines. The machines * resolve it from the Effect environment (overridable in tests); the default * instance is shared with the sync `requestOlderThreadTurns` entry point so * the apps get working wiring without providing anything. */ class ThreadOlderTurnRequests extends Context.Reference<ThreadOlderTurnRequestRegistry>( "@t3tools/client-runtime/state/threads/ThreadOlderTurnRequests", { defaultValue: () => defaultOlderTurnRequestRegistry }, ) {} /** * Asks the live state machine for `threadId` to fetch the next older page. * Returns false when no machine is live or no fetch was started (no cursor, * already loading); callers render from `EnvironmentThreadState.page` and can * treat false as "nothing to do". */ export function requestOlderThreadTurns( environmentId: EnvironmentIdType, threadId: ThreadIdType, ): boolean { return defaultOlderTurnRequestRegistry.request(threadKey({ environmentId, threadId })); } function formatThreadError(cause: Cause.Cause<unknown>): string { const error = Cause.squash(cause); return error instanceof Error && error.message.trim().length > 0 ? error.message : "Could not synchronize the thread."; } function shouldPersistThread(thread: OrchestrationThread): boolean { const status = thread.session?.status; return status !== "starting" && status !== "running"; } interface ThreadResumeSnapshot { readonly state: EnvironmentThreadState; readonly sequence: number; readonly persisted: boolean; } interface ThreadResumeCache { snapshot: ThreadResumeSnapshot | undefined; owner: object | undefined; } function matchesThreadSnapshot( current: ThreadResumeSnapshot, thread: OrchestrationThread | null, sequence: number, page: Pick<EnvironmentThreadPageState, "beforeCursor" | "hasMore"> | undefined, ): boolean { if (current.sequence !== sequence || Option.getOrNull(current.state.data) !== thread) return false; const currentPage = Option.getOrUndefined(current.state.page); return currentPage === undefined ? page === undefined : page !== undefined && currentPage.beforeCursor === page.beforeCursor && currentPage.hasMore === page.hasMore; } // A retained "live" state stays live: the cursor resume that follows only // replays what the thread missed, and on servers that send the completion // marker the first replayed event moves the status to "synchronizing" on its // own. Downgrading here would flash a sync label on every return to a // recently viewed thread. function cachedThreadState(value: EnvironmentThreadState): EnvironmentThreadState { return { ...value, status: value.status === "deleted" || (value.status === "live" && Option.isSome(value.data)) ? value.status : statusWithoutLiveData(value.data), error: Option.none(), page: Option.map(value.page, (page) => ({ ...page, loadingOlder: false })), }; } export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make")(function* ( threadId: ThreadIdType, resumeCache?: ThreadResumeCache, ) { const supervisor = yield* EnvironmentSupervisor; const cache = yield* EnvironmentCacheStore; const snapshotLoader = yield* ThreadSnapshotLoader; const wakeups = yield* Effect.serviceOption(ConnectionWakeups.ConnectionWakeups); const environmentId = supervisor.target.environmentId; const retained = resumeCache?.snapshot; const owner = {}; if (resumeCache) resumeCache.owner = owner; const cached = retained === undefined ? yield* cache.loadThread(environmentId, threadId).pipe( Effect.catch((error) => Effect.logWarning("Could not load cached thread.").pipe( Effect.annotateLogs({ environmentId, threadId, error: error.message, }), Effect.as(Option.none<OrchestrationThreadDetailSnapshot>()), ), ), ) : Option.none<OrchestrationThreadDetailSnapshot>(); const cachedThread = Option.map(cached, (snapshot) => snapshot.thread); const initialState: EnvironmentThreadState = retained ? cachedThreadState(retained.state) : { data: cachedThread, status: statusWithoutLiveData(cachedThread), error: Option.none(), // A cached windowed snapshot restores its page cursor so "load earlier" // works while rendering from cache; a cached full snapshot has no page. page: Option.flatMap(cached, (snapshot) => pageStateFromSnapshot(snapshot.page)), }; const state = yield* SubscriptionRef.make(initialState); // Seed the resume cursor from the cached snapshot so a warm cache can catch up // via `afterSequence` instead of re-downloading the full thread body. const initialSequence = retained?.sequence ?? Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence }); const lastSequence = yield* SubscriptionRef.make(initialSequence); let committed: ThreadResumeSnapshot = { state: initialState, sequence: initialSequence, persisted: retained?.persisted ?? Option.isSome(cached), }; if (resumeCache?.owner === owner) resumeCache.snapshot = committed; const awaitingCompletion = yield* Ref.make(false); // Bumped whenever loaded history may have been rewritten out from under an // in-flight older-page fetch (snapshot replacement, revert, deletion). A // page response captured under an older epoch is discarded, not merged. const historyEpoch = yield* Ref.make(0); // Serializes stream-item application against older-page staleness checks + // merges. Without it, a revert or snapshot processed between loadOlderTurns' // epoch check and its merge could still slip resurrected history in. const applyLock = yield* Semaphore.make(1); // Save only completed data/cursor updates. A canceled scope must not cache // a cursor whose event has not reached the data yet. const remember = Effect.gen(function* () { const current = yield* SubscriptionRef.get(state); const sequence = yield* SubscriptionRef.get(lastSequence); committed = { state: current, sequence, persisted: committed.persisted && matchesThreadSnapshot( committed, Option.getOrNull(current.data), sequence, Option.getOrUndefined(current.page), ), }; if (resumeCache?.owner === owner) resumeCache.snapshot = committed; }); // Whether the connected server accepts windowed reads; set per subscription // from the session config. Gates loadOlderTurns so a reconnect to a // pre-pagination server never sends unsupported window parameters. const paginationSupported = yield* Ref.make(false); // An older page whose thread watermark is ahead of the live state, parked // until the subscription catches up (see mergeOlderPage's caller). At most // one can exist because loadOlderTurns no-ops while loadingOlder is true. const pendingOlderPage = yield* Ref.make<{ readonly snapshot: OrchestrationThreadDetailSnapshot; readonly epoch: number; } | null>(null); const persistence = yield* Queue.sliding<OrchestrationThreadDetailSnapshot>(1); const persist = Effect.fn("EnvironmentThreadState.persist")(function* ( snapshot: OrchestrationThreadDetailSnapshot, ) { if (resumeCache !== undefined && resumeCache.owner !== owner) return; if ( committed.persisted && matchesThreadSnapshot(committed, snapshot.thread, snapshot.snapshotSequence, snapshot.page) ) return; yield* cache.saveThread(environmentId, snapshot).pipe( Effect.tap(() => Effect.sync(() => { if ( !matchesThreadSnapshot( committed, snapshot.thread, snapshot.snapshotSequence, snapshot.page, ) ) return; committed = { ...committed, persisted: true }; if (resumeCache?.owner === owner) resumeCache.snapshot = committed; }), ), Effect.catch((error) => Effect.logWarning("Could not persist the thread cache.").pipe( Effect.annotateLogs({ environmentId, threadId, error: error.message, }), ), ), ); }); yield* Stream.fromQueue(persistence).pipe( Stream.debounce("500 millis"), Stream.runForEach(persist), Effect.forkScoped, ); const setConnecting = SubscriptionRef.update(state, (current) => current.status === "deleted" || Option.isSome(current.error) ? current : { ...current, status: "synchronizing" as const, error: Option.none(), }, ); const setReady = SubscriptionRef.update(state, (current) => current.status === "live" || current.status === "deleted" || Option.isSome(current.error) ? current : { ...current, status: "synchronizing" as const, error: Option.none(), }, ); const setDisconnected = Effect.gen(function* () { yield* Ref.set(awaitingCompletion, false); // The capability belongs to the session that advertised it. During a // reconnect, a new prepared connection can exist before the new session's // config arrives; leaving the old value would let loadOlderTurns send // window parameters to a server that may not accept them (review // finding). makeSubscribeInput re-sets it from the next session's config. yield* Ref.set(paginationSupported, false); yield* SubscriptionRef.update(state, (current) => ({ ...current, status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), })); }); const setStreamError = (message: string) => Ref.set(awaitingCompletion, false).pipe( Effect.andThen( SubscriptionRef.update(state, (current) => ({ ...current, status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), error: Option.some(message), })), ), ); const setThread = Effect.fn("EnvironmentThreadState.setThread")(function* ( thread: OrchestrationThread, // "keep" preserves the current page state (live events touch only loaded // recent turns); a snapshot or merged page passes its own page state. page: Option.Option<EnvironmentThreadPageState> | "keep", ) { const waiting = yield* Ref.get(awaitingCompletion); yield* SubscriptionRef.update(state, (current) => ({ data: Option.some(thread), // Buffered values from the failed attempt can still arrive after its error. status: Option.isSome(current.error) ? ("cached" as const) : waiting ? ("synchronizing" as const) : ("live" as const), error: current.error, page: page === "keep" ? current.page : page, })); // Active threads can update many times per second and retain large tool // payloads. The server remains the source of truth while a turn is active; // persist once it settles so cache encoding stays off the streaming path. if (shouldPersistThread(thread)) { const snapshotSequence = yield* SubscriptionRef.get(lastSequence); const currentPage = yield* SubscriptionRef.get(state).pipe(Effect.map((value) => value.page)); yield* Queue.offer(persistence, { snapshotSequence, thread, // Persist the window boundary with the window's content so a cache // restore can keep paging from where the loaded history ends. ...Option.match(currentPage, { onNone: () => ({}), onSome: (value) => ({ page: { beforeCursor: value.beforeCursor, hasMore: value.hasMore, snapshotSequence, }, }) as const, }), }); } }); const setDeleted = Effect.fn("EnvironmentThreadState.setDeleted")(function* () { yield* Ref.set(awaitingCompletion, false); yield* Ref.update(historyEpoch, (epoch) => epoch + 1); yield* SubscriptionRef.set(state, { data: Option.none(), status: "deleted", error: Option.none(), page: Option.none(), }); yield* remember; if (resumeCache !== undefined && resumeCache.owner !== owner) return; yield* cache.removeThread(environmentId, threadId).pipe( Effect.catch((error) => Effect.logWarning("Could not remove the cached thread.").pipe( Effect.annotateLogs({ environmentId, threadId, error: error.message, }), ), ), ); }); // Body of applyItem, running under applyLock. const applyItemLocked = Effect.fn("EnvironmentThreadState.applyItemLocked")(function* ( item: OrchestrationThreadStreamItem, ) { if (item.kind === "synchronized") { yield* Ref.set(awaitingCompletion, false); yield* SubscriptionRef.update(state, (current) => Option.isSome(current.data) && current.status !== "deleted" && Option.isNone(current.error) ? { ...current, status: "live" as const, error: Option.none() } : current, ); return; } if (item.kind === "snapshot") { // A fresh snapshot replaces all loaded history, including older // pages: a turn reverted while disconnected would otherwise survive // in the preserved history with no event left to remove it. The // epoch bump discards any older-page fetch racing this snapshot. yield* Ref.update(historyEpoch, (epoch) => epoch + 1); yield* SubscriptionRef.set(lastSequence, item.snapshot.snapshotSequence); yield* setThread(item.snapshot.thread, pageStateFromSnapshot(item.snapshot.page)); return; } const sequence = yield* SubscriptionRef.get(lastSequence); if (item.event.sequence <= sequence) { return; } yield* SubscriptionRef.set(lastSequence, item.event.sequence); const current = yield* SubscriptionRef.get(state); if (Option.isNone(current.data)) { if (item.event.type === "thread.deleted") { yield* setDeleted(); } return; } if (item.event.type === "thread.reverted") { // A revert rewrites loaded history (whole turns disappear), so an // older-page fetch in flight may straddle the removed range; the epoch // bump discards it. The stored page cursor stays valid: cursors are an // (anchor, turnId) keyset derived from event content, which survives // the revert projector's row rewrite, so no refresh is needed — the // revert reducer's turn filtering fully handles loaded history. yield* Ref.update(historyEpoch, (epoch) => epoch + 1); } const result = applyThreadDetailEvent(current.data.value, item.event); if (result.kind === "updated") { yield* setThread(result.thread, "keep"); } else if (result.kind === "deleted") { yield* setDeleted(); } // The event may have advanced the live state past a parked page's // watermark; merge it as soon as that happens. yield* tryMergePendingOlderPage(); }); // Merges a parked older page once the live state has caught up to the // page's thread watermark, or discards it if history was rewritten // (epoch advanced) while it waited. Must run under applyLock. const tryMergePendingOlderPage = Effect.fn("EnvironmentThreadState.tryMergePendingOlderPage")( function* () { const pending = yield* Ref.get(pendingOlderPage); if (pending === null) { return; } const epochNow = yield* Ref.get(historyEpoch); if (epochNow !== pending.epoch) { yield* Ref.set(pendingOlderPage, null); yield* SubscriptionRef.update(state, (value) => ({ ...value, page: Option.map(value.page, (existing) => ({ ...existing, loadingOlder: false })), })); return; } const watermark = pending.snapshot.page?.threadSequence;