History

perf(client): stop thread streams when unused (#9740)

Close a thread's live detail stream when its last consumer leaves. Retain a registry-local completed state and replay cursor for five idle minutes. Keep warm lookup independent of collectible raw atom definitions. Preserve paging and deletion state, reject stale owners, and cache only completed data/cursor updates. Skip repeated saves of unchanged data while retaining dirty-data flushes and failed-write retries. Verify real GC/remount behavior and focused lifecycle cases. Created with GPT-6 Astra (preview) in Codex.

Theo Browne committed 2 months ago

Commit d7cf8aa1 parent 0de956e6 files · +911 −129Browse files

Unified · Split

6 files
  1. docs/internals/connection-runtime.md +15 −0
  2. packages/client-runtime/src/state/threadDetail.ts +10 −35
  3. packages/client-runtime/src/state/threadRetention.ts +3 −3
  4. packages/client-runtime/src/state/threads-atoms.test.ts +410 −20
  5. packages/client-runtime/src/state/threads-sync.test.ts +279 −3
  6. packages/client-runtime/src/state/threads.ts +194 −68
docs/internals/connection-runtime.md+15−0
@@ -170,6 +170,20 @@ Application code must not construct RPC clients, retry loops, or raw · 169 unmodified lines
170170 orchestration commands. Persistence paths belong to the platform registration
171171 and cache stores, with explicit migration or invalidation policy.
172172
173+### Thread detail ownership
174+
175+Mounted detail consumers share one live thread subscription. The subscription
176+ends when the last consumer leaves. Hidden routes that remain mounted still
177+count as consumers.
178+
179+A separate registry-local atom retains the last thread state and replay cursor
180+for five idle minutes. A warm mount renders that state and resumes the stream
181+without another snapshot download. The cache holds completed state and cursor
182+updates together. It retains pagination data but clears canceled loading state.
183+An ownership token prevents an old scope from replacing its successor's cache.
184+Closing a thread skips snapshots that were already saved. Changed snapshots and
185+failed background writes still get a final save attempt.
186+
173187 ## Verification
174188
175189 Core state-machine tests use `@effect/vitest` and deterministic service layers.
@@ -184,6 +198,7 @@ Required coverage includes: · 8 unmodified lines
184198 - relay token reuse and refresh;
185199 - progressive relay discovery;
186200 - shell and thread cache hydration;
201+- thread stream shutdown, warm resume, and snapshot expiry;
187202 - durable subscriptions switching sessions;
188203 - command metadata and idempotent queued-command metadata.
189204
packages/client-runtime/src/state/threadDetail.ts+10−35
@@ -15,7 +15,6 @@ import type { EnvironmentThread, EnvironmentThreadShell } from "./models.ts"; · 14 unmodified lines
1515 import { scopeThread } from "./models.ts";
1616 import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threadState.ts";
1717 import { parseThreadKey, threadKey } from "./entities.ts";
18−import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";
1918
2019 const EMPTY_MESSAGES: ReadonlyArray<OrchestrationMessage> = Object.freeze([]);
2120 const EMPTY_ACTIVITIES: ReadonlyArray<OrchestrationThreadActivity> = Object.freeze([]);
@@ -80,10 +79,7 @@ export function createEnvironmentThreadDetailAtoms<E>( · 58 unmodified lines
8079 AsyncResult.value(get(threadStateAtom(ref.environmentId, ref.threadId))),
8180 () => EMPTY_ENVIRONMENT_THREAD_STATE,
8281 ),
83− ).pipe(
84− Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
85− Atom.withLabel(`environment-thread-state-value:${key}`),
86− );
82+ ).pipe(Atom.setIdleTTL(0), Atom.withLabel(`environment-thread-state-value:${key}`));
8783 });
8884
8985 const threadDetailAtomFamily = Atom.family((key: string) => {
@@ -98,22 +94,19 @@ export function createEnvironmentThreadDetailAtoms<E>( · 8 unmodified lines
9894 previousSource = source;
9995 previousValue = source === null ? null : scopeThread(ref.environmentId, source);
10096 return previousValue;
101− }).pipe(
102− Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
103− Atom.withLabel(`environment-thread-detail:${key}`),
104− );
97+ }).pipe(Atom.setIdleTTL(0), Atom.withLabel(`environment-thread-detail:${key}`));
10598 });
10699
107100 const threadStatusAtomFamily = Atom.family((key: string) =>
108101 Atom.make((get) => get(threadStateValueAtomFamily(key)).status).pipe(
109− Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
102+ Atom.setIdleTTL(0),
110103 Atom.withLabel(`environment-thread-status:${key}`),
111104 ),
112105 );
113106
114107 const threadErrorAtomFamily = Atom.family((key: string) =>
115108 Atom.make((get) => Option.getOrNull(get(threadStateValueAtomFamily(key)).error)).pipe(
116− Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
109+ Atom.setIdleTTL(0),
117110 Atom.withLabel(`environment-thread-error:${key}`),
118111 ),
119112 );
@@ -122,58 +115,40 @@ export function createEnvironmentThreadDetailAtoms<E>( · 2 unmodified lines
122115 Atom.make(
123116 (get): ReadonlyArray<OrchestrationMessage> =>
124117 get(threadDetailAtomFamily(key))?.messages ?? EMPTY_MESSAGES,
125− ).pipe(
126− Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
127− Atom.withLabel(`environment-thread-messages:${key}`),
128− ),
118+ ).pipe(Atom.setIdleTTL(0), Atom.withLabel(`environment-thread-messages:${key}`)),
129119 );
130120
131121 const threadActivitiesAtomFamily = Atom.family((key: string) =>
132122 Atom.make(
133123 (get): ReadonlyArray<OrchestrationThreadActivity> =>
134124 get(threadDetailAtomFamily(key))?.activities ?? EMPTY_ACTIVITIES,
135− ).pipe(
136− Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
137− Atom.withLabel(`environment-thread-activities:${key}`),
138− ),
125+ ).pipe(Atom.setIdleTTL(0), Atom.withLabel(`environment-thread-activities:${key}`)),
139126 );
140127
141128 const threadProposedPlansAtomFamily = Atom.family((key: string) =>
142129 Atom.make(
143130 (get): ReadonlyArray<OrchestrationProposedPlan> =>
144131 get(threadDetailAtomFamily(key))?.proposedPlans ?? EMPTY_PROPOSED_PLANS,
145− ).pipe(
146− Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
147− Atom.withLabel(`environment-thread-proposed-plans:${key}`),
148− ),
132+ ).pipe(Atom.setIdleTTL(0), Atom.withLabel(`environment-thread-proposed-plans:${key}`)),
149133 );
150134
151135 const threadCheckpointsAtomFamily = Atom.family((key: string) =>
152136 Atom.make(
153137 (get): ReadonlyArray<OrchestrationCheckpointSummary> =>
154138 get(threadDetailAtomFamily(key))?.checkpoints ?? EMPTY_CHECKPOINTS,
155− ).pipe(
156− Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
157− Atom.withLabel(`environment-thread-checkpoints:${key}`),
158− ),
139+ ).pipe(Atom.setIdleTTL(0), Atom.withLabel(`environment-thread-checkpoints:${key}`)),
159140 );
160141
161142 const threadSessionAtomFamily = Atom.family((key: string) =>
162143 Atom.make(
163144 (get): OrchestrationSession | null => get(threadDetailAtomFamily(key))?.session ?? null,
164− ).pipe(
165− Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
166− Atom.withLabel(`environment-thread-session:${key}`),
167− ),
145+ ).pipe(Atom.setIdleTTL(0), Atom.withLabel(`environment-thread-session:${key}`)),
168146 );
169147
170148 const threadLatestTurnAtomFamily = Atom.family((key: string) =>
171149 Atom.make(
172150 (get): OrchestrationLatestTurn | null => get(threadDetailAtomFamily(key))?.latestTurn ?? null,
173− ).pipe(
174− Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
175− Atom.withLabel(`environment-thread-latest-turn:${key}`),
176− ),
151+ ).pipe(Atom.setIdleTTL(0), Atom.withLabel(`environment-thread-latest-turn:${key}`)),
177152 );
178153
179154 return {
packages/client-runtime/src/state/threadRetention.ts+3−3
@@ -1,3 +1,3 @@
1−// Mobile thread routes unmount during back navigation. Retain the stream-backed
2−// state across short subscriber gaps without keeping every opened thread alive.
3−export const THREAD_STATE_IDLE_TTL_MS = 5 * 60_000;
1+// Keep recent thread snapshots for back navigation. Live subscriptions end
2+// when the last detail consumer leaves.
3+export const THREAD_SNAPSHOT_IDLE_TTL_MS = 5 * 60_000;
packages/client-runtime/src/state/threads-atoms.test.ts+410−20
@@ -1,26 +1,416 @@
1−import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2−import { describe, expect, it } from "@effect/vitest";
1+import {
2+ EnvironmentId,
3+ EventId,
4+ MessageId,
5+ ORCHESTRATION_WS_METHODS,
6+ ProjectId,
7+ ProviderInstanceId,
8+ ThreadId,
9+ type OrchestrationThread,
10+ type OrchestrationThreadDetailSnapshot,
11+ type OrchestrationThreadStreamItem,
12+} from "@t3tools/contracts";
13+import { afterEach, describe, expect, it, vi } from "@effect/vitest";
14+import * as Deferred from "effect/Deferred";
15+import * as Effect from "effect/Effect";
316 import * as Layer from "effect/Layer";
4−import { Atom } from "effect/unstable/reactivity";
17+import * as Option from "effect/Option";
18+import * as Queue from "effect/Queue";
19+import * as Stream from "effect/Stream";
20+import * as SubscriptionRef from "effect/SubscriptionRef";
21+import { Atom, AtomRegistry } from "effect/unstable/reactivity";
522
6−import type { EnvironmentRegistry } from "../connection/registry.ts";
7−import type { EnvironmentCacheStore } from "../platform/persistence.ts";
8−import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";
9−import { createEnvironmentThreadStateAtoms, type ThreadSnapshotLoader } from "./threads.ts";
23+import type { ConnectionCatalogEntry } from "../connection/catalog.ts";
24+import { EnvironmentRegistry } from "../connection/registry.ts";
25+import {
26+ AVAILABLE_CONNECTION_STATE,
27+ PrimaryConnectionTarget,
28+ type NetworkStatus,
29+ type PreparedConnection,
30+} from "../connection/model.ts";
31+import { EnvironmentSupervisor } from "../connection/supervisor.ts";
32+import { EnvironmentCacheStore } from "../platform/persistence.ts";
33+import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
34+import type { RpcSession } from "../rpc/session.ts";
35+import { createEnvironmentThreadDetailAtoms } from "./threadDetail.ts";
36+import { THREAD_SNAPSHOT_IDLE_TTL_MS } from "./threadRetention.ts";
37+import type { ThreadSnapshotWindow } from "./threadSnapshotHttp.ts";
38+import {
39+ createEnvironmentThreadStateAtoms,
40+ requestOlderThreadTurns,
41+ ThreadSnapshotLoader,
42+ type EnvironmentThreadState,
43+} from "./threads.ts";
44+
45+const TARGET = new PrimaryConnectionTarget({
46+ environmentId: EnvironmentId.make("environment-1"),
47+ label: "Test environment",
48+ httpBaseUrl: "https://environment.example.test",
49+ wsBaseUrl: "wss://environment.example.test",
50+});
51+const THREAD_ID = ThreadId.make("thread-1");
52+const THREAD: OrchestrationThread = {
53+ id: THREAD_ID,
54+ projectId: ProjectId.make("project-1"),
55+ title: "Cached thread",
56+ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
57+ runtimeMode: "full-access",
58+ interactionMode: "default",
59+ branch: "main",
60+ worktreePath: null,
61+ latestTurn: null,
62+ createdAt: "2026-04-01T00:00:00.000Z",
63+ updatedAt: "2026-04-01T00:00:00.000Z",
64+ archivedAt: null,
65+ settledOverride: null,
66+ settledAt: null,
67+ deletedAt: null,
68+ messages: [],
69+ proposedPlans: [],
70+ activities: [],
71+ checkpoints: [],
72+ session: null,
73+};
74+const SNAPSHOT: OrchestrationThreadDetailSnapshot = { snapshotSequence: 7, thread: THREAD };
75+
76+const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options?: {
77+ readonly snapshot?: OrchestrationThreadDetailSnapshot;
78+}) {
79+ const subscriptions = yield* Queue.unbounded<{
80+ readonly afterSequence: number | undefined;
81+ readonly events: Queue.Queue<OrchestrationThreadStreamItem>;
82+ readonly closed: Deferred.Deferred<void>;
83+ }>();
84+ const olderLoads = yield* Queue.unbounded<{
85+ readonly window: ThreadSnapshotWindow;
86+ readonly response: Deferred.Deferred<Option.Option<OrchestrationThreadDetailSnapshot>>;
87+ readonly closed: Deferred.Deferred<void>;
88+ }>();
89+ const snapshot = options?.snapshot ?? SNAPSHOT;
90+ let httpLoads = 0;
91+ let diskLoads = 0;
92+ let opened = 0;
93+ let active = 0;
94+ const client = {
95+ [ORCHESTRATION_WS_METHODS.subscribeThread]: (input: { readonly afterSequence?: number }) =>
96+ Stream.unwrap(
97+ Effect.gen(function* () {
98+ const events = yield* Queue.unbounded<OrchestrationThreadStreamItem>();
99+ const closed = yield* Deferred.make<void>();
100+ yield* Effect.acquireRelease(
101+ Effect.sync(() => {
102+ opened += 1;
103+ active += 1;
104+ }),
105+ () =>
106+ Effect.sync(() => {
107+ active -= 1;
108+ }).pipe(Effect.andThen(Deferred.succeed(closed, undefined))),
109+ );
110+ yield* Queue.offer(subscriptions, { afterSequence: input.afterSequence, events, closed });
111+ return Stream.fromQueue(events);
112+ }),
113+ ),
114+ } as unknown as WsRpcProtocolClient;
115+ const session: RpcSession = {
116+ client,
117+ initialConfig: Effect.succeed({
118+ threadResumeCompletionMarker: true,
119+ threadSnapshotPagination: true,
120+ } as never),
121+ subscribeServerConfig: (input) => client.subscribeServerConfig(input),
122+ ready: Effect.void,
123+ probe: Effect.void,
124+ closed: Effect.never,
125+ };
126+ const supervisor = EnvironmentSupervisor.of({
127+ target: TARGET,
128+ state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE),
129+ session: yield* SubscriptionRef.make(Option.some(session)),
130+ prepared: yield* SubscriptionRef.make<Option.Option<PreparedConnection>>(
131+ Option.some({
132+ environmentId: TARGET.environmentId,
133+ label: TARGET.label,
134+ httpBaseUrl: TARGET.httpBaseUrl,
135+ socketUrl: TARGET.wsBaseUrl,
136+ httpAuthorization: null,
137+ target: TARGET,
138+ }),
139+ ),
140+ connect: Effect.void,
141+ disconnect: Effect.void,
142+ retryNow: Effect.void,
143+ });
144+ const environmentRegistry = EnvironmentRegistry.of({
145+ entries: yield* SubscriptionRef.make<ReadonlyMap<EnvironmentId, ConnectionCatalogEntry>>(
146+ new Map(),
147+ ),
148+ networkStatus: yield* SubscriptionRef.make<NetworkStatus>("online"),
149+ start: Effect.void,
150+ register: () => Effect.die("Unexpected environment registration"),
151+ registerPlatform: () => Effect.die("Unexpected environment registration"),
152+ reconcilePlatform: () => Effect.die("Unexpected environment reconciliation"),
153+ remove: () => Effect.die("Unexpected environment removal"),
154+ removeRelayEnvironments: () => Effect.die("Unexpected environment removal"),
155+ retryNow: () => Effect.void,
156+ state: () => SubscriptionRef.get(supervisor.state),
157+ stateChanges: () => SubscriptionRef.changes(supervisor.state),
158+ run: (_environmentId, effect) =>
159+ Effect.provideService(effect, EnvironmentSupervisor, supervisor),
160+ runStream: (_environmentId, stream) =>
161+ Stream.provideService(stream, EnvironmentSupervisor, supervisor),
162+ followStream: (_environmentId, stream) =>
163+ Stream.provideService(stream, EnvironmentSupervisor, supervisor),
164+ });
165+ const runtime = Atom.runtime(
166+ Layer.mergeAll(
167+ Layer.succeed(EnvironmentRegistry, environmentRegistry),
168+ Layer.succeed(
169+ EnvironmentCacheStore,
170+ EnvironmentCacheStore.of({
171+ loadShell: () => Effect.succeed(Option.none()),
172+ saveShell: () => Effect.void,
173+ loadThread: () =>
174+ Effect.sync(() => {
175+ diskLoads += 1;
176+ return Option.none();
177+ }),
178+ saveThread: () => Effect.void,
179+ removeThread: () => Effect.void,
180+ loadServerConfig: () => Effect.succeed(Option.none()),
181+ saveServerConfig: () => Effect.void,
182+ loadVcsRefs: () => Effect.succeed(Option.none()),
183+ saveVcsRefs: () => Effect.void,
184+ removeVcsRefs: () => Effect.void,
185+ clearVcsRefs: () => Effect.void,
186+ clear: () => Effect.void,
187+ }),
188+ ),
189+ Layer.succeed(
190+ ThreadSnapshotLoader,
191+ ThreadSnapshotLoader.of({
192+ load: (_prepared, _threadId, window) => {
193+ if (window?.beforeCursor === undefined) {
194+ return Effect.sync(() => {
195+ httpLoads += 1;
196+ return Option.some(snapshot);
197+ });
198+ }
199+ return Effect.gen(function* () {
200+ const response =
201+ yield* Deferred.make<Option.Option<OrchestrationThreadDetailSnapshot>>();
202+ const closed = yield* Deferred.make<void>();
203+ yield* Effect.addFinalizer(() => Deferred.succeed(closed, undefined));
204+ yield* Queue.offer(olderLoads, { window, response, closed });
205+ return yield* Deferred.await(response);
206+ }).pipe(Effect.scoped);
207+ },
208+ }),
209+ ),
210+ ),
211+ );
212+ const raw = createEnvironmentThreadStateAtoms(runtime);
213+ const details = createEnvironmentThreadDetailAtoms(raw.stateAtom);
214+ const ref = { environmentId: TARGET.environmentId, threadId: THREAD_ID };
215+ const stateAtom = details.stateAtom(ref);
216+ const makeRegistry = Effect.acquireRelease(
217+ Effect.sync(() => AtomRegistry.make({ defaultIdleTTL: 60_000, timeoutResolution: 1 })),
218+ (registry) => Effect.sync(() => registry.dispose()),
219+ );
220+ const registry = yield* makeRegistry;
221+
222+ return {
223+ registry,
224+ makeRegistry,
225+ rawAtoms: raw,
226+ stateAtom,
227+ details,
228+ ref,
229+ subscriptions,
230+ olderLoads,
231+ counts: () => ({ httpLoads, diskLoads, opened, active }),
232+ };
233+});
234+
235+function observeState(
236+ registry: AtomRegistry.AtomRegistry,
237+ atom: Atom.Atom<EnvironmentThreadState>,
238+ predicate: (state: EnvironmentThreadState) => boolean,
239+) {
240+ return AtomRegistry.toStream(registry, atom).pipe(
241+ Stream.filter(predicate),
242+ Stream.runHead,
243+ Effect.map(Option.getOrThrow),
244+ );
245+}
246+
247+function currentThread(
248+ registry: AtomRegistry.AtomRegistry,
249+ atom: Atom.Atom<EnvironmentThreadState>,
250+) {
251+ return Option.getOrThrow(registry.get(atom).data);
252+}
10253
11254 describe("createEnvironmentThreadStateAtoms", () => {
12− it("retains thread state across short subscriber gaps", () => {
13− const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime<
14− EnvironmentRegistry | EnvironmentCacheStore | ThreadSnapshotLoader,
15− never
16− >;
17− const threads = createEnvironmentThreadStateAtoms(runtime);
18− const environmentId = EnvironmentId.make("environment-1");
19− const threadId = ThreadId.make("thread-1");
20− const atom = threads.stateAtom(environmentId, threadId);
21−
22− expect(atom.idleTTL).toBe(THREAD_STATE_IDLE_TTL_MS);
23− expect(threads.stateAtom(environmentId, threadId)).toBe(atom);
24− expect(threads.stateAtom(environmentId, ThreadId.make("thread-2"))).not.toBe(atom);
255+ afterEach(() => {
256+ vi.useRealTimers();
257+ vi.restoreAllMocks();
25258 });
259+
260+ it.effect("shares one live stream and closes it after the last detail consumer leaves", () =>
261+ Effect.gen(function* () {
262+ const h = yield* makeHarness();
263+ const unmountMessages = h.registry.mount(h.details.messagesAtom(h.ref));
264+ const first = yield* Queue.take(h.subscriptions);
265+ const unmountStatus = h.registry.mount(h.details.statusAtom(h.ref));
266+ expect(h.counts()).toEqual({ httpLoads: 1, diskLoads: 1, opened: 1, active: 1 });
267+ unmountMessages();
268+ yield* Queue.offer(first.events, { kind: "synchronized" });
269+ yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live");
270+ expect(h.counts().active).toBe(1);
271+ unmountStatus();
272+ yield* Deferred.await(first.closed);
273+ expect(h.counts().active).toBe(0);
274+ }),
275+ );
276+
277+ it.effect("keeps warm data and resumes a completed cursor without loading another snapshot", () =>
278+ Effect.gen(function* () {
279+ const h = yield* makeHarness();
280+ const unmount = h.registry.mount(h.stateAtom);
281+ const first = yield* Queue.take(h.subscriptions);
282+ yield* Queue.offer(first.events, {
283+ kind: "event",
284+ event: {
285+ type: "thread.message-sent",
286+ sequence: 8,
287+ eventId: EventId.make("message-1"),
288+ aggregateKind: "thread",
289+ aggregateId: THREAD_ID,
290+ occurredAt: THREAD.createdAt,
291+ commandId: null,
292+ causationEventId: null,
293+ correlationId: null,
294+ metadata: {},
295+ payload: {
296+ threadId: THREAD_ID,
297+ messageId: MessageId.make("message-1"),
298+ role: "assistant",
299+ text: "Retained text",
300+ turnId: null,
301+ streaming: true,
302+ createdAt: THREAD.createdAt,
303+ updatedAt: THREAD.createdAt,
304+ },
305+ },
306+ });
307+ yield* Queue.offer(first.events, { kind: "synchronized" });
308+ yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live");
309+ const before = currentThread(h.registry, h.stateAtom);
310+ unmount();
311+ yield* Deferred.await(first.closed);
312+ const remount = h.registry.mount(h.stateAtom);
313+ expect(currentThread(h.registry, h.stateAtom)).toBe(before);
314+ const next = yield* Queue.take(h.subscriptions);
315+ expect(next.afterSequence).toBe(8);
316+ expect(h.counts()).toEqual({ httpLoads: 1, diskLoads: 1, opened: 2, active: 1 });
317+ remount();
318+ yield* Deferred.await(next.closed);
319+ }),
320+ );
321+
322+ it.effect("keeps warm data when the raw atom family's weak entry is collected", () =>
323+ Effect.gen(function* () {
324+ const h = yield* makeHarness();
325+ const oldRaw = h.rawAtoms.stateAtom(TARGET.environmentId, THREAD_ID);
326+ const unmount = h.registry.mount(h.stateAtom);
327+ const first = yield* Queue.take(h.subscriptions);
328+ const latest = { ...THREAD, title: "Newer cached thread" };
329+ yield* Queue.offer(first.events, {
330+ kind: "snapshot",
331+ snapshot: { snapshotSequence: 8, thread: latest },
332+ });
333+ yield* Queue.offer(first.events, { kind: "synchronized" });
334+ yield* observeState(h.registry, h.stateAtom, (value) => value.status === "live");
335+ unmount();
336+ yield* Deferred.await(first.closed);
337+
338+ // Force the weak-family miss without depending on host GC timing.
339+ const deref = WeakRef.prototype.deref;
340+ vi.spyOn(WeakRef.prototype, "deref").mockImplementation(function (this: WeakRef<object>) {
341+ const value = deref.call(this);
342+ return value === oldRaw ? undefined : value;
343+ });
344+ const remount = h.registry.mount(h.stateAtom);
345+ expect(currentThread(h.registry, h.stateAtom)).toBe(latest);
346+ const next = yield* Queue.take(h.subscriptions);
347+ expect(next.afterSequence).toBe(8);
348+ expect(h.counts()).toEqual({ httpLoads: 1, diskLoads: 1, opened: 2, active: 1 });
349+ remount();
350+ yield* Deferred.await(next.closed);
351+ }),
352+ );
353+
354+ it.effect("keeps cached snapshots local to each registry", () =>
355+ Effect.gen(function* () {
356+ const h = yield* makeHarness();
357+ const unmount = h.registry.mount(h.stateAtom);
358+ const first = yield* Queue.take(h.subscriptions);
359+ unmount();
360+ yield* Deferred.await(first.closed);
361+ const otherRegistry = yield* h.makeRegistry;
362+ const unmountOther = otherRegistry.mount(h.stateAtom);
363+ const other = yield* Queue.take(h.subscriptions);
364+ expect(h.counts()).toEqual({ httpLoads: 2, diskLoads: 2, opened: 2, active: 1 });
365+ unmountOther();
366+ yield* Deferred.await(other.closed);
367+ }),
368+ );
369+
370+ it.effect("expires the plain snapshot after five idle minutes", () =>
371+ Effect.gen(function* () {
372+ vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] });
373+ const h = yield* makeHarness();
374+ const unmount = h.registry.mount(h.stateAtom);
375+ const first = yield* Queue.take(h.subscriptions);
376+ unmount();
377+ yield* Deferred.await(first.closed);
378+ yield* Effect.yieldNow;
379+ yield* Effect.promise(() => vi.advanceTimersByTimeAsync(THREAD_SNAPSHOT_IDLE_TTL_MS + 1));
380+ const remount = h.registry.mount(h.stateAtom);
381+ const next = yield* Queue.take(h.subscriptions);
382+ expect(h.counts()).toEqual({ httpLoads: 2, diskLoads: 2, opened: 2, active: 1 });
383+ remount();
384+ yield* Deferred.await(next.closed);
385+ }),
386+ );
387+
388+ it.effect("cancels older-page work on unmount and permits it again on a warm return", () =>
389+ Effect.gen(function* () {
390+ const h = yield* makeHarness({
391+ snapshot: {
392+ ...SNAPSHOT,
393+ page: { beforeCursor: "older-1", hasMore: true, snapshotSequence: 7 },
394+ },
395+ });
396+ const unmount = h.registry.mount(h.stateAtom);
397+ const first = yield* Queue.take(h.subscriptions);
398+ expect(requestOlderThreadTurns(TARGET.environmentId, THREAD_ID)).toBe(true);
399+ const older = yield* Queue.take(h.olderLoads);
400+ expect(Option.getOrThrow(h.registry.get(h.stateAtom).page).loadingOlder).toBe(true);
401+ unmount();
402+ yield* Deferred.await(first.closed);
403+ yield* Deferred.await(older.closed);
404+ const remount = h.registry.mount(h.stateAtom);
405+ const next = yield* Queue.take(h.subscriptions);
406+ expect(Option.getOrThrow(h.registry.get(h.stateAtom).page).loadingOlder).toBe(false);
407+ expect(requestOlderThreadTurns(TARGET.environmentId, THREAD_ID)).toBe(true);
408+ const retried = yield* Queue.take(h.olderLoads);
409+ expect(retried.window.beforeCursor).toBe("older-1");
410+ expect(h.counts().httpLoads).toBe(1);
411+ remount();
412+ yield* Deferred.await(next.closed);
413+ yield* Deferred.await(retried.closed);
414+ }),
415+ );
26416 });
packages/client-runtime/src/state/threads-sync.test.ts+279−3
@@ -11,10 +11,14 @@ import { · 10 unmodified lines
1111 type OrchestrationThreadStreamItem,
1212 } from "@t3tools/contracts";
1313 import { describe, expect, it } from "@effect/vitest";
14+import * as Deferred from "effect/Deferred";
1415 import * as Effect from "effect/Effect";
16+import * as Exit from "effect/Exit";
17+import * as Fiber from "effect/Fiber";
1518 import * as Option from "effect/Option";
1619 import * as Queue from "effect/Queue";
1720 import * as Ref from "effect/Ref";
21+import * as Scope from "effect/Scope";
1822 import * as Stream from "effect/Stream";
1923 import * as SubscriptionRef from "effect/SubscriptionRef";
2024 import * as TestClock from "effect/testing/TestClock";
@@ -134,6 +138,9 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o · 113 unmodified lines
134138 readonly cached?: OrchestrationThread;
135139 readonly httpSnapshot?: Option.Option<OrchestrationThreadDetailSnapshot>;
136140 readonly completionMarker?: boolean;
141+ readonly resumeCache?: NonNullable<Parameters<typeof makeEnvironmentThreadState>[1]>;
142+ readonly loadCached?: Effect.Effect<Option.Option<OrchestrationThreadDetailSnapshot>>;
143+ readonly saveThread?: Persistence.EnvironmentCacheStore["Service"]["saveThread"];
137144 }) {
138145 const inputs = yield* Queue.unbounded<TestThreadInput>();
139146 const observed = yield* Queue.unbounded<EnvironmentThreadState>();
@@ -202,6 +209,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o · 62 unmodified lines
202209 loadShell: () => Effect.succeed(Option.none()),
203210 saveShell: () => Effect.void,
204211 loadThread: (_environmentId, threadId) =>
212+ options?.loadCached ??
205213 Effect.succeed(
206214 threadId === THREAD_ID && options?.cached !== undefined
207215 ? Option.some({
@@ -210,8 +218,10 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o · 2 unmodified lines
210218 })
211219 : Option.none(),
212220 ),
213− saveThread: (_environmentId, thread) =>
214− Ref.update(savedThreads, (current) => [...current, thread]),
221+ saveThread: (environmentId, thread) =>
222+ Ref.update(savedThreads, (current) => [...current, thread]).pipe(
223+ Effect.andThen(options?.saveThread?.(environmentId, thread) ?? Effect.void),
224+ ),
215225 removeThread: (_environmentId, threadId) =>
216226 Ref.update(removedThreads, (current) => [...current, threadId]),
217227 loadServerConfig: () => Effect.succeed(Option.none()),
@@ -222,7 +232,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o · 4 unmodified lines
222232 clearVcsRefs: () => Effect.void,
223233 clear: () => Effect.void,
224234 });
225− const threadState = yield* makeEnvironmentThreadState(THREAD_ID).pipe(
235+ const threadState = yield* makeEnvironmentThreadState(THREAD_ID, options?.resumeCache).pipe(
226236 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
227237 Effect.provideService(Persistence.EnvironmentCacheStore, cache),
228238 Effect.provideService(ThreadSnapshotLoader, snapshotLoader),
@@ -239,6 +249,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o · 10 unmodified lines
239249 );
240250
241251 return {
252+ threadState,
242253 inputs,
243254 observed,
244255 latest,
@@ -316,6 +327,271 @@ const deleted = (): OrchestrationThreadStreamItem => ({ · 71 unmodified lines
316327 });
317328
318329 describe("EnvironmentThreads", () => {
330+ for (const source of ["disk", "HTTP"] as const) {
331+ it.effect(`does not rewrite an unchanged ${source} snapshot on navigation or warm return`, () =>
332+ Effect.gen(function* () {
333+ const resumeCache: NonNullable<Parameters<typeof makeEnvironmentThreadState>[1]> = {
334+ snapshot: undefined,
335+ owner: undefined,
336+ };
337+ const firstSaved = yield* Effect.scoped(
338+ Effect.gen(function* () {
339+ const h = yield* makeHarness({
340+ resumeCache,
341+ ...(source === "disk"
342+ ? { cached: BASE_THREAD }
343+ : { httpSnapshot: Option.some({ snapshotSequence: 7, thread: BASE_THREAD }) }),
344+ });
345+ yield* awaitThreadState(h.observed, (value) => value.status === "live");
346+ if (source === "HTTP") yield* TestClock.adjust("500 millis");
347+ return h.savedThreads;
348+ }),
349+ );
350+ expect(yield* Ref.get(firstSaved)).toHaveLength(source === "disk" ? 0 : 1);
351+ const nextSaved = yield* Effect.scoped(
352+ Effect.gen(function* () {
353+ const h = yield* makeHarness({ resumeCache });
354+ yield* awaitThreadState(h.observed, (value) => value.status === "live");
355+ return h.savedThreads;
356+ }),
357+ );
358+ expect(yield* Ref.get(nextSaved)).toEqual([]);
359+ }),
360+ );
361+ }
362+
363+ it.effect("retries a failed background cache write when the scope closes", () =>
364+ Effect.gen(function* () {
365+ let attempts = 0;
366+ yield* Effect.scoped(
367+ Effect.gen(function* () {
368+ const h = yield* makeHarness({
369+ httpSnapshot: Option.some({ snapshotSequence: 7, thread: BASE_THREAD }),
370+ saveThread: () =>
371+ Effect.suspend(() => {
372+ attempts += 1;
373+ return attempts === 1
374+ ? Effect.fail(
375+ new Persistence.ConnectionPersistenceError({
376+ operation: "save-thread",
377+ message: "Test storage failure",
378+ }),
379+ )
380+ : Effect.void;
381+ }),
382+ });
383+ yield* awaitThreadState(h.observed, (value) => value.status === "live");
384+ yield* TestClock.adjust("500 millis");
385+ expect(attempts).toBe(1);
386+ }),
387+ );
388+ expect(attempts).toBe(2);
389+ }),
390+ );
391+
392+ it.effect("flushes newer data after an older background write completes", () =>
393+ Effect.gen(function* () {
394+ const writing = yield* Deferred.make<void>();
395+ const written = yield* Deferred.make<void>();
396+ const release = yield* Deferred.make<void>();
397+ const saved = yield* Effect.scoped(
398+ Effect.gen(function* () {
399+ const h = yield* makeHarness({
400+ cached: BASE_THREAD,
401+ saveThread: (_environmentId, snapshot) =>
402+ snapshot.snapshotSequence === 8
403+ ? Deferred.succeed(writing, undefined).pipe(
404+ Effect.andThen(Deferred.await(release)),
405+ Effect.andThen(Deferred.succeed(written, undefined)),
406+ )
407+ : Effect.void,
408+ });
409+ yield* Queue.offer(h.inputs, titleUpdated("First update", 8));
410+ yield* awaitThreadState(
411+ h.observed,
412+ (value) => Option.getOrNull(value.data)?.title === "First update",
413+ );
414+ yield* TestClock.adjust("500 millis");
415+ yield* Deferred.await(writing);
416+ yield* Queue.offer(h.inputs, titleUpdated("Newer update", 9));
417+ yield* awaitThreadState(
418+ h.observed,
419+ (value) => Option.getOrNull(value.data)?.title === "Newer update",
420+ );
421+ yield* Deferred.succeed(release, undefined);
422+ yield* Deferred.await(written);
423+ return h.savedThreads;
424+ }),
425+ );
426+ expect(
427+ (yield* Ref.get(saved)).map((snapshot) => [
428+ snapshot.snapshotSequence,
429+ snapshot.thread.title,
430+ ]),
431+ ).toEqual([
432+ [8, "First update"],
433+ [9, "Newer update"],
434+ ]);
435+ }),
436+ );
437+
438+ it.effect("does not resume past a canceled event whose data was not applied", () =>
439+ Effect.gen(function* () {
440+ const resumeCache: NonNullable<Parameters<typeof makeEnvironmentThreadState>[1]> = {
441+ snapshot: undefined,
442+ owner: undefined,
443+ };
444+ const scope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
445+ Scope.close(scope, Exit.void),
446+ );
447+ const first = yield* makeHarness({
448+ httpSnapshot: Option.some({
449+ thread: BASE_THREAD,
450+ snapshotSequence: CACHED_SNAPSHOT_SEQUENCE,
451+ }),
452+ resumeCache,
453+ }).pipe(Effect.provideService(Scope.Scope, scope));
454+ yield* awaitThreadState(first.observed, (value) => value.status === "live");
455+ const applying = yield* Deferred.make<void>();
456+ const update = titleUpdated("Not applied", 8);
457+ if (update.kind !== "event") return yield* Effect.die("Expected an event");
458+ Object.defineProperty(update.event.payload, "title", {
459+ get: () => {
460+ Deferred.doneUnsafe(applying, Exit.void);
461+ return "Not applied";
462+ },
463+ });
464+ // The reducer reads the title after it advances the cursor. Hold only
465+ // the data write so closing the scope interrupts that exact interval.
466+ yield* first.threadState.semaphore.take(1);
467+ yield* Queue.offer(first.inputs, update);
468+ yield* Deferred.await(applying);
469+ yield* Scope.close(scope, Exit.void);
470+ yield* first.threadState.semaphore.release(1);
471+
472+ const resumed = yield* makeHarness({ resumeCache });
473+ const state = yield* awaitThreadState(resumed.observed, (value) => value.status === "live");
474+ expect(Option.getOrThrow(state.data).title).toBe(BASE_THREAD.title);
475+ expect(yield* Ref.get(resumed.lastSubscribeAfterSequence)).toBe(CACHED_SNAPSHOT_SEQUENCE);
476+ expect(yield* Ref.get(first.savedThreads)).toEqual([
477+ {
478+ thread: BASE_THREAD,
479+ snapshotSequence: CACHED_SNAPSHOT_SEQUENCE,
480+ },
481+ ]);
482+ }),
483+ );
484+
485+ it.effect("prevents an old scope from replacing its successor's cached data", () =>
486+ Effect.gen(function* () {
487+ const resumeCache: NonNullable<Parameters<typeof makeEnvironmentThreadState>[1]> = {
488+ snapshot: undefined,
489+ owner: undefined,
490+ };
491+ const oldScope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
492+ Scope.close(scope, Exit.void),
493+ );
494+ const old = yield* makeHarness({ cached: BASE_THREAD, resumeCache }).pipe(
495+ Effect.provideService(Scope.Scope, oldScope),
496+ );
497+ yield* awaitThreadState(old.observed, (value) => value.status === "live");
498+ const successor = yield* makeHarness({ resumeCache });
499+ yield* Queue.offer(successor.inputs, titleUpdated("Successor title", 9));
500+ yield* awaitThreadState(
501+ successor.observed,
502+ (value) => Option.getOrNull(value.data)?.title === "Successor title",
503+ );
504+ yield* Queue.offer(old.inputs, titleUpdated("Old scope title", 8));
505+ yield* awaitThreadState(
506+ old.observed,
507+ (value) => Option.getOrNull(value.data)?.title === "Old scope title",
508+ );
509+ yield* Scope.close(oldScope, Exit.void);
510+
511+ expect(resumeCache.snapshot?.sequence).toBe(9);
512+ expect(Option.getOrThrow(resumeCache.snapshot!.state.data).title).toBe("Successor title");
513+ expect(yield* Ref.get(old.savedThreads)).toEqual([]);
514+ }),
515+ );
516+
517+ it.effect("does not let a delayed cache read replace an initialized successor", () =>
518+ Effect.gen(function* () {
519+ const resumeCache: NonNullable<Parameters<typeof makeEnvironmentThreadState>[1]> = {
520+ snapshot: undefined,
521+ owner: undefined,
522+ };
523+ const loading = yield* Deferred.make<void>();
524+ const response = yield* Deferred.make<Option.Option<OrchestrationThreadDetailSnapshot>>();
525+ const oldFiber = yield* makeHarness({
526+ resumeCache,
527+ loadCached: Deferred.succeed(loading, undefined).pipe(
528+ Effect.andThen(Deferred.await(response)),
529+ ),
530+ }).pipe(Effect.forkScoped);
531+ yield* Deferred.await(loading);
532+ const successor = yield* makeHarness({ cached: BASE_THREAD, resumeCache });
533+ yield* awaitThreadState(successor.observed, (value) => value.status === "live");
534+ yield* Deferred.succeed(
535+ response,
536+ Option.some({ snapshotSequence: 3, thread: { ...BASE_THREAD, title: "Old disk data" } }),
537+ );
538+ const old = yield* Fiber.join(oldFiber);
539+ yield* awaitThreadState(old.observed, (value) => value.status === "live");
540+
541+ expect(resumeCache.snapshot?.sequence).toBe(CACHED_SNAPSHOT_SEQUENCE);
542+ expect(Option.getOrThrow(resumeCache.snapshot!.state.data).title).toBe(BASE_THREAD.title);
543+ }),
544+ );
545+
546+ it.effect("does not let an old deletion remove its successor's persisted cache", () =>
547+ Effect.gen(function* () {
548+ const resumeCache: NonNullable<Parameters<typeof makeEnvironmentThreadState>[1]> = {
549+ snapshot: undefined,
550+ owner: undefined,
551+ };
552+ const old = yield* makeHarness({ cached: BASE_THREAD, resumeCache });
553+ yield* awaitThreadState(old.observed, (value) => value.status === "live");
554+ const successor = yield* makeHarness({ resumeCache });
555+ yield* Queue.offer(successor.inputs, titleUpdated("Successor title", 9));
556+ yield* awaitThreadState(
557+ successor.observed,
558+ (value) => Option.getOrNull(value.data)?.title === "Successor title",
559+ );
560+ const update = deleted();
561+ if (update.kind !== "event") return yield* Effect.die("Expected an event");
562+ yield* Queue.offer(old.inputs, { ...update, event: { ...update.event, sequence: 8 } });
563+ yield* awaitThreadState(old.observed, (value) => value.status === "deleted");
564+
565+ expect(resumeCache.snapshot?.sequence).toBe(9);
566+ expect(yield* Ref.get(old.removedThreads)).toEqual([]);
567+ }),
568+ );
569+
570+ it.effect("retains a deletion instead of restoring the old disk snapshot", () =>
571+ Effect.gen(function* () {
572+ const resumeCache: NonNullable<Parameters<typeof makeEnvironmentThreadState>[1]> = {
573+ snapshot: undefined,
574+ owner: undefined,
575+ };
576+ yield* Effect.scoped(
577+ Effect.gen(function* () {
578+ const first = yield* makeHarness({ cached: BASE_THREAD, resumeCache });
579+ const update = deleted();
580+ if (update.kind !== "event") return yield* Effect.die("Expected an event");
581+ yield* Queue.offer(first.inputs, { ...update, event: { ...update.event, sequence: 8 } });
582+ yield* awaitThreadState(first.observed, (value) => value.status === "deleted");
583+ }),
584+ );
585+ const resumed = yield* makeHarness({ cached: BASE_THREAD, resumeCache });
586+ const state = yield* awaitThreadState(
587+ resumed.observed,
588+ (value) => value.status === "deleted",
589+ );
590+ expect(Option.isNone(state.data)).toBe(true);
591+ expect(yield* Ref.get(resumed.loaderCalls)).toBe(0);
592+ }),
593+ );
594+
319595 it.effect("publishes cached data immediately from a warm cache", () =>
320596 Effect.gen(function* () {
321597 const harness = yield* makeHarness({ cached: BASE_THREAD });
packages/client-runtime/src/state/threads.ts+194−68
@@ -27,7 +27,7 @@ import { subscribeDynamic } from "../rpc/client.ts"; · 26 unmodified lines
2727 import { ThreadSnapshotLoader, type ThreadSnapshotWindow } from "./threadSnapshotHttp.ts";
2828 import { parseThreadKey, threadKey } from "./entities.ts";
2929 import { applyThreadDetailEvent } from "./threadReducer.ts";
30−import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";
30+import { THREAD_SNAPSHOT_IDLE_TTL_MS } from "./threadRetention.ts";
3131 import { followStreamInEnvironment } from "./runtime.ts";
3232 import {
3333 EMPTY_ENVIRONMENT_THREAD_STATE,
@@ -131,40 +131,93 @@ function shouldPersistThread(thread: OrchestrationThread): boolean { · 97 unmodified lines
131131 return status !== "starting" && status !== "running";
132132 }
133133
134+interface ThreadResumeSnapshot {
135+ readonly state: EnvironmentThreadState;
136+ readonly sequence: number;
137+ readonly persisted: boolean;
138+}
139+
140+interface ThreadResumeCache {
141+ snapshot: ThreadResumeSnapshot | undefined;
142+ owner: object | undefined;
143+}
144+
145+function matchesThreadSnapshot(
146+ current: ThreadResumeSnapshot,
147+ thread: OrchestrationThread | null,
148+ sequence: number,
149+ page: Pick<EnvironmentThreadPageState, "beforeCursor" | "hasMore"> | undefined,
150+): boolean {
151+ if (current.sequence !== sequence || Option.getOrNull(current.state.data) !== thread)
152+ return false;
153+ const currentPage = Option.getOrUndefined(current.state.page);
154+ return currentPage === undefined
155+ ? page === undefined
156+ : page !== undefined &&
157+ currentPage.beforeCursor === page.beforeCursor &&
158+ currentPage.hasMore === page.hasMore;
159+}
160+
161+function cachedThreadState(value: EnvironmentThreadState): EnvironmentThreadState {
162+ return {
163+ ...value,
164+ status: value.status === "deleted" ? "deleted" : statusWithoutLiveData(value.data),
165+ error: Option.none(),
166+ page: Option.map(value.page, (page) => ({ ...page, loadingOlder: false })),
167+ };
168+}
169+
134170 export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make")(function* (
135171 threadId: ThreadIdType,
172+ resumeCache?: ThreadResumeCache,
136173 ) {
137174 const supervisor = yield* EnvironmentSupervisor;
138175 const cache = yield* EnvironmentCacheStore;
139176 const snapshotLoader = yield* ThreadSnapshotLoader;
140177 const wakeups = yield* Effect.serviceOption(ConnectionWakeups.ConnectionWakeups);
141178 const environmentId = supervisor.target.environmentId;
142− const cached = yield* cache.loadThread(environmentId, threadId).pipe(
143− Effect.catch((error) =>
144− Effect.logWarning("Could not load cached thread.").pipe(
145− Effect.annotateLogs({
146− environmentId,
147− threadId,
148− error: error.message,
149− }),
150− Effect.as(Option.none<OrchestrationThreadDetailSnapshot>()),
151− ),
152− ),
153− );
179+ const retained = resumeCache?.snapshot;
180+ const owner = {};
181+ if (resumeCache) resumeCache.owner = owner;
182+ const cached =
183+ retained === undefined
184+ ? yield* cache.loadThread(environmentId, threadId).pipe(
185+ Effect.catch((error) =>
186+ Effect.logWarning("Could not load cached thread.").pipe(
187+ Effect.annotateLogs({
188+ environmentId,
189+ threadId,
190+ error: error.message,
191+ }),
192+ Effect.as(Option.none<OrchestrationThreadDetailSnapshot>()),
193+ ),
194+ ),
195+ )
196+ : Option.none<OrchestrationThreadDetailSnapshot>();
154197 const cachedThread = Option.map(cached, (snapshot) => snapshot.thread);
155− const state = yield* SubscriptionRef.make<EnvironmentThreadState>({
156− data: cachedThread,
157− status: statusWithoutLiveData(cachedThread),
158− error: Option.none(),
159− // A cached windowed snapshot restores its page cursor so "load earlier"
160− // works while rendering from cache; a cached full snapshot has no page.
161− page: Option.flatMap(cached, (snapshot) => pageStateFromSnapshot(snapshot.page)),
162− });
198+ const initialState: EnvironmentThreadState = retained
199+ ? cachedThreadState(retained.state)
200+ : {
201+ data: cachedThread,
202+ status: statusWithoutLiveData(cachedThread),
203+ error: Option.none(),
204+ // A cached windowed snapshot restores its page cursor so "load earlier"
205+ // works while rendering from cache; a cached full snapshot has no page.
206+ page: Option.flatMap(cached, (snapshot) => pageStateFromSnapshot(snapshot.page)),
207+ };
208+ const state = yield* SubscriptionRef.make(initialState);
163209 // Seed the resume cursor from the cached snapshot so a warm cache can catch up
164210 // via `afterSequence` instead of re-downloading the full thread body.
165− const lastSequence = yield* SubscriptionRef.make(
166− Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence }),
167− );
211+ const initialSequence =
212+ retained?.sequence ??
213+ Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence });
214+ const lastSequence = yield* SubscriptionRef.make(initialSequence);
215+ let committed: ThreadResumeSnapshot = {
216+ state: initialState,
217+ sequence: initialSequence,
218+ persisted: retained?.persisted ?? Option.isSome(cached),
219+ };
220+ if (resumeCache?.owner === owner) resumeCache.snapshot = committed;
168221 const awaitingCompletion = yield* Ref.make(false);
169222 // Bumped whenever loaded history may have been rewritten out from under an
170223 // in-flight older-page fetch (snapshot replacement, revert, deletion). A
@@ -174,6 +227,25 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 3 unmodified lines
174227 // merges. Without it, a revert or snapshot processed between loadOlderTurns'
175228 // epoch check and its merge could still slip resurrected history in.
176229 const applyLock = yield* Semaphore.make(1);
230+ // Save only completed data/cursor updates. A canceled scope must not cache
231+ // a cursor whose event has not reached the data yet.
232+ const remember = Effect.gen(function* () {
233+ const current = yield* SubscriptionRef.get(state);
234+ const sequence = yield* SubscriptionRef.get(lastSequence);
235+ committed = {
236+ state: current,
237+ sequence,
238+ persisted:
239+ committed.persisted &&
240+ matchesThreadSnapshot(
241+ committed,
242+ Option.getOrNull(current.data),
243+ sequence,
244+ Option.getOrUndefined(current.page),
245+ ),
246+ };
247+ if (resumeCache?.owner === owner) resumeCache.snapshot = committed;
248+ });
177249 // Whether the connected server accepts windowed reads; set per subscription
178250 // from the session config. Gates loadOlderTurns so a reconnect to a
179251 // pre-pagination server never sends unsupported window parameters.
@@ -190,7 +262,28 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 10 unmodified lines
190262 const persist = Effect.fn("EnvironmentThreadState.persist")(function* (
191263 snapshot: OrchestrationThreadDetailSnapshot,
192264 ) {
265+ if (resumeCache !== undefined && resumeCache.owner !== owner) return;
266+ if (
267+ committed.persisted &&
268+ matchesThreadSnapshot(committed, snapshot.thread, snapshot.snapshotSequence, snapshot.page)
269+ )
270+ return;
193271 yield* cache.saveThread(environmentId, snapshot).pipe(
272+ Effect.tap(() =>
273+ Effect.sync(() => {
274+ if (
275+ !matchesThreadSnapshot(
276+ committed,
277+ snapshot.thread,
278+ snapshot.snapshotSequence,
279+ snapshot.page,
280+ )
281+ )
282+ return;
283+ committed = { ...committed, persisted: true };
284+ if (resumeCache?.owner === owner) resumeCache.snapshot = committed;
285+ }),
286+ ),
194287 Effect.catch((error) =>
195288 Effect.logWarning("Could not persist the thread cache.").pipe(
196289 Effect.annotateLogs({
@@ -300,6 +393,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 103 unmodified lines
300393 error: Option.none(),
301394 page: Option.none(),
302395 });
396+ yield* remember;
397+ if (resumeCache !== undefined && resumeCache.owner !== owner) return;
303398 yield* cache.removeThread(environmentId, threadId).pipe(
304399 Effect.catch((error) =>
305400 Effect.logWarning("Could not remove the cached thread.").pipe(
@@ -402,7 +497,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 96 unmodified lines
402497 const applyItem = Effect.fn("EnvironmentThreadState.applyItem")(function* (
403498 item: OrchestrationThreadStreamItem,
404499 ) {
405− yield* applyLock.withPermits(1)(applyItemLocked(item));
500+ yield* applyLock.withPermits(1)(applyItemLocked(item).pipe(Effect.andThen(remember)));
406501 });
407502
408503 // Merges an older disjoint page below the currently loaded window. All four
@@ -459,6 +554,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 50 unmodified lines
459554 ...(snapshot.page === undefined ? {} : { page: { ...snapshot.page, snapshotSequence } }),
460555 });
461556 }
557+ yield* remember;
462558 });
463559
464560 const loadOlderTurns = Effect.fn("EnvironmentThreadState.loadOlderTurns")(function* () {
@@ -579,15 +675,21 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 114 unmodified lines
579675 // trap: afterSequence resume keeps only the window, and the missing
580676 // older turns can never be loaded (the server has no cursor reads).
581677 // Drop the window marker and treat the data as needing a full reload.
582− if (!supportsPagination && Option.isSome(current.page)) {
583− yield* Ref.update(historyEpoch, (epoch) => epoch + 1);
584− yield* SubscriptionRef.update(state, (value) => ({
585− ...value,
586− data: Option.none(),
587− status: value.status === "deleted" ? value.status : ("empty" as const),
588− page: Option.none(),
589− }));
590− yield* SubscriptionRef.set(lastSequence, 0);
678+ if (!supportsPagination) {
679+ yield* applyLock.withPermits(1)(
680+ Effect.gen(function* () {
681+ if (Option.isNone((yield* SubscriptionRef.get(state)).page)) return;
682+ yield* Ref.update(historyEpoch, (epoch) => epoch + 1);
683+ yield* SubscriptionRef.update(state, (value) => ({
684+ ...value,
685+ data: Option.none(),
686+ status: value.status === "deleted" ? value.status : ("empty" as const),
687+ page: Option.none(),
688+ }));
689+ yield* SubscriptionRef.set(lastSequence, 0);
690+ yield* remember;
691+ }),
692+ );
591693 current = yield* SubscriptionRef.get(state);
592694 }
593695 if (Option.isNone(current.data) && current.status !== "deleted") {
@@ -663,40 +765,45 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 69 unmodified lines
663765 yield* Effect.addFinalizer(() => Effect.sync(deregister));
664766
665767 yield* Effect.addFinalizer(() =>
666− Effect.all([SubscriptionRef.get(state), SubscriptionRef.get(lastSequence)]).pipe(
667− Effect.flatMap(([current, snapshotSequence]) =>
668− Option.match(current.data, {
669− onNone: () => Effect.void,
670− onSome: (thread) =>
671− shouldPersistThread(thread)
672− ? persist({
673− snapshotSequence,
674− thread,
675− ...Option.match(current.page, {
676− onNone: () => ({}),
677− onSome: (page) =>
678− ({
679− page: {
680− beforeCursor: page.beforeCursor,
681− hasMore: page.hasMore,
682− snapshotSequence,
683− },
684− }) as const,
685− }),
686− })
687− : Effect.void,
688− }),
689− ),
690− ),
768+ Effect.suspend(() => {
769+ const { state: current, sequence: snapshotSequence } = committed;
770+ return Option.match(current.data, {
771+ onNone: () => Effect.void,
772+ onSome: (thread) =>
773+ shouldPersistThread(thread)
774+ ? persist({
775+ snapshotSequence,
776+ thread,
777+ ...Option.match(current.page, {
778+ onNone: () => ({}),
779+ onSome: (page) =>
780+ ({
781+ page: {
782+ beforeCursor: page.beforeCursor,
783+ hasMore: page.hasMore,
784+ snapshotSequence,
785+ },
786+ }) as const,
787+ }),
788+ })
789+ : Effect.void,
790+ });
791+ }),
691792 );
692793
693794 return state;
694795 });
695796
696−export function threadStateChanges(environmentId: EnvironmentIdType, threadId: ThreadIdType) {
797+export function threadStateChanges(
798+ environmentId: EnvironmentIdType,
799+ threadId: ThreadIdType,
800+ resumeCache?: ThreadResumeCache,
801+) {
697802 return followStreamInEnvironment(
698803 environmentId,
699− Stream.unwrap(makeEnvironmentThreadState(threadId).pipe(Effect.map(SubscriptionRef.changes))),
804+ Stream.unwrap(
805+ makeEnvironmentThreadState(threadId, resumeCache).pipe(Effect.map(SubscriptionRef.changes)),
806+ ),
700807 );
701808 }
702809
@@ -706,16 +813,35 @@ export function createEnvironmentThreadStateAtoms<R, E>( · 3 unmodified lines
706813 E
707814 >,
708815 ) {
816+ // Cache definitions must outlive collectible live-atom definitions. The
817+ // registry retains these nodes without retaining environment or RPC scopes.
818+ const resumeFamily = Atom.family((key: string) =>
819+ Atom.make((): ThreadResumeCache => ({
820+ snapshot: undefined,
821+ owner: undefined,
822+ })).pipe(
823+ Atom.setIdleTTL(THREAD_SNAPSHOT_IDLE_TTL_MS),
824+ Atom.withLabel(`environment-thread-resume:${key}`),
825+ ),
826+ );
709827 const family = Atom.family((key: string) => {
710828 const { environmentId, threadId } = parseThreadKey(key);
829+ const resumeAtom = resumeFamily(key);
711830 return runtime
712− .atom(threadStateChanges(environmentId, threadId), {
713− initialValue: EMPTY_ENVIRONMENT_THREAD_STATE,
714− })
715− .pipe(
716− Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
717− Atom.withLabel(`environment-thread-state:${key}`),
718− );
831+ .atom(
832+ (get) => {
833+ get.mount(resumeAtom);
834+ const resume = get.once(resumeAtom);
835+ const live = threadStateChanges(environmentId, threadId, resume);
836+ return resume.snapshot === undefined
837+ ? live
838+ : Stream.concat(Stream.succeed(cachedThreadState(resume.snapshot.state)), live);
839+ },
840+ {
841+ initialValue: EMPTY_ENVIRONMENT_THREAD_STATE,
842+ },
843+ )
844+ .pipe(Atom.setIdleTTL(0), Atom.withLabel(`environment-thread-state:${key}`));
719845 });
720846
721847 return {