authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-19 13:15:19-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-19 13:15:19-07:00
logcdaba7fa8b9c94dba783b7161e265a6db03b3d93
treed2ccc4c5b00cf9a20ff6dc2068da76028a601484
parentfbf6263876fd9b6f8bcbbf6b9ea097ed8fc7cb60
signature Signed by PGP key B5690EEEBB952194

Share thread state idle TTL across client atoms (#3163)


4 files changed, 49 insertions(+), 14 deletions(-)

packages/client-runtime/src/state/threadDetail.ts+11-11
......@@ -15,12 +15,12 @@ import type { EnvironmentThread, EnvironmentThreadShell } from "./models.ts";
1515import { scopeThread } from "./models.ts";
1616import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threads.ts";
1717import { parseThreadKey, threadKey } from "./entities.ts";
18import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";
1819
1920const EMPTY_MESSAGES: ReadonlyArray<OrchestrationMessage> = Object.freeze([]);
2021const EMPTY_ACTIVITIES: ReadonlyArray<OrchestrationThreadActivity> = Object.freeze([]);
2122const EMPTY_PROPOSED_PLANS: ReadonlyArray<OrchestrationProposedPlan> = Object.freeze([]);
2223const EMPTY_CHECKPOINTS: ReadonlyArray<OrchestrationCheckpointSummary> = Object.freeze([]);
23const THREAD_DETAIL_IDLE_TTL_MS = 5 * 60_000;
2424
2525/**
2626 * Combine detail-only collections with the shell's authoritative thread metadata.
......@@ -75,7 +75,7 @@ export function createEnvironmentThreadDetailAtoms<E>(
7575 () => EMPTY_ENVIRONMENT_THREAD_STATE,
7676 ),
7777 ).pipe(
78 Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS),
78 Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
7979 Atom.withLabel(`environment-thread-state-value:${key}`),
8080 );
8181 });
......@@ -93,21 +93,21 @@ export function createEnvironmentThreadDetailAtoms<E>(
9393 previousValue = source === null ? null : scopeThread(ref.environmentId, source);
9494 return previousValue;
9595 }).pipe(
96 Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS),
96 Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
9797 Atom.withLabel(`environment-thread-detail:${key}`),
9898 );
9999 });
100100
101101 const threadStatusAtomFamily = Atom.family((key: string) =>
102102 Atom.make((get) => get(threadStateValueAtomFamily(key)).status).pipe(
103 Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS),
103 Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
104104 Atom.withLabel(`environment-thread-status:${key}`),
105105 ),
106106 );
107107
108108 const threadErrorAtomFamily = Atom.family((key: string) =>
109109 Atom.make((get) => Option.getOrNull(get(threadStateValueAtomFamily(key)).error)).pipe(
110 Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS),
110 Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
111111 Atom.withLabel(`environment-thread-error:${key}`),
112112 ),
113113 );
......@@ -117,7 +117,7 @@ export function createEnvironmentThreadDetailAtoms<E>(
117117 (get): ReadonlyArray<OrchestrationMessage> =>
118118 get(threadDetailAtomFamily(key))?.messages ?? EMPTY_MESSAGES,
119119 ).pipe(
120 Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS),
120 Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
121121 Atom.withLabel(`environment-thread-messages:${key}`),
122122 ),
123123 );
......@@ -127,7 +127,7 @@ export function createEnvironmentThreadDetailAtoms<E>(
127127 (get): ReadonlyArray<OrchestrationThreadActivity> =>
128128 get(threadDetailAtomFamily(key))?.activities ?? EMPTY_ACTIVITIES,
129129 ).pipe(
130 Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS),
130 Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
131131 Atom.withLabel(`environment-thread-activities:${key}`),
132132 ),
133133 );
......@@ -137,7 +137,7 @@ export function createEnvironmentThreadDetailAtoms<E>(
137137 (get): ReadonlyArray<OrchestrationProposedPlan> =>
138138 get(threadDetailAtomFamily(key))?.proposedPlans ?? EMPTY_PROPOSED_PLANS,
139139 ).pipe(
140 Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS),
140 Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
141141 Atom.withLabel(`environment-thread-proposed-plans:${key}`),
142142 ),
143143 );
......@@ -147,7 +147,7 @@ export function createEnvironmentThreadDetailAtoms<E>(
147147 (get): ReadonlyArray<OrchestrationCheckpointSummary> =>
148148 get(threadDetailAtomFamily(key))?.checkpoints ?? EMPTY_CHECKPOINTS,
149149 ).pipe(
150 Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS),
150 Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
151151 Atom.withLabel(`environment-thread-checkpoints:${key}`),
152152 ),
153153 );
......@@ -156,7 +156,7 @@ export function createEnvironmentThreadDetailAtoms<E>(
156156 Atom.make(
157157 (get): OrchestrationSession | null => get(threadDetailAtomFamily(key))?.session ?? null,
158158 ).pipe(
159 Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS),
159 Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
160160 Atom.withLabel(`environment-thread-session:${key}`),
161161 ),
162162 );
......@@ -165,7 +165,7 @@ export function createEnvironmentThreadDetailAtoms<E>(
165165 Atom.make(
166166 (get): OrchestrationLatestTurn | null => get(threadDetailAtomFamily(key))?.latestTurn ?? null,
167167 ).pipe(
168 Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS),
168 Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
169169 Atom.withLabel(`environment-thread-latest-turn:${key}`),
170170 ),
171171 );
packages/client-runtime/src/state/threadRetention.ts created+3
......@@ -0,0 +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.
3export const THREAD_STATE_IDLE_TTL_MS = 5 * 60_000;
packages/client-runtime/src/state/threads-atoms.test.ts created+26
......@@ -0,0 +1,26 @@
1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";
3import * as Layer from "effect/Layer";
4import { Atom } from "effect/unstable/reactivity";
5
6import type { EnvironmentRegistry } from "../connection/registry.ts";
7import type { EnvironmentCacheStore } from "../platform/persistence.ts";
8import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";
9import { createEnvironmentThreadStateAtoms } from "./threads.ts";
10
11describe("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,
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);
25 });
26});
packages/client-runtime/src/state/threads.ts+9-3
......@@ -21,6 +21,7 @@ import { EnvironmentSupervisor } from "../connection/supervisor.ts";
2121import { EnvironmentCacheStore } from "../platform/persistence.ts";
2222import { subscribe } from "../rpc/client.ts";
2323import { applyThreadDetailEvent } from "./threadReducer.ts";
24import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";
2425import { followStreamInEnvironment } from "./runtime.ts";
2526
2627export type EnvironmentThreadStatus = "empty" | "cached" | "synchronizing" | "live" | "deleted";
......@@ -249,9 +250,14 @@ export function createEnvironmentThreadStateAtoms<R, E>(
249250) {
250251 const family = Atom.family((key: string) => {
251252 const { environmentId, threadId } = parseThreadAtomKey(key);
252 return runtime.atom(threadStateChanges(environmentId, threadId), {
253 initialValue: EMPTY_ENVIRONMENT_THREAD_STATE,
254 });
253 return runtime
254 .atom(threadStateChanges(environmentId, threadId), {
255 initialValue: EMPTY_ENVIRONMENT_THREAD_STATE,
256 })
257 .pipe(
258 Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
259 Atom.withLabel(`environment-thread-state:${key}`),
260 );
255261 });
256262
257263 return {