authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-20 11:54:59+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-20 11:54:59+02:00
log8e3467fe60c49ee739b278229156108032b72e25
tree5b44e2488e27969f366526faab7213f06f2a8922
parent2fae0d0ac300353be774c9f9fe79d3af025d4f91
signature Signed by PGP key B5690EEEBB952194

Synchronize mobile threads with authoritative shell snapshots (#4163)

Co-authored-by: codex <codex@users.noreply.github.com>

17 files changed, 779 insertions(+), 233 deletions(-)

apps/mobile/src/Stack.tsx+1-3
......@@ -15,7 +15,6 @@ import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "re
1515import { useResolveClassNames } from "uniwind";
1616
1717import { AppText as Text } from "./components/AppText";
18import { renderCompactBrandTitle } from "./components/CompactBrandTitle";
1918import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen";
2019import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation";
2120import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent";
......@@ -378,8 +377,7 @@ export const RootStack = createNativeStackNavigator({
378377 ...GLASS_HEADER_OPTIONS,
379378 contentStyle: { backgroundColor: "transparent" },
380379 headerBackVisible: false,
381 headerTitle: renderCompactBrandTitle,
382 title: "T3 Code",
380 title: "Threads",
383381 },
384382 }),
385383 Thread: createNativeStackScreen({
apps/mobile/src/components/CompactBrandTitle.tsx deleted-60
......@@ -1,60 +0,0 @@
1import { View } from "react-native";
2
3import { AppText as Text } from "./AppText";
4import { T3Wordmark } from "./T3Wordmark";
5import { useThemeColor } from "../lib/useThemeColor";
6
7/**
8 * Compact brand lockup sized for native navigation bars.
9 */
10export function CompactBrandTitle() {
11 const iconColor = useThemeColor("--color-icon");
12 const mutedColor = useThemeColor("--color-foreground-muted");
13 const subtleColor = useThemeColor("--color-subtle");
14
15 return (
16 <View
17 aria-level={1}
18 accessibilityLabel="T3 Code"
19 accessible
20 role="heading"
21 style={{ alignItems: "center", flexDirection: "row", gap: 6 }}
22 >
23 <T3Wordmark color={iconColor} height={11} />
24 <Text
25 style={{
26 color: mutedColor,
27 fontFamily: "DMSans-Medium",
28 fontSize: 14,
29 letterSpacing: -0.35,
30 }}
31 >
32 Code
33 </Text>
34 <View
35 style={{
36 backgroundColor: subtleColor,
37 borderRadius: 999,
38 paddingHorizontal: 6,
39 paddingVertical: 2,
40 }}
41 >
42 <Text
43 style={{
44 color: mutedColor,
45 fontFamily: "DMSans-Bold",
46 fontSize: 9,
47 letterSpacing: 0.9,
48 textTransform: "uppercase",
49 }}
50 >
51 Alpha
52 </Text>
53 </View>
54 </View>
55 );
56}
57
58export function renderCompactBrandTitle() {
59 return <CompactBrandTitle />;
60}
apps/mobile/src/features/home/HomeRouteScreen.tsx+1-4
......@@ -4,7 +4,6 @@ import { useNavigation } from "@react-navigation/native";
44import { useMemo, useState } from "react";
55
66import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
7import { renderCompactBrandTitle } from "../../components/CompactBrandTitle";
87import { useProjects, useThreadShells } from "../../state/entities";
98import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
109import { useWorkspaceState } from "../../state/workspace";
......@@ -87,9 +86,7 @@ export function HomeRouteScreen() {
8786 >
8887 <>
8988 {/* Restore the compact title in case the split branch blanked it. */}
90 <NativeStackScreenOptions
91 options={{ title: "T3 Code", headerTitle: renderCompactBrandTitle }}
92 />
89 <NativeStackScreenOptions options={{ title: "Threads", headerTitle: "Threads" }} />
9390 <HomeHeader
9491 environments={environments}
9592 searchQuery={searchQuery}
apps/mobile/src/features/home/HomeScreen.tsx+10-1
......@@ -399,11 +399,20 @@ export function HomeScreen(props: HomeScreenProps) {
399399 onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined}
400400 variant="plain"
401401 />
402 {emptyState.loading ? (
402 {emptyState.loading && !shouldShowConnectionStatus ? (
403403 <View className="mt-4 items-center">
404404 <ActivityIndicator color={accentColor} />
405405 </View>
406406 ) : null}
407 {shouldShowConnectionStatus && Platform.OS === "ios" ? (
408 <View className="mt-4">
409 <WorkspaceConnectionStatus
410 state={props.catalogState}
411 onPress={props.onOpenEnvironments}
412 variant="sidebar"
413 />
414 </View>
415 ) : null}
407416 </View>
408417 {connectionStatus}
409418 </View>
apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts+17
......@@ -67,4 +67,21 @@ describe("workspace connection status", () => {
6767 expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true);
6868 expect(workspaceConnectionStatusLabel(state)).toBe("Could not reach Julius’s Mac mini");
6969 });
70
71 it("shows shell catch-up while cached threads remain visible", () => {
72 const state = workspaceState({ hasPendingShellSnapshot: true });
73
74 expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true);
75 expect(workspaceConnectionStatusLabel(state)).toBe("Syncing threads...");
76 });
77
78 it("distinguishes initial shell loading from cached catch-up", () => {
79 const state = workspaceState({
80 hasLoadedShellSnapshot: false,
81 hasPendingShellSnapshot: true,
82 });
83
84 expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true);
85 expect(workspaceConnectionStatusLabel(state)).toBe("Loading threads...");
86 });
7087});
apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx+5-2
......@@ -12,7 +12,10 @@ export function WorkspaceConnectionStatus(props: {
1212 readonly variant?: "floating" | "sidebar";
1313}) {
1414 const iconColor = useThemeColor("--color-icon-muted");
15 const isReconnecting = props.state.connectingEnvironments.length > 0;
15 const isSynchronizing =
16 props.state.networkStatus !== "offline" &&
17 props.state.connectionError === null &&
18 (props.state.connectingEnvironments.length > 0 || props.state.hasPendingShellSnapshot);
1619 const variant = props.variant ?? "floating";
1720
1821 return (
......@@ -37,7 +40,7 @@ export function WorkspaceConnectionStatus(props: {
3740 : undefined
3841 }
3942 >
40 {isReconnecting ? (
43 {isSynchronizing ? (
4144 <ActivityIndicator color={iconColor} size="small" />
4245 ) : (
4346 <SymbolView name="wifi.slash" size={15} tintColor={iconColor} type="monochrome" />
apps/mobile/src/features/home/workspace-connection-status.ts+4
......@@ -5,6 +5,7 @@ export function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): bool
55 state.networkStatus === "offline" ||
66 state.connectionError !== null ||
77 state.hasConnectingEnvironment ||
8 state.hasPendingShellSnapshot ||
89 (state.hasLoadedShellSnapshot && !state.hasReadyEnvironment)
910 );
1011}
......@@ -18,5 +19,8 @@ export function workspaceConnectionStatusLabel(state: WorkspaceState): string {
1819 return `Reconnecting ${state.connectingEnvironments.length} environments`;
1920 }
2021 if (state.connectionError !== null) return state.connectionError;
22 if (state.hasPendingShellSnapshot) {
23 return state.hasLoadedShellSnapshot ? "Syncing threads..." : "Loading threads...";
24 }
2125 return "Not connected";
2226}
apps/mobile/src/features/threads/sidebar-navigation-shell.tsx+1-3
......@@ -11,7 +11,6 @@ import {
1111import type { ReactNode } from "react";
1212import { Platform, useColorScheme } from "react-native";
1313
14import { renderCompactBrandTitle } from "../../components/CompactBrandTitle";
1514import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
1615
1716const SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
......@@ -35,11 +34,10 @@ const SIDEBAR_SCREEN_OPTIONS: SidebarScreenOptions = {
3534 headerShadowVisible: false,
3635 headerShown: true,
3736 headerStyle: { backgroundColor: "transparent" },
38 headerTitle: renderCompactBrandTitle,
3937 headerTitleStyle: { fontSize: 18, fontWeight: "800" },
4038 headerTransparent: true,
4139 scrollEdgeEffects: SCROLL_EDGE_EFFECTS,
42 title: "T3 Code",
40 title: "Threads",
4341 unstable_navigationItemStyle: "editor",
4442};
4543
apps/server/src/server.test.ts+109
......@@ -3717,6 +3717,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
37173717
37183718 assert.equal(response.environment.environmentId, testEnvironmentDescriptor.environmentId);
37193719 assert.equal(response.auth.policy, "desktop-managed-local");
3720 assert.equal(response.shellResumeCompletionMarker, true);
3721 assert.equal(response.threadResumeCompletionMarker, true);
37203722 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
37213723 );
37223724
......@@ -5607,6 +5609,113 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
56075609 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
56085610 );
56095611
5612 it.effect("marks an empty shell catch-up replay as synchronized when requested", () =>
5613 Effect.gen(function* () {
5614 yield* buildAppUnderTest({
5615 layers: {
5616 orchestrationEngine: {
5617 readEvents: () => Stream.empty,
5618 },
5619 },
5620 });
5621
5622 const wsUrl = yield* getWsServerUrl("/ws");
5623 const firstItem = yield* Effect.scoped(
5624 withWsRpcClient(wsUrl, (client) =>
5625 client[ORCHESTRATION_WS_METHODS.subscribeShell]({
5626 afterSequence: 0,
5627 requestCompletionMarker: true,
5628 }).pipe(Stream.runHead),
5629 ),
5630 );
5631
5632 assert.deepEqual(Option.getOrThrow(firstItem), { kind: "synchronized" });
5633 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
5634 );
5635
5636 it.effect("marks a socket thread snapshot as synchronized when requested", () =>
5637 Effect.gen(function* () {
5638 const thread = makeDefaultOrchestrationReadModel().threads[0]!;
5639 yield* buildAppUnderTest({
5640 layers: {
5641 projectionSnapshotQuery: {
5642 getThreadDetailSnapshot: () =>
5643 Effect.succeed(Option.some({ snapshotSequence: 1, thread })),
5644 },
5645 },
5646 });
5647
5648 const wsUrl = yield* getWsServerUrl("/ws");
5649 const items = yield* Effect.scoped(
5650 withWsRpcClient(wsUrl, (client) =>
5651 client[ORCHESTRATION_WS_METHODS.subscribeThread]({
5652 threadId: defaultThreadId,
5653 requestCompletionMarker: true,
5654 }).pipe(Stream.take(2), Stream.runCollect),
5655 ),
5656 );
5657
5658 assert.equal(items[0]?.kind, "snapshot");
5659 assert.deepEqual(items[1], { kind: "synchronized" });
5660 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
5661 );
5662
5663 it.effect("buffers shell events published while the fallback snapshot loads", () =>
5664 Effect.gen(function* () {
5665 const liveEvents = yield* PubSub.unbounded<OrchestrationEvent>();
5666 const deletedEvent = {
5667 sequence: 2,
5668 eventId: EventId.make("event-shell-thread-deleted"),
5669 aggregateKind: "thread",
5670 aggregateId: defaultThreadId,
5671 occurredAt: "2026-01-01T00:00:01.000Z",
5672 commandId: null,
5673 causationEventId: null,
5674 correlationId: null,
5675 metadata: {},
5676 type: "thread.deleted",
5677 payload: {
5678 threadId: defaultThreadId,
5679 deletedAt: "2026-01-01T00:00:01.000Z",
5680 },
5681 } satisfies Extract<OrchestrationEvent, { type: "thread.deleted" }>;
5682
5683 yield* buildAppUnderTest({
5684 layers: {
5685 orchestrationEngine: {
5686 streamDomainEvents: Stream.fromPubSub(liveEvents),
5687 },
5688 projectionSnapshotQuery: {
5689 getShellSnapshot: () =>
5690 Effect.gen(function* () {
5691 yield* Effect.sleep("25 millis");
5692 yield* PubSub.publish(liveEvents, deletedEvent);
5693 return {
5694 snapshotSequence: 1,
5695 projects: [],
5696 threads: [makeDefaultOrchestrationThreadShell()],
5697 updatedAt: "2026-01-01T00:00:00.000Z",
5698 };
5699 }),
5700 },
5701 },
5702 });
5703
5704 const wsUrl = yield* getWsServerUrl("/ws");
5705 const items = yield* Effect.scoped(
5706 withWsRpcClient(wsUrl, (client) =>
5707 client[ORCHESTRATION_WS_METHODS.subscribeShell]({
5708 requestCompletionMarker: true,
5709 }).pipe(Stream.take(3), Stream.runCollect),
5710 ),
5711 ).pipe(Effect.timeout("2 seconds"));
5712
5713 assert.equal(items[0]?.kind, "snapshot");
5714 assert.equal(items[1]?.kind, "thread-removed");
5715 assert.deepEqual(items[2], { kind: "synchronized" });
5716 }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive),
5717 );
5718
56105719 it.effect("buffers thread events published while the initial snapshot loads", () =>
56115720 Effect.gen(function* () {
56125721 const thread = makeDefaultOrchestrationReadModel().threads[0]!;
apps/server/src/ws.ts+50-4
......@@ -936,6 +936,8 @@ const makeWsRpcLayer = (
936936 otlpMetricsEnabled: config.otlpMetricsUrl !== undefined,
937937 },
938938 settings,
939 shellResumeCompletionMarker: true,
940 threadResumeCompletionMarker: true,
939941 };
940942 });
941943
......@@ -1105,11 +1107,28 @@ const makeWsRpcLayer = (
11051107 }),
11061108 ),
11071109 );
1108 return Stream.concat(catchUpStream, Stream.fromQueue(liveBuffer));
1110 const afterCatchUp =
1111 input.requestCompletionMarker === true
1112 ? Stream.concat(
1113 Stream.fromEffect(
1114 Queue.offer(liveBuffer, { kind: "synchronized" as const }),
1115 ).pipe(Stream.drain),
1116 Stream.fromQueue(liveBuffer),
1117 )
1118 : Stream.fromQueue(liveBuffer);
1119 return Stream.concat(catchUpStream, afterCatchUp);
11091120 }),
11101121 );
11111122 }
11121123
1124 // The full-snapshot fallback needs the same replay-window safety
1125 // as the resume path: subscribe before loading the projection so
1126 // events published while the snapshot is read are buffered.
1127 const liveBuffer = yield* Queue.unbounded<OrchestrationShellStreamItem>();
1128 yield* Effect.forkScoped(
1129 liveStream.pipe(Stream.runForEach((item) => Queue.offer(liveBuffer, item))),
1130 );
1131 const bufferedLiveStream = Stream.fromQueue(liveBuffer);
11131132 const snapshot = yield* projectionSnapshotQuery.getShellSnapshot().pipe(
11141133 Effect.tapError((cause) =>
11151134 Effect.logError("orchestration shell snapshot load failed", { cause }),
......@@ -1123,12 +1142,21 @@ const makeWsRpcLayer = (
11231142 ),
11241143 );
11251144
1145 const afterSnapshot =
1146 input.requestCompletionMarker === true
1147 ? Stream.concat(
1148 Stream.fromEffect(
1149 Queue.offer(liveBuffer, { kind: "synchronized" as const }),
1150 ).pipe(Stream.drain),
1151 bufferedLiveStream,
1152 )
1153 : bufferedLiveStream;
11261154 return Stream.concat(
11271155 Stream.make({
11281156 kind: "snapshot" as const,
11291157 snapshot,
11301158 }),
1131 liveStream,
1159 afterSnapshot,
11321160 );
11331161 }),
11341162 { "rpc.aggregate": "orchestration" },
......@@ -1207,7 +1235,16 @@ const makeWsRpcLayer = (
12071235 }),
12081236 ),
12091237 );
1210 return Stream.concat(catchUpStream, bufferedLiveStream);
1238 const afterCatchUp =
1239 input.requestCompletionMarker === true
1240 ? Stream.concat(
1241 Stream.fromEffect(
1242 Queue.offer(liveBuffer, { kind: "synchronized" as const }),
1243 ).pipe(Stream.drain),
1244 bufferedLiveStream,
1245 )
1246 : bufferedLiveStream;
1247 return Stream.concat(catchUpStream, afterCatchUp);
12111248 }
12121249
12131250 const snapshot = yield* projectionSnapshotQuery
......@@ -1229,12 +1266,21 @@ const makeWsRpcLayer = (
12291266 });
12301267 }
12311268
1269 const afterSnapshot =
1270 input.requestCompletionMarker === true
1271 ? Stream.concat(
1272 Stream.fromEffect(
1273 Queue.offer(liveBuffer, { kind: "synchronized" as const }),
1274 ).pipe(Stream.drain),
1275 bufferedLiveStream,
1276 )
1277 : bufferedLiveStream;
12321278 return Stream.concat(
12331279 Stream.make({
12341280 kind: "snapshot" as const,
12351281 snapshot: snapshot.value,
12361282 }),
1237 bufferedLiveStream,
1283 afterSnapshot,
12381284 );
12391285 }),
12401286 { "rpc.aggregate": "orchestration" },
packages/client-runtime/src/rpc/client.ts+88-52
......@@ -11,6 +11,7 @@ import { RpcClientError } from "effect/unstable/rpc";
1111
1212import { EnvironmentSupervisor } from "../connection/supervisor.ts";
1313import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
14import type { RpcSession } from "../rpc/session.ts";
1415
1516export class EnvironmentRpcUnavailableError extends Schema.TaggedErrorClass<EnvironmentRpcUnavailableError>()(
1617 "EnvironmentRpcUnavailableError",
......@@ -147,15 +148,18 @@ export function runStream<TTag extends EnvironmentStreamCommandRpcTag>(
147148 );
148149}
149150
150export function subscribe<TTag extends EnvironmentSubscriptionRpcTag>(
151interface SubscriptionOptions<TTag extends EnvironmentSubscriptionRpcTag> {
152 readonly onExpectedFailure?: (
153 cause: Cause.Cause<EnvironmentRpcStreamFailure<TTag>>,
154 ) => Effect.Effect<void, never, never>;
155 readonly retryExpectedFailureAfter?: Duration.Input;
156 readonly resubscribe?: Stream.Stream<unknown, never, never>;
157}
158
159export function subscribeDynamic<TTag extends EnvironmentSubscriptionRpcTag>(
151160 tag: TTag,
152 input: EnvironmentRpcInput<TTag>,
153 options?: {
154 readonly onExpectedFailure?: (
155 cause: Cause.Cause<EnvironmentRpcStreamFailure<TTag>>,
156 ) => Effect.Effect<void, never, never>;
157 readonly retryExpectedFailureAfter?: Duration.Input;
158 },
161 makeInput: (session: RpcSession) => Effect.Effect<EnvironmentRpcInput<TTag>>,
162 options?: SubscriptionOptions<TTag>,
159163): Stream.Stream<
160164 EnvironmentRpcStreamValue<TTag>,
161165 EnvironmentRpcStreamFailure<TTag>,
......@@ -163,8 +167,18 @@ export function subscribe<TTag extends EnvironmentSubscriptionRpcTag>(
163167> {
164168 return Stream.unwrap(
165169 EnvironmentSupervisor.pipe(
166 Effect.map((supervisor) =>
167 SubscriptionRef.changes(supervisor.session).pipe(
170 Effect.map((supervisor) => {
171 const sessionChanges = SubscriptionRef.changes(supervisor.session);
172 const sessions =
173 options?.resubscribe === undefined
174 ? sessionChanges
175 : Stream.merge(
176 sessionChanges,
177 options.resubscribe.pipe(
178 Stream.mapEffect(() => SubscriptionRef.get(supervisor.session)),
179 ),
180 );
181 return sessions.pipe(
168182 Stream.switchMap(
169183 Option.match({
170184 onNone: () => Stream.empty,
......@@ -180,54 +194,64 @@ export function subscribe<TTag extends EnvironmentSubscriptionRpcTag>(
180194 EnvironmentRpcStreamFailure<TTag>
181195 > =>
182196 Stream.suspend(() =>
183 method(input).pipe(
184 Stream.catchCause((cause) => {
185 const hasOnlyExpectedFailures =
186 cause.reasons.length > 0 &&
187 cause.reasons.every((reason) => reason._tag === "Fail");
188 const isTransportFailure =
189 hasOnlyExpectedFailures &&
190 cause.reasons.every(
191 (reason) => reason._tag === "Fail" && isRpcClientError(reason.error),
192 );
193 if (isTransportFailure) {
194 return Stream.fromEffect(
195 Effect.logWarning(
196 "Durable RPC subscription lost its transport; waiting for the next session.",
197 {
198 cause: Cause.pretty(cause),
199 method: tag,
200 environmentId: supervisor.target.environmentId,
201 },
202 ),
203 ).pipe(Stream.drain);
204 }
205 if (hasOnlyExpectedFailures && options?.onExpectedFailure !== undefined) {
206 const handled = Stream.fromEffect(options.onExpectedFailure(cause)).pipe(
207 Stream.drain,
208 );
209 if (options.retryExpectedFailureAfter === undefined) {
210 return handled;
211 }
212 return handled.pipe(
213 Stream.concat(
214 Stream.fromEffect(
215 Effect.sleep(options.retryExpectedFailureAfter),
216 ).pipe(Stream.drain),
217 ),
218 Stream.concat(subscribeToSession()),
219 );
220 }
221 return Stream.failCause(cause);
222 }),
197 Stream.unwrap(
198 makeInput(session).pipe(
199 Effect.map((input) =>
200 method(input).pipe(
201 Stream.catchCause((cause) => {
202 const hasOnlyExpectedFailures =
203 cause.reasons.length > 0 &&
204 cause.reasons.every((reason) => reason._tag === "Fail");
205 const isTransportFailure =
206 hasOnlyExpectedFailures &&
207 cause.reasons.every(
208 (reason) =>
209 reason._tag === "Fail" && isRpcClientError(reason.error),
210 );
211 if (isTransportFailure) {
212 return Stream.fromEffect(
213 Effect.logWarning(
214 "Durable RPC subscription lost its transport; waiting for the next session.",
215 {
216 cause: Cause.pretty(cause),
217 method: tag,
218 environmentId: supervisor.target.environmentId,
219 },
220 ),
221 ).pipe(Stream.drain);
222 }
223 if (
224 hasOnlyExpectedFailures &&
225 options?.onExpectedFailure !== undefined
226 ) {
227 const handled = Stream.fromEffect(
228 options.onExpectedFailure(cause),
229 ).pipe(Stream.drain);
230 if (options.retryExpectedFailureAfter === undefined) {
231 return handled;
232 }
233 return handled.pipe(
234 Stream.concat(
235 Stream.fromEffect(
236 Effect.sleep(options.retryExpectedFailureAfter),
237 ).pipe(Stream.drain),
238 ),
239 Stream.concat(subscribeToSession()),
240 );
241 }
242 return Stream.failCause(cause);
243 }),
244 ),
245 ),
246 ),
223247 ),
224248 );
225249 return subscribeToSession();
226250 },
227251 }),
228252 ),
229 ),
230 ),
253 );
254 }),
231255 ),
232256 ).pipe(
233257 Stream.withSpan("EnvironmentRpc.subscribe", {
......@@ -236,6 +260,18 @@ export function subscribe<TTag extends EnvironmentSubscriptionRpcTag>(
236260 );
237261}
238262
263export function subscribe<TTag extends EnvironmentSubscriptionRpcTag>(
264 tag: TTag,
265 input: EnvironmentRpcInput<TTag>,
266 options?: SubscriptionOptions<TTag>,
267): Stream.Stream<
268 EnvironmentRpcStreamValue<TTag>,
269 EnvironmentRpcStreamFailure<TTag>,
270 EnvironmentSupervisor
271> {
272 return subscribeDynamic(tag, () => Effect.succeed(input), options);
273}
274
239275export const config = Effect.gen(function* () {
240276 const session = yield* currentSession();
241277 return yield* session.initialConfig;
packages/client-runtime/src/state/shell-sync.test.ts+133-9
......@@ -8,6 +8,7 @@ import { describe, expect, it } from "@effect/vitest";
88import * as Effect from "effect/Effect";
99import * as Option from "effect/Option";
1010import * as Queue from "effect/Queue";
11import * as Ref from "effect/Ref";
1112import * as Stream from "effect/Stream";
1213import * as SubscriptionRef from "effect/SubscriptionRef";
1314
......@@ -17,6 +18,7 @@ import {
1718 type PreparedConnection,
1819} from "../connection/model.ts";
1920import * as EnvironmentSupervisor from "../connection/supervisor.ts";
21import * as ConnectionWakeups from "../connection/wakeups.ts";
2022import * as Persistence from "../platform/persistence.ts";
2123import * as RpcSession from "../rpc/session.ts";
2224import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
......@@ -48,7 +50,7 @@ const LIVE_SHELL_SNAPSHOT: OrchestrationShellSnapshot = {
4850function session(client: WsRpcProtocolClient): RpcSession.RpcSession {
4951 return {
5052 client,
51 initialConfig: Effect.never,
53 initialConfig: Effect.succeed({ shellResumeCompletionMarker: true } as never),
5254 ready: Effect.void,
5355 probe: Effect.void,
5456 closed: Effect.never,
......@@ -112,6 +114,15 @@ describe("environment shell synchronization", () => {
112114 kind: "snapshot",
113115 snapshot: LIVE_SHELL_SNAPSHOT,
114116 });
117 const synchronizing = yield* SubscriptionRef.changes(shellState).pipe(
118 Stream.filter((state) => state.status === "synchronizing" && Option.isSome(state.snapshot)),
119 Stream.runHead,
120 );
121 expect(Option.getOrThrow(Option.getOrThrow(synchronizing).snapshot)).toEqual(
122 LIVE_SHELL_SNAPSHOT,
123 );
124
125 yield* Queue.offer(events, { kind: "synchronized" });
115126 yield* SubscriptionRef.changes(shellState).pipe(
116127 Stream.filter((state) => state.status === "live"),
117128 Stream.runHead,
......@@ -137,21 +148,32 @@ describe("environment shell synchronization", () => {
137148 }),
138149 );
139150
140 it.effect("resumes a warm shell cache via afterSequence without an HTTP fetch", () =>
151 it.effect("replaces a warm shell cache with an authoritative HTTP snapshot", () =>
141152 Effect.gen(function* () {
142153 const cachedSnapshot: OrchestrationShellSnapshot = {
143154 snapshotSequence: 5,
144155 projects: [],
145 threads: [],
156 threads: [{ id: "stale-thread" } as never],
146157 updatedAt: "2026-06-06T00:00:00.000Z",
147158 };
159 const httpSnapshot: OrchestrationShellSnapshot = {
160 ...cachedSnapshot,
161 snapshotSequence: 9,
162 threads: [],
163 updatedAt: "2026-06-07T00:00:00.000Z",
164 };
148165 const events = yield* Queue.unbounded<OrchestrationShellStreamItem>();
149166 const capturedAfterSequence = yield* SubscriptionRef.make<number | undefined>(undefined);
167 const capturedCompletionMarker = yield* Ref.make<boolean | undefined>(undefined);
150168 const loaderCalls = yield* SubscriptionRef.make(0);
151169 const client = {
152 [ORCHESTRATION_WS_METHODS.subscribeShell]: (input: { readonly afterSequence?: number }) =>
170 [ORCHESTRATION_WS_METHODS.subscribeShell]: (input: {
171 readonly afterSequence?: number;
172 readonly requestCompletionMarker?: boolean;
173 }) =>
153174 Stream.unwrap(
154 SubscriptionRef.set(capturedAfterSequence, input.afterSequence).pipe(
175 Ref.set(capturedCompletionMarker, input.requestCompletionMarker).pipe(
176 Effect.andThen(SubscriptionRef.set(capturedAfterSequence, input.afterSequence)),
155177 Effect.as(Stream.fromQueue(events)),
156178 ),
157179 ),
......@@ -183,9 +205,11 @@ describe("environment shell synchronization", () => {
183205 });
184206 const snapshotLoader = ShellSnapshotLoader.of({
185207 load: () =>
186 SubscriptionRef.update(loaderCalls, (count) => count + 1).pipe(Effect.as(Option.none())),
208 SubscriptionRef.update(loaderCalls, (count) => count + 1).pipe(
209 Effect.as(Option.some(httpSnapshot)),
210 ),
187211 });
188 yield* makeEnvironmentShellState().pipe(
212 const shellState = yield* makeEnvironmentShellState().pipe(
189213 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
190214 Effect.provideService(Persistence.EnvironmentCacheStore, cache),
191215 Effect.provideService(ShellSnapshotLoader, snapshotLoader),
......@@ -197,8 +221,108 @@ describe("environment shell synchronization", () => {
197221 Stream.runHead,
198222 );
199223
200 expect(yield* SubscriptionRef.get(capturedAfterSequence)).toBe(5);
201 expect(yield* SubscriptionRef.get(loaderCalls)).toBe(0);
224 expect(yield* SubscriptionRef.get(capturedAfterSequence)).toBe(9);
225 expect(yield* Ref.get(capturedCompletionMarker)).toBe(true);
226 expect(yield* SubscriptionRef.get(loaderCalls)).toBe(1);
227 const synchronizing = yield* SubscriptionRef.get(shellState);
228 expect(synchronizing.status).toBe("synchronizing");
229 expect(Option.getOrThrow(synchronizing.snapshot)).toEqual(httpSnapshot);
230
231 yield* Queue.offer(events, { kind: "synchronized" });
232 yield* SubscriptionRef.changes(shellState).pipe(
233 Stream.filter((value) => value.status === "live"),
234 Stream.runHead,
235 );
236 }),
237 );
238
239 it.effect("refreshes the authoritative shell snapshot when the app becomes active", () =>
240 Effect.gen(function* () {
241 const events = yield* Queue.unbounded<OrchestrationShellStreamItem>();
242 const wakeups = yield* Queue.unbounded<ConnectionWakeups.ConnectionWakeup>();
243 const loaderCalls = yield* Ref.make(0);
244 const subscriptionCount = yield* Ref.make(0);
245 const client = {
246 [ORCHESTRATION_WS_METHODS.subscribeShell]: () =>
247 Stream.unwrap(
248 Ref.update(subscriptionCount, (count) => count + 1).pipe(
249 Effect.as(Stream.fromQueue(events)),
250 ),
251 ),
252 } as unknown as WsRpcProtocolClient;
253 const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE);
254 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
255 target: TARGET,
256 state: supervisorState,
257 session: yield* SubscriptionRef.make(Option.some(session(client))),
258 prepared: yield* SubscriptionRef.make(Option.some(PREPARED)),
259 connect: Effect.void,
260 disconnect: Effect.void,
261 retryNow: Effect.void,
262 } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
263 const cache = Persistence.EnvironmentCacheStore.of({
264 loadShell: () => Effect.succeed(Option.some(LIVE_SHELL_SNAPSHOT)),
265 saveShell: () => Effect.void,
266 loadThread: () => Effect.succeed(Option.none()),
267 saveThread: () => Effect.void,
268 removeThread: () => Effect.void,
269 loadServerConfig: () => Effect.succeed(Option.none()),
270 saveServerConfig: () => Effect.void,
271 loadVcsRefs: () => Effect.succeed(Option.none()),
272 saveVcsRefs: () => Effect.void,
273 clear: () => Effect.void,
274 });
275 const snapshotLoader = ShellSnapshotLoader.of({
276 load: () =>
277 Ref.updateAndGet(loaderCalls, (count) => count + 1).pipe(
278 Effect.map((count) =>
279 Option.some({ ...LIVE_SHELL_SNAPSHOT, snapshotSequence: count * 10 }),
280 ),
281 ),
282 });
283 const shellState = yield* makeEnvironmentShellState().pipe(
284 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
285 Effect.provideService(Persistence.EnvironmentCacheStore, cache),
286 Effect.provideService(ShellSnapshotLoader, snapshotLoader),
287 Effect.provideService(
288 ConnectionWakeups.ConnectionWakeups,
289 ConnectionWakeups.ConnectionWakeups.of({ changes: Stream.fromQueue(wakeups) }),
290 ),
291 );
292
293 yield* SubscriptionRef.changes(shellState).pipe(
294 Stream.filter(
295 (value) =>
296 value.status === "synchronizing" &&
297 Option.isSome(value.snapshot) &&
298 value.snapshot.value.snapshotSequence === 10,
299 ),
300 Stream.runHead,
301 );
302 yield* Queue.offer(events, { kind: "synchronized" });
303 yield* SubscriptionRef.changes(shellState).pipe(
304 Stream.filter((value) => value.status === "live"),
305 Stream.runHead,
306 );
307
308 yield* Queue.offer(wakeups, "application-active");
309 yield* SubscriptionRef.changes(shellState).pipe(
310 Stream.filter(
311 (value) =>
312 value.status === "synchronizing" &&
313 Option.isSome(value.snapshot) &&
314 value.snapshot.value.snapshotSequence === 20,
315 ),
316 Stream.runHead,
317 );
318
319 for (let attempt = 0; attempt < 100; attempt += 1) {
320 if ((yield* Ref.get(subscriptionCount)) >= 2) break;
321 yield* Effect.yieldNow;
322 }
323
324 expect(yield* Ref.get(loaderCalls)).toBe(2);
325 expect(yield* Ref.get(subscriptionCount)).toBe(2);
202326 }),
203327 );
204328});
packages/client-runtime/src/state/shell.ts+72-41
......@@ -9,6 +9,7 @@ import * as Cause from "effect/Cause";
99import * as Effect from "effect/Effect";
1010import * as Option from "effect/Option";
1111import * as Queue from "effect/Queue";
12import * as Ref from "effect/Ref";
1213import * as Stream from "effect/Stream";
1314import * as SubscriptionRef from "effect/SubscriptionRef";
1415import { AsyncResult, Atom } from "effect/unstable/reactivity";
......@@ -16,9 +17,10 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity";
1617import { EnvironmentRegistry } from "../connection/registry.ts";
1718import { connectionProjectionPhase } from "../connection/model.ts";
1819import { EnvironmentSupervisor } from "../connection/supervisor.ts";
20import * as ConnectionWakeups from "../connection/wakeups.ts";
1921import { safeErrorLogAttributes } from "../errors/safeLog.ts";
2022import { EnvironmentCacheStore } from "../platform/persistence.ts";
21import { subscribe } from "../rpc/client.ts";
23import { subscribeDynamic } from "../rpc/client.ts";
2224import { ShellSnapshotLoader } from "./shellSnapshotHttp.ts";
2325import { applyShellStreamEvent } from "./shellReducer.ts";
2426import type { EnvironmentCatalogState } from "./connections.ts";
......@@ -50,6 +52,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")
5052 const supervisor = yield* EnvironmentSupervisor;
5153 const cache = yield* EnvironmentCacheStore;
5254 const snapshotLoader = yield* ShellSnapshotLoader;
55 const wakeups = yield* Effect.serviceOption(ConnectionWakeups.ConnectionWakeups);
5356 const environmentId = supervisor.target.environmentId;
5457 const cachedSnapshot = yield* cache.loadShell(environmentId).pipe(
5558 Effect.catch((error) =>
......@@ -67,6 +70,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")
6770 status: shellStatusForSnapshot(cachedSnapshot),
6871 error: Option.none(),
6972 });
73 const awaitingCompletion = yield* Ref.make(false);
7074 const persistence = yield* Queue.sliding<OrchestrationShellSnapshot>(1);
7175
7276 const persist = Effect.fn("EnvironmentShellState.persist")(function* (
......@@ -90,10 +94,14 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")
9094 Effect.forkScoped,
9195 );
9296
93 const setDisconnected = SubscriptionRef.update(state, (current) => ({
94 ...current,
95 status: shellStatusForSnapshot(current.snapshot),
96 }));
97 const setDisconnected = Ref.set(awaitingCompletion, false).pipe(
98 Effect.andThen(
99 SubscriptionRef.update(state, (current) => ({
100 ...current,
101 status: shellStatusForSnapshot(current.snapshot),
102 })),
103 ),
104 );
97105 const setSynchronizing = SubscriptionRef.update(state, (current) => ({
98106 ...current,
99107 status: "synchronizing" as const,
......@@ -109,7 +117,8 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")
109117 },
110118 );
111119 const setStreamError = (error: unknown) =>
112 Effect.logWarning("Could not synchronize the environment shell.").pipe(
120 Ref.set(awaitingCompletion, false).pipe(
121 Effect.andThen(Effect.logWarning("Could not synchronize the environment shell.")),
113122 Effect.annotateLogs({
114123 environmentId,
115124 ...safeErrorLogAttributes(error),
......@@ -126,6 +135,16 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")
126135 const applyItem = Effect.fn("EnvironmentShellState.applyItem")(function* (
127136 item: OrchestrationShellStreamItem,
128137 ) {
138 if (item.kind === "synchronized") {
139 yield* Ref.set(awaitingCompletion, false);
140 yield* SubscriptionRef.update(state, (current) =>
141 Option.isSome(current.snapshot)
142 ? { ...current, status: "live" as const, error: Option.none() }
143 : current,
144 );
145 return;
146 }
147
129148 const current = yield* SubscriptionRef.get(state);
130149 const nextSnapshot =
131150 item.kind === "snapshot"
......@@ -141,52 +160,64 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")
141160 return;
142161 }
143162
163 const waiting = yield* Ref.get(awaitingCompletion);
144164 yield* SubscriptionRef.set(state, {
145165 snapshot: Option.some(nextSnapshot),
146 status: "live",
166 status: waiting ? "synchronizing" : "live",
147167 error: Option.none(),
148168 });
149169 yield* Queue.offer(persistence, nextSnapshot);
150170 });
151171
152 yield* Effect.forkScoped(
153 Effect.gen(function* () {
154 // Establish the base shell snapshot to resume from, minimizing bytes over
155 // the wire:
156 // - Warm cache: reuse the cached snapshot (zero network) and resume via
157 // `afterSequence` so we only receive shell events since the cached
158 // sequence.
159 // - Cold cache: load the full shell snapshot over HTTP (gzip-compressible,
160 // and off the socket), then resume via `afterSequence`.
161 // If no base can be established we fall back to the socket-embedded
162 // snapshot so the shell still synchronizes. Overlapping/replayed events are
163 // deduped by sequence in applyItem.
164 const base = Option.isSome(cachedSnapshot)
165 ? cachedSnapshot
166 : yield* Effect.gen(function* () {
167 const prepared = yield* SubscriptionRef.changes(supervisor.prepared).pipe(
168 Stream.filter(Option.isSome),
169 Stream.map((current) => current.value),
170 Stream.runHead,
171 );
172 return Option.isSome(prepared)
173 ? yield* snapshotLoader.load(prepared.value)
174 : Option.none<OrchestrationShellSnapshot>();
175 });
172 const foregroundResubscriptions = Option.match(wakeups, {
173 onNone: () => Stream.never,
174 onSome: (service) =>
175 service.changes.pipe(Stream.filter((reason) => reason === "application-active")),
176 });
176177
177 if (Option.isSome(base)) {
178 yield* applyItem({ kind: "snapshot", snapshot: base.value });
179 }
178 yield* setSynchronizing;
179 yield* Effect.forkScoped(
180 subscribeDynamic(
181 ORCHESTRATION_WS_METHODS.subscribeShell,
182 Effect.fn("EnvironmentShellState.makeSubscribeInput")(function* (session) {
183 const supportsCompletionMarker = yield* session.initialConfig.pipe(
184 Effect.map((config) => config.shellResumeCompletionMarker === true),
185 Effect.orElseSucceed(() => false),
186 );
187 yield* Ref.set(awaitingCompletion, supportsCompletionMarker);
188 yield* setSynchronizing;
180189
181 const subscribeInput = Option.match(base, {
182 onNone: () => ({}),
183 onSome: (snapshot) => ({ afterSequence: snapshot.snapshotSequence }),
184 });
190 const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe(
191 Effect.flatMap(
192 Option.match({
193 onSome: Effect.succeed,
194 onNone: () =>
195 SubscriptionRef.changes(supervisor.prepared).pipe(
196 Stream.filter(Option.isSome),
197 Stream.map((value) => value.value),
198 Stream.runHead,
199 Effect.map(Option.getOrThrow),
200 ),
201 }),
202 ),
203 );
204 const httpSnapshot = yield* snapshotLoader.load(prepared);
205 if (Option.isSome(httpSnapshot)) {
206 yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value });
207 return {
208 afterSequence: httpSnapshot.value.snapshotSequence,
209 ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}),
210 };
211 }
185212
186 yield* subscribe(ORCHESTRATION_WS_METHODS.subscribeShell, subscribeInput, {
213 return supportsCompletionMarker ? { requestCompletionMarker: true as const } : {};
214 }),
215 {
187216 onExpectedFailure: (cause) => setStreamError(Cause.squash(cause)),
188 }).pipe(Stream.runForEach(applyItem));
189 }),
217 retryExpectedFailureAfter: "250 millis",
218 resubscribe: foregroundResubscriptions,
219 },
220 ).pipe(Stream.runForEach(applyItem)),
190221 );
191222 yield* SubscriptionRef.changes(supervisor.state).pipe(
192223 Stream.runForEach((connectionState) => {
packages/client-runtime/src/state/threads-sync.test.ts+170-5
......@@ -26,6 +26,7 @@ import {
2626 type PreparedConnection,
2727 type SupervisorConnectionState,
2828} from "../connection/model.ts";
29import * as ConnectionWakeups from "../connection/wakeups.ts";
2930import * as EnvironmentSupervisor from "../connection/supervisor.ts";
3031import * as Persistence from "../platform/persistence.ts";
3132import * as RpcSession from "../rpc/session.ts";
......@@ -98,10 +99,17 @@ const ACTIVE_THREAD: OrchestrationThread = {
9899
99100type TestThreadInput = OrchestrationThreadStreamItem | Error;
100101
101function testSession(client: WsRpcProtocolClient): RpcSession.RpcSession {
102function testSession(
103 client: WsRpcProtocolClient,
104 options?: { readonly completionMarker?: boolean },
105): RpcSession.RpcSession {
102106 return {
103107 client,
104 initialConfig: Effect.never,
108 initialConfig: Effect.succeed(
109 options?.completionMarker === true
110 ? ({ threadResumeCompletionMarker: true } as never)
111 : ({} as never),
112 ),
105113 ready: Effect.void,
106114 probe: Effect.void,
107115 closed: Effect.never,
......@@ -122,6 +130,7 @@ function awaitThreadState(
122130const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (options?: {
123131 readonly cached?: OrchestrationThread;
124132 readonly httpSnapshot?: Option.Option<OrchestrationThreadDetailSnapshot>;
133 readonly completionMarker?: boolean;
125134}) {
126135 const inputs = yield* Queue.unbounded<TestThreadInput>();
127136 const observed = yield* Queue.unbounded<EnvironmentThreadState>();
......@@ -130,8 +139,10 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o
130139 const subscriptionCount = yield* Ref.make(0);
131140 const loaderCalls = yield* Ref.make(0);
132141 const lastSubscribeAfterSequence = yield* Ref.make<number | undefined>(undefined);
142 const lastRequestCompletionMarker = yield* Ref.make<boolean | undefined>(undefined);
133143 const savedThreads = yield* Ref.make<ReadonlyArray<OrchestrationThreadDetailSnapshot>>([]);
134144 const removedThreads = yield* Ref.make<ReadonlyArray<ThreadId>>([]);
145 const wakeups = yield* Queue.unbounded<ConnectionWakeups.ConnectionWakeup>();
135146 const supervisorState = yield* SubscriptionRef.make<SupervisorConnectionState>(
136147 AVAILABLE_CONNECTION_STATE,
137148 );
......@@ -142,16 +153,25 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o
142153 ),
143154 );
144155 const client = {
145 [ORCHESTRATION_WS_METHODS.subscribeThread]: (input: { readonly afterSequence?: number }) =>
156 [ORCHESTRATION_WS_METHODS.subscribeThread]: (input: {
157 readonly afterSequence?: number;
158 readonly requestCompletionMarker?: boolean;
159 }) =>
146160 Stream.unwrap(
147161 Ref.updateAndGet(subscriptionCount, (count) => count + 1).pipe(
148162 Effect.andThen(Ref.set(lastSubscribeAfterSequence, input.afterSequence)),
163 Effect.andThen(Ref.set(lastRequestCompletionMarker, input.requestCompletionMarker)),
149164 Effect.as(streamFrom(inputs)),
150165 ),
151166 ),
152167 } as unknown as WsRpcProtocolClient;
153168 const supervisorSession = yield* SubscriptionRef.make<Option.Option<RpcSession.RpcSession>>(
154 Option.some(testSession(client)),
169 Option.some(
170 testSession(
171 client,
172 options?.completionMarker === true ? { completionMarker: true } : undefined,
173 ),
174 ),
155175 );
156176 const prepared = yield* SubscriptionRef.make<Option.Option<PreparedConnection>>(
157177 Option.some(PREPARED),
......@@ -201,6 +221,10 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o
201221 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
202222 Effect.provideService(Persistence.EnvironmentCacheStore, cache),
203223 Effect.provideService(ThreadSnapshotLoader, snapshotLoader),
224 Effect.provideService(
225 ConnectionWakeups.ConnectionWakeups,
226 ConnectionWakeups.ConnectionWakeups.of({ changes: Stream.fromQueue(wakeups) }),
227 ),
204228 );
205229 yield* SubscriptionRef.changes(threadState).pipe(
206230 Stream.runForEach((state) =>
......@@ -217,11 +241,21 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o
217241 subscriptionCount,
218242 loaderCalls,
219243 lastSubscribeAfterSequence,
244 lastRequestCompletionMarker,
220245 supervisorState,
221246 supervisorSession,
222247 savedThreads,
223248 removedThreads,
224 replaceSession: SubscriptionRef.set(supervisorSession, Option.some(testSession(client))),
249 wakeups,
250 replaceSession: SubscriptionRef.set(
251 supervisorSession,
252 Option.some(
253 testSession(
254 client,
255 options?.completionMarker === true ? { completionMarker: true } : undefined,
256 ),
257 ),
258 ),
225259 };
226260});
227261
......@@ -233,6 +267,8 @@ const snapshot = (thread: OrchestrationThread): OrchestrationThreadStreamItem =>
233267 },
234268});
235269
270const synchronized = (): OrchestrationThreadStreamItem => ({ kind: "synchronized" });
271
236272const titleUpdated = (title: string, sequence = 2): OrchestrationThreadStreamItem => ({
237273 kind: "event",
238274 event: {
......@@ -415,6 +451,35 @@ describe("EnvironmentThreads", () => {
415451 }),
416452 );
417453
454 it.effect("does not resurrect a deleted thread when the app returns to the foreground", () =>
455 Effect.gen(function* () {
456 const harness = yield* makeHarness({
457 cached: BASE_THREAD,
458 completionMarker: true,
459 httpSnapshot: Option.some({
460 snapshotSequence: 4,
461 thread: { ...BASE_THREAD, title: "Stale HTTP thread" },
462 }),
463 });
464 yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD));
465 yield* Queue.offer(harness.inputs, deleted());
466 yield* awaitThreadState(harness.observed, (value) => value.status === "deleted");
467
468 expect(yield* Ref.get(harness.loaderCalls)).toBe(0);
469 yield* Queue.offer(harness.wakeups, "application-active");
470 for (let attempt = 0; attempt < 100; attempt += 1) {
471 if ((yield* Ref.get(harness.subscriptionCount)) >= 2) break;
472 yield* Effect.yieldNow;
473 }
474
475 const latest = yield* Ref.get(harness.latest);
476 expect(yield* Ref.get(harness.subscriptionCount)).toBe(2);
477 expect(yield* Ref.get(harness.loaderCalls)).toBe(0);
478 expect(latest.status).toBe("deleted");
479 expect(Option.isNone(latest.data)).toBe(true);
480 }),
481 );
482
418483 it.effect("preserves data after a domain failure and resumes on a replacement session", () =>
419484 Effect.gen(function* () {
420485 const harness = yield* makeHarness({ cached: BASE_THREAD });
......@@ -529,4 +594,104 @@ describe("EnvironmentThreads", () => {
529594 expect((yield* Ref.get(harness.latest)).status).toBe("live");
530595 }),
531596 );
597
598 it.effect("keeps replayed updates synchronizing until the completion marker arrives", () =>
599 Effect.gen(function* () {
600 const harness = yield* makeHarness({ cached: BASE_THREAD, completionMarker: true });
601 yield* awaitThreadState(
602 harness.observed,
603 (value) => value.status === "synchronizing" && Option.isSome(value.data),
604 );
605 expect(yield* Ref.get(harness.lastRequestCompletionMarker)).toBe(true);
606
607 yield* Queue.offer(
608 harness.inputs,
609 titleUpdated("Caught-up title", CACHED_SNAPSHOT_SEQUENCE + 1),
610 );
611 const catchingUp = yield* awaitThreadState(
612 harness.observed,
613 (value) =>
614 value.status === "synchronizing" &&
615 Option.isSome(value.data) &&
616 value.data.value.title === "Caught-up title",
617 );
618 expect(catchingUp.status).toBe("synchronizing");
619
620 yield* Queue.offer(harness.inputs, synchronized());
621 const live = yield* awaitThreadState(
622 harness.observed,
623 (value) => value.status === "live" && Option.isSome(value.data),
624 );
625 expect(Option.getOrThrow(live.data).title).toBe("Caught-up title");
626 }),
627 );
628
629 it.effect("resumes replacement sessions from the latest applied sequence", () =>
630 Effect.gen(function* () {
631 const harness = yield* makeHarness({ cached: BASE_THREAD, completionMarker: true });
632 yield* Queue.offer(
633 harness.inputs,
634 titleUpdated("Latest title", CACHED_SNAPSHOT_SEQUENCE + 1),
635 );
636 yield* Queue.offer(harness.inputs, synchronized());
637 yield* awaitThreadState(
638 harness.observed,
639 (value) =>
640 value.status === "live" &&
641 Option.isSome(value.data) &&
642 value.data.value.title === "Latest title",
643 );
644
645 yield* harness.replaceSession;
646 for (let attempt = 0; attempt < 100; attempt += 1) {
647 if ((yield* Ref.get(harness.subscriptionCount)) >= 2) break;
648 yield* Effect.yieldNow;
649 }
650
651 expect(yield* Ref.get(harness.subscriptionCount)).toBe(2);
652 expect(yield* Ref.get(harness.lastSubscribeAfterSequence)).toBe(CACHED_SNAPSHOT_SEQUENCE + 1);
653 expect((yield* Ref.get(harness.latest)).status).toBe("synchronizing");
654 }),
655 );
656
657 it.effect("resubscribes on app foreground from the latest applied sequence", () =>
658 Effect.gen(function* () {
659 const harness = yield* makeHarness({ cached: BASE_THREAD, completionMarker: true });
660 yield* Queue.offer(
661 harness.inputs,
662 titleUpdated("Latest title", CACHED_SNAPSHOT_SEQUENCE + 1),
663 );
664 yield* Queue.offer(harness.inputs, synchronized());
665 yield* awaitThreadState(
666 harness.observed,
667 (value) =>
668 value.status === "live" &&
669 Option.isSome(value.data) &&
670 value.data.value.title === "Latest title",
671 );
672
673 yield* Queue.offer(harness.wakeups, "application-active");
674 const synchronizing = yield* awaitThreadState(
675 harness.observed,
676 (value) => value.status === "synchronizing" && Option.isSome(value.data),
677 );
678 for (let attempt = 0; attempt < 100; attempt += 1) {
679 if ((yield* Ref.get(harness.subscriptionCount)) >= 2) break;
680 yield* Effect.yieldNow;
681 }
682
683 expect(synchronizing.status).toBe("synchronizing");
684 expect(yield* Ref.get(harness.subscriptionCount)).toBe(2);
685 expect(yield* Ref.get(harness.lastSubscribeAfterSequence)).toBe(CACHED_SNAPSHOT_SEQUENCE + 1);
686 expect(yield* Ref.get(harness.lastRequestCompletionMarker)).toBe(true);
687 expect(yield* Ref.get(harness.loaderCalls)).toBe(0);
688
689 yield* Queue.offer(harness.inputs, synchronized());
690 const live = yield* awaitThreadState(
691 harness.observed,
692 (value) => value.status === "live" && Option.isSome(value.data),
693 );
694 expect(Option.getOrThrow(live.data).title).toBe("Latest title");
695 }),
696 );
532697});
packages/client-runtime/src/state/threads.ts+98-49
......@@ -10,6 +10,7 @@ import * as Cause from "effect/Cause";
1010import * as Effect from "effect/Effect";
1111import * as Option from "effect/Option";
1212import * as Queue from "effect/Queue";
13import * as Ref from "effect/Ref";
1314import * as Stream from "effect/Stream";
1415import * as SubscriptionRef from "effect/SubscriptionRef";
1516import { Atom } from "effect/unstable/reactivity";
......@@ -17,8 +18,9 @@ import { Atom } from "effect/unstable/reactivity";
1718import { EnvironmentRegistry } from "../connection/registry.ts";
1819import { connectionProjectionPhase } from "../connection/model.ts";
1920import { EnvironmentSupervisor } from "../connection/supervisor.ts";
21import * as ConnectionWakeups from "../connection/wakeups.ts";
2022import { EnvironmentCacheStore } from "../platform/persistence.ts";
21import { subscribe } from "../rpc/client.ts";
23import { subscribeDynamic } from "../rpc/client.ts";
2224import { ThreadSnapshotLoader } from "./threadSnapshotHttp.ts";
2325import { parseThreadKey, threadKey } from "./entities.ts";
2426import { applyThreadDetailEvent } from "./threadReducer.ts";
......@@ -52,6 +54,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
5254 const supervisor = yield* EnvironmentSupervisor;
5355 const cache = yield* EnvironmentCacheStore;
5456 const snapshotLoader = yield* ThreadSnapshotLoader;
57 const wakeups = yield* Effect.serviceOption(ConnectionWakeups.ConnectionWakeups);
5558 const environmentId = supervisor.target.environmentId;
5659 const cached = yield* cache.loadThread(environmentId, threadId).pipe(
5760 Effect.catch((error) =>
......@@ -76,6 +79,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
7679 const lastSequence = yield* SubscriptionRef.make(
7780 Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence }),
7881 );
82 const awaitingCompletion = yield* Ref.make(false);
7983 const persistence = yield* Queue.sliding<OrchestrationThreadDetailSnapshot>(1);
8084
8185 const persist = Effect.fn("EnvironmentThreadState.persist")(function* (
......@@ -100,11 +104,15 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
100104 Effect.forkScoped,
101105 );
102106
103 const setSynchronizing = SubscriptionRef.update(state, (current) => ({
104 ...current,
105 status: "synchronizing" as const,
106 error: Option.none(),
107 }));
107 const setSynchronizing = SubscriptionRef.update(state, (current) =>
108 current.status === "deleted"
109 ? current
110 : {
111 ...current,
112 status: "synchronizing" as const,
113 error: Option.none(),
114 },
115 );
108116 const setReady = SubscriptionRef.update(state, (current) =>
109117 current.status === "live" || current.status === "deleted"
110118 ? current
......@@ -114,23 +122,32 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
114122 error: Option.none(),
115123 },
116124 );
117 const setDisconnected = SubscriptionRef.update(state, (current) => ({
118 ...current,
119 status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data),
120 }));
121 const setStreamError = (cause: Cause.Cause<unknown>) =>
122 SubscriptionRef.update(state, (current) => ({
125 const setDisconnected = Effect.gen(function* () {
126 yield* Ref.set(awaitingCompletion, false);
127 yield* SubscriptionRef.update(state, (current) => ({
123128 ...current,
124129 status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data),
125 error: Option.some(formatThreadError(cause)),
126130 }));
131 });
132 const setStreamError = (cause: Cause.Cause<unknown>) =>
133 Ref.set(awaitingCompletion, false).pipe(
134 Effect.andThen(
135 SubscriptionRef.update(state, (current) => ({
136 ...current,
137 status:
138 current.status === "deleted" ? current.status : statusWithoutLiveData(current.data),
139 error: Option.some(formatThreadError(cause)),
140 })),
141 ),
142 );
127143
128144 const setThread = Effect.fn("EnvironmentThreadState.setThread")(function* (
129145 thread: OrchestrationThread,
130146 ) {
147 const waiting = yield* Ref.get(awaitingCompletion);
131148 yield* SubscriptionRef.set(state, {
132149 data: Option.some(thread),
133 status: "live",
150 status: waiting ? "synchronizing" : "live",
134151 error: Option.none(),
135152 });
136153 // Active threads can update many times per second and retain large tool
......@@ -143,6 +160,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
143160 });
144161
145162 const setDeleted = Effect.fn("EnvironmentThreadState.setDeleted")(function* () {
163 yield* Ref.set(awaitingCompletion, false);
146164 yield* SubscriptionRef.set(state, {
147165 data: Option.none(),
148166 status: "deleted",
......@@ -164,6 +182,16 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
164182 const applyItem = Effect.fn("EnvironmentThreadState.applyItem")(function* (
165183 item: OrchestrationThreadStreamItem,
166184 ) {
185 if (item.kind === "synchronized") {
186 yield* Ref.set(awaitingCompletion, false);
187 yield* SubscriptionRef.update(state, (current) =>
188 Option.isSome(current.data) && current.status !== "deleted"
189 ? { ...current, status: "live" as const, error: Option.none() }
190 : current,
191 );
192 return;
193 }
194
167195 if (item.kind === "snapshot") {
168196 yield* SubscriptionRef.set(lastSequence, item.snapshot.snapshotSequence);
169197 yield* setThread(item.snapshot.thread);
......@@ -205,48 +233,69 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
205233 Effect.forkScoped,
206234 );
207235
236 const foregroundResubscriptions = Option.match(wakeups, {
237 onNone: () => Stream.never,
238 onSome: (service) =>
239 service.changes.pipe(Stream.filter((reason) => reason === "application-active")),
240 });
241
208242 yield* setSynchronizing;
209243 yield* Effect.forkScoped(
210 Effect.gen(function* () {
211 // Establish the base snapshot to resume from, minimizing bytes over the
212 // wire:
213 // - Warm cache: reuse the cached snapshot (zero network) and resume via
214 // `afterSequence` so we only receive events since the cached sequence.
215 // - Cold cache: load the full snapshot over HTTP (gzip-compressible, and
216 // off the socket), then resume via `afterSequence`.
217 // If no base can be established we fall back to the socket-embedded
218 // snapshot so the thread still synchronizes. Overlapping/replayed events
219 // are deduped by sequence in applyItem.
220 const base = Option.isSome(cached)
221 ? cached
222 : yield* Effect.gen(function* () {
223 // Cold cache only: wait for a prepared connection so we can
224 // authenticate the HTTP request; this mirrors the socket path, which
225 // likewise waits for a live session.
226 const prepared = yield* SubscriptionRef.changes(supervisor.prepared).pipe(
227 Stream.filter(Option.isSome),
228 Stream.map((current) => current.value),
229 Stream.runHead,
230 );
231 return Option.isSome(prepared)
232 ? yield* snapshotLoader.load(prepared.value, threadId)
233 : Option.none<OrchestrationThreadDetailSnapshot>();
234 });
244 subscribeDynamic(
245 ORCHESTRATION_WS_METHODS.subscribeThread,
246 Effect.fn("EnvironmentThreadState.makeSubscribeInput")(function* (session) {
247 const supportsCompletionMarker = yield* session.initialConfig.pipe(
248 Effect.map((config) => config.threadResumeCompletionMarker === true),
249 Effect.orElseSucceed(() => false),
250 );
251 yield* Ref.set(awaitingCompletion, supportsCompletionMarker);
252 yield* setSynchronizing;
235253
236 if (Option.isSome(base)) {
237 yield* applyItem({ kind: "snapshot", snapshot: base.value });
238 }
254 let current = yield* SubscriptionRef.get(state);
255 if (Option.isNone(current.data) && current.status !== "deleted") {
256 const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe(
257 Effect.flatMap(
258 Option.match({
259 onSome: Effect.succeed,
260 onNone: () =>
261 SubscriptionRef.changes(supervisor.prepared).pipe(
262 Stream.filter(Option.isSome),
263 Stream.map((value) => value.value),
264 Stream.runHead,
265 Effect.map(Option.getOrThrow),
266 ),
267 }),
268 ),
269 );
270 const httpSnapshot = yield* snapshotLoader.load(prepared, threadId);
271 if (Option.isSome(httpSnapshot)) {
272 yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value });
273 current = yield* SubscriptionRef.get(state);
274 }
275 }
239276
240 const subscribeInput = Option.match(base, {
241 onNone: () => ({ threadId }),
242 onSome: (snapshot) => ({ threadId, afterSequence: snapshot.snapshotSequence }),
243 });
277 const sequence = yield* SubscriptionRef.get(lastSequence);
278 const canResume = Option.isSome(current.data);
279 if (!supportsCompletionMarker && canResume) {
280 yield* SubscriptionRef.update(state, (value) => ({
281 ...value,
282 status: value.status === "deleted" ? value.status : ("live" as const),
283 error: Option.none(),
284 }));
285 }
244286
245 yield* subscribe(ORCHESTRATION_WS_METHODS.subscribeThread, subscribeInput, {
287 return {
288 threadId,
289 ...(canResume ? { afterSequence: sequence } : {}),
290 ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}),
291 };
292 }),
293 {
246294 onExpectedFailure: setStreamError,
247295 retryExpectedFailureAfter: "250 millis",
248 }).pipe(Stream.runForEach(applyItem));
249 }),
296 resubscribe: foregroundResubscriptions,
297 },
298 ).pipe(Stream.runForEach(applyItem)),
250299 );
251300
252301 yield* Effect.addFinalizer(() =>
packages/contracts/src/orchestration.ts+16
......@@ -443,6 +443,9 @@ export const OrchestrationShellStreamEvent = Schema.Union([
443443export type OrchestrationShellStreamEvent = typeof OrchestrationShellStreamEvent.Type;
444444
445445export const OrchestrationShellStreamItem = Schema.Union([
446 Schema.Struct({
447 kind: Schema.Literal("synchronized"),
448 }),
446449 Schema.Struct({
447450 kind: Schema.Literal("snapshot"),
448451 snapshot: OrchestrationShellSnapshot,
......@@ -461,6 +464,11 @@ export const OrchestrationSubscribeShellInput = Schema.Struct({
461464 * client).
462465 */
463466 afterSequence: Schema.optionalKey(NonNegativeInt),
467 /**
468 * Requests an explicit marker after the subscription has emitted its initial
469 * snapshot or catch-up replay and before it begins emitting live events.
470 */
471 requestCompletionMarker: Schema.optionalKey(Schema.Boolean),
464472});
465473export type OrchestrationSubscribeShellInput = typeof OrchestrationSubscribeShellInput.Type;
466474
......@@ -474,6 +482,11 @@ export const OrchestrationSubscribeThreadInput = Schema.Struct({
474482 * sequence on the client).
475483 */
476484 afterSequence: Schema.optionalKey(NonNegativeInt),
485 /**
486 * Requests an explicit marker after the subscription has emitted its initial
487 * snapshot or catch-up replay and before it begins emitting live events.
488 */
489 requestCompletionMarker: Schema.optionalKey(Schema.Boolean),
477490});
478491export type OrchestrationSubscribeThreadInput = typeof OrchestrationSubscribeThreadInput.Type;
479492
......@@ -1135,6 +1148,9 @@ export const OrchestrationEvent = Schema.Union([
11351148export type OrchestrationEvent = typeof OrchestrationEvent.Type;
11361149
11371150export const OrchestrationThreadStreamItem = Schema.Union([
1151 Schema.Struct({
1152 kind: Schema.Literal("synchronized"),
1153 }),
11381154 Schema.Struct({
11391155 kind: Schema.Literal("snapshot"),
11401156 snapshot: OrchestrationThreadDetailSnapshot,
packages/contracts/src/server.ts+4
......@@ -417,6 +417,10 @@ export const ServerConfig = Schema.Struct({
417417 availableEditors: Schema.Array(EditorId),
418418 observability: ServerObservability,
419419 settings: ServerSettings,
420 /** Whether shell subscriptions can emit an opt-in catch-up completion marker. */
421 shellResumeCompletionMarker: Schema.optionalKey(Schema.Boolean),
422 /** Whether thread subscriptions can emit an opt-in catch-up completion marker. */
423 threadResumeCompletionMarker: Schema.optionalKey(Schema.Boolean),
420424});
421425export type ServerConfig = typeof ServerConfig.Type;
422426