History

fix(orchestrator): hydrate shell cache and group multi-environment projects (#3640)

Restore versioned shell-cache hydration, reconcile repository enrichment across multiple environments, and decode non-null settled shell timestamps correctly.

Mike Olson committed 3 months ago

Commit 1bc99ec1 parent 480ec8417 files · +1759 −51Browse files

Unified · Split

17 files
  1. apps/server/src/orchestration-v2/ShellStream.test.ts +176 −0
  2. apps/server/src/orchestration-v2/ShellStream.ts +71 −0
  3. apps/server/src/orchestration-v2/http.ts +121 −0
  4. apps/server/src/orchestration-v2/runtimeLayer.test.ts +12 −2
  5. apps/server/src/project/ProjectEnrichmentService.test.ts +104 −0
  6. apps/server/src/project/ProjectEnrichmentService.ts +13 −1
  7. apps/server/src/ws.ts +117 −12
  8. packages/client-runtime/src/platform/orchestrationCache.test.ts +87 −13
  9. packages/client-runtime/src/platform/orchestrationCache.ts +8 −4
  10. packages/client-runtime/src/state/shell-sync.test.ts +418 −3
  11. packages/client-runtime/src/state/shell.ts +48 −11
  12. packages/client-runtime/src/state/shellReducer.test.ts +325 −1
  13. packages/client-runtime/src/state/shellReducer.ts +89 −2
  14. packages/contracts/src/orchestrationV2.test.ts +26 −0
  15. packages/contracts/src/orchestrationV2.ts +77 −0
  16. packages/effect-acp/src/protocol.test.ts +55 −0
  17. packages/effect-acp/src/protocol.ts +12 −2
apps/server/src/orchestration-v2/ShellStream.test.ts+176−0
@@ -0,0 +1,176 @@
1import { describe, expect, it } from "@effect/vitest";
2import type { ApplicationStoredEvent, OrchestrationV2ShellSnapshot } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5
6import {
7 coalesceShellApplicationEvents,
8 composeShellStreamWithEnrichment,
9 shellStreamItemFromEnrichmentRefresh,
10 shellStreamItemsFromInitialSnapshot,
11} from "./ShellStream.ts";
12
13function project(sequence: number, id: string): ApplicationStoredEvent {
14 return {
15 sequence,
16 aggregateKind: "project",
17 aggregateId: id,
18 } as ApplicationStoredEvent;
19}
20
21function thread(sequence: number, id: string): ApplicationStoredEvent {
22 return {
23 sequence,
24 event: { threadId: id },
25 } as ApplicationStoredEvent;
26}
27
28const emptyShellSnapshot = {
29 schemaVersion: 1,
30 snapshotSequence: 0,
31 projects: [],
32 threads: [],
33 archivedThreads: [],
34} as OrchestrationV2ShellSnapshot;
35
36describe("coalesceShellApplicationEvents", () => {
37 it("keeps the newest event per aggregate and preserves sequence order", () => {
38 expect(
39 coalesceShellApplicationEvents([
40 thread(2, "thread-a"),
41 project(3, "project-a"),
42 thread(4, "thread-b"),
43 thread(5, "thread-a"),
44 project(6, "project-a"),
45 ]).map((event) => event.sequence),
46 ).toEqual([4, 5, 6]);
47 });
48});
49
50describe("shellStreamItemFromEnrichmentRefresh", () => {
51 it("batches nearby completion roots onto one snapshot item", () => {
52 expect(
53 shellStreamItemFromEnrichmentRefresh({
54 snapshot: emptyShellSnapshot,
55 changes: [
56 { workspaceRoot: "/workspace/a" },
57 { workspaceRoot: "/workspace/b" },
58 { workspaceRoot: "/workspace/a" },
59 ],
60 }),
61 ).toEqual({
62 kind: "snapshot",
63 snapshot: emptyShellSnapshot,
64 resolvedRepositoryIdentityRoots: ["/workspace/a", "/workspace/b"],
65 });
66 });
67});
68
69describe("shellStreamItemsFromInitialSnapshot", () => {
70 it("emits unmarked authoritative then same-sequence marked enrichment when roots resolved", () => {
71 const snapshot = {
72 ...emptyShellSnapshot,
73 snapshotSequence: 7,
74 } as OrchestrationV2ShellSnapshot;
75
76 expect(
77 shellStreamItemsFromInitialSnapshot({
78 snapshot,
79 resolvedRepositoryIdentityRoots: ["/workspace/a", "/workspace/a"],
80 }),
81 ).toEqual([
82 { kind: "snapshot", snapshot },
83 {
84 kind: "snapshot",
85 snapshot,
86 resolvedRepositoryIdentityRoots: ["/workspace/a"],
87 },
88 ]);
89 });
90
91 it("emits only the unmarked authoritative snapshot when no roots resolved", () => {
92 expect(
93 shellStreamItemsFromInitialSnapshot({
94 snapshot: emptyShellSnapshot,
95 resolvedRepositoryIdentityRoots: [],
96 }),
97 ).toEqual([{ kind: "snapshot", snapshot: emptyShellSnapshot }]);
98 });
99});
100
101describe("composeShellStreamWithEnrichment", () => {
102 it.effect(
103 "emits every initial item before enrichment even when enrichment is already ready",
104 () =>
105 Effect.gen(function* () {
106 const initialSnapshot = {
107 ...emptyShellSnapshot,
108 snapshotSequence: 5,
109 } as OrchestrationV2ShellSnapshot;
110 const enrichmentSnapshot = {
111 ...emptyShellSnapshot,
112 snapshotSequence: 10,
113 } as OrchestrationV2ShellSnapshot;
114
115 const initialItems = shellStreamItemsFromInitialSnapshot({
116 snapshot: initialSnapshot,
117 resolvedRepositoryIdentityRoots: ["/workspace/a"],
118 });
119 // Enrichment stream is fully ready before the composed stream is pulled.
120 const enrichment = Stream.make(
121 shellStreamItemFromEnrichmentRefresh({
122 snapshot: enrichmentSnapshot,
123 changes: [{ workspaceRoot: "/workspace/b" }],
124 }),
125 );
126 const tail = Stream.make(
127 { kind: "synchronized" as const },
128 {
129 kind: "project.removed" as const,
130 sequence: 6,
131 projectId: "project-a",
132 },
133 );
134
135 const items = Array.from(
136 yield* composeShellStreamWithEnrichment({
137 initial: Stream.fromIterable(initialItems),
138 tail,
139 enrichment,
140 }).pipe(Stream.runCollect),
141 );
142
143 expect(items.slice(0, initialItems.length)).toEqual(initialItems);
144
145 const enrichmentIndex = items.findIndex(
146 (item) =>
147 item.kind === "snapshot" &&
148 "resolvedRepositoryIdentityRoots" in item &&
149 item.resolvedRepositoryIdentityRoots?.includes("/workspace/b"),
150 );
151 expect(enrichmentIndex).toBeGreaterThanOrEqual(initialItems.length);
152
153 for (let index = 0; index < initialItems.length; index++) {
154 expect(items[index]).toEqual(initialItems[index]);
155 }
156 }),
157 );
158
159 it.effect("still interleaves enrichment with the post-prefix tail after initials drain", () =>
160 Effect.gen(function* () {
161 const items = Array.from(
162 yield* composeShellStreamWithEnrichment({
163 initial: Stream.make("initial-unmarked", "initial-marked"),
164 tail: Stream.make("tail-a", "tail-b"),
165 enrichment: Stream.make("enrichment"),
166 }).pipe(Stream.runCollect),
167 );
168
169 expect(items.slice(0, 2)).toEqual(["initial-unmarked", "initial-marked"]);
170 expect(items).toContain("enrichment");
171 expect(items).toContain("tail-a");
172 expect(items).toContain("tail-b");
173 expect(items.indexOf("enrichment")).toBeGreaterThanOrEqual(2);
174 }),
175 );
176});
apps/server/src/orchestration-v2/ShellStream.ts+71−0
@@ -1,9 +1,80 @@
1import type {1import type {
2 OrchestrationV2ArchivedShellStreamItem,2 OrchestrationV2ArchivedShellStreamItem,
3 OrchestrationV2ShellSnapshot,
3 OrchestrationV2ThreadShellSnapshot,4 OrchestrationV2ThreadShellSnapshot,
4 OrchestrationV2ShellStreamItem,5 OrchestrationV2ShellStreamItem,
5 OrchestrationV2StoredEvent,6 OrchestrationV2StoredEvent,
6} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import * as Stream from "effect/Stream";
9
10/** Keep only the newest shell-relevant event per project/thread aggregate. */
11export function coalesceShellApplicationEvents(
12 events: ReadonlyArray<ApplicationStoredEvent>,
13): ReadonlyArray<ApplicationStoredEvent> {
14 const latestByAggregate = new Map<string, ApplicationStoredEvent>();
15 for (const stored of events) {
16 const key =
17 "aggregateKind" in stored
18 ? `project:${stored.aggregateId}`
19 : `thread:${stored.event.threadId}`;
20 latestByAggregate.set(key, stored);
21 }
22 return Array.from(latestByAggregate.values()).sort(
23 (left, right) => left.sequence - right.sequence,
24 );
25}
26
27/**
28 * Emit the initial shell prefix strictly first, then merge the post-prefix
29 * tail with enrichment refreshes. Prevents a newer marked enrichment from
30 * landing before the unmarked authoritative initial snapshot.
31 */
32export function composeShellStreamWithEnrichment<A, E, R, A2, E2, R2, A3, E3, R3>(input: {
33 readonly initial: Stream.Stream<A, E, R>;
34 readonly tail: Stream.Stream<A2, E2, R2>;
35 readonly enrichment: Stream.Stream<A3, E3, R3>;
36}): Stream.Stream<A | A2 | A3, E | E2 | E3, R | R2 | R3> {
37 return Stream.concat(input.initial, Stream.merge(input.tail, input.enrichment));
38}
39
40/** Build a shell snapshot stream item for a batched enrichment completion. */
41export function shellStreamItemFromEnrichmentRefresh(input: {
42 readonly snapshot: OrchestrationV2ShellSnapshot;
43 readonly changes: ReadonlyArray<{ readonly workspaceRoot: string }>;
44}): Extract<OrchestrationV2ShellStreamItem, { readonly kind: "snapshot" }> {
45 return {
46 kind: "snapshot",
47 snapshot: input.snapshot,
48 resolvedRepositoryIdentityRoots: [
49 ...new Set(input.changes.map((change) => change.workspaceRoot)),
50 ],
51 };
52}
53
54/**
55 * Initial subscribe frames: always emit the unmarked authoritative snapshot,
56 * then a same-sequence enrichment frame only when some roots already resolved.
57 */
58export function shellStreamItemsFromInitialSnapshot(input: {
59 readonly snapshot: OrchestrationV2ShellSnapshot;
60 readonly resolvedRepositoryIdentityRoots: ReadonlyArray<string>;
61}): ReadonlyArray<Extract<OrchestrationV2ShellStreamItem, { readonly kind: "snapshot" }>> {
62 const authoritative = {
63 kind: "snapshot" as const,
64 snapshot: input.snapshot,
65 };
66 if (input.resolvedRepositoryIdentityRoots.length === 0) {
67 return [authoritative];
68 }
69 return [
70 authoritative,
71 {
72 kind: "snapshot" as const,
73 snapshot: input.snapshot,
74 resolvedRepositoryIdentityRoots: [...new Set(input.resolvedRepositoryIdentityRoots)],
75 },
76 ];
77}
7 78
8/** Converts a committed event and its resulting shell snapshot into one delta. */79/** Converts a committed event and its resulting shell snapshot into one delta. */
9export function shellStreamItemFromSnapshot(input: {80export function shellStreamItemFromSnapshot(input: {
apps/server/src/orchestration-v2/http.ts+121−0
@@ -0,0 +1,121 @@
1import {
2 AuthOrchestrationReadScope,
3 EnvironmentHttpApi,
4 type OrchestrationProjectShell,
5} from "@t3tools/contracts";
6import * as Effect from "effect/Effect";
7import * as Predicate from "effect/Predicate";
8import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
9import * as SqlClient from "effect/unstable/sql/SqlClient";
10
11import {
12 annotateEnvironmentRequest,
13 failEnvironmentInternal,
14 failEnvironmentNotFound,
15 requireEnvironmentScope,
16} from "../auth/http.ts";
17import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
18import * as OrchestrationEventStore from "../persistence/Services/OrchestrationEventStore.ts";
19import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";
20import * as ThreadManagementService from "./ThreadManagementService.ts";
21
22function isThreadNotFound(error: unknown): boolean {
23 return (
24 Predicate.hasProperty(error, "cause") &&
25 Predicate.hasProperty(error.cause, "_tag") &&
26 error.cause._tag === "ProjectionStoreThreadNotFoundError"
27 );
28}
29
30/**
31 * Serves orchestration V2 snapshots over HTTP so clients can load the
32 * (potentially large) shell and thread projections off the socket — gzip
33 * compressible and cacheable — and then resume the WebSocket subscription via
34 * `afterSequence`.
35 */
36export const orchestrationHttpApiLayer = HttpApiBuilder.group(
37 EnvironmentHttpApi,
38 "orchestration",
39 Effect.fnUntraced(function* (handlers) {
40 const sql = yield* SqlClient.SqlClient;
41 const threadManagement = yield* ThreadManagementService.ThreadManagementService;
42 const applicationEvents = yield* OrchestrationEventStore.OrchestrationEventStore;
43 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
44 const projectEnrichment = yield* ProjectEnrichmentService.ProjectEnrichmentService;
45
46 const enrichProjectShells = Effect.fn("http.orchestration.enrichProjectShells")(
47 (projects: ReadonlyArray<OrchestrationProjectShell>) =>
48 Effect.forEach(
49 projects,
50 (project) =>
51 // Use immediately available enrichment only. Awaiting git-backed
52 // identity resolution can exceed the client shell-snapshot budget
53 // (ProcessRunner allows probes up to one minute). Background workers
54 // plus the WS enrichment subscription fill in repositoryIdentity.
55 projectEnrichment.getAvailable(project.workspaceRoot).pipe(
56 Effect.map((enrichment) => ({
57 ...project,
58 repositoryIdentity: enrichment.repositoryIdentity,
59 })),
60 ),
61 { concurrency: 16 },
62 ),
63 );
64
65 const loadShellSnapshot = Effect.fn("http.orchestration.loadShellSnapshot")(function* () {
66 const base = yield* sql.withTransaction(
67 Effect.gen(function* () {
68 const projects = yield* projectionSnapshotQuery.getShellSnapshotWithoutEnrichment();
69 const threads = yield* threadManagement.getShellSnapshot();
70 return {
71 schemaVersion: threads.schemaVersion,
72 snapshotSequence: yield* applicationEvents.latestApplicationSequence,
73 projects: projects.projects,
74 threads: threads.threads,
75 archivedThreads: threads.archivedThreads,
76 } as const;
77 }),
78 );
79 const projects = yield* enrichProjectShells(base.projects);
80 return { ...base, projects };
81 });
82
83 return handlers
84 .handle(
85 "shellSnapshot",
86 Effect.fn("environment.orchestration.shellSnapshot")(function* (args) {
87 yield* annotateEnvironmentRequest(args.endpoint.name);
88 yield* requireEnvironmentScope(AuthOrchestrationReadScope);
89 return yield* loadShellSnapshot().pipe(
90 Effect.catch((cause) =>
91 failEnvironmentInternal("orchestration_snapshot_failed", cause),
92 ),
93 );
94 }),
95 )
96 .handle(
97 "threadSnapshot",
98 Effect.fn("environment.orchestration.threadSnapshot")(function* (args) {
99 yield* annotateEnvironmentRequest(args.endpoint.name);
100 yield* requireEnvironmentScope(AuthOrchestrationReadScope);
101 const snapshot = yield* threadManagement.getThreadSnapshot(args.params.threadId).pipe(
102 Effect.catch(
103 Effect.fnUntraced(function* (error) {
104 if (isThreadNotFound(error)) {
105 return yield* failEnvironmentNotFound("thread_not_found");
106 }
107 return yield* failEnvironmentInternal(
108 "orchestration_thread_snapshot_failed",
109 error,
110 );
111 }),
112 ),
113 );
114 return {
115 snapshotSequence: snapshot.snapshotSequence,
116 projection: snapshot.projection,
117 };
118 }),
119 );
120 }),
121);
apps/server/src/orchestration-v2/runtimeLayer.test.ts+12−2
@@ -98,9 +98,19 @@ const SharedApplicationDataPlaneTestLayer = Layer.merge( · 97 unmodified lines
98).pipe(98).pipe(
99 Layer.provide(99 Layer.provide(
100 Layer.succeed(ProjectEnrichmentService, {100 Layer.succeed(ProjectEnrichmentService, {
101 peek: () => Effect.succeed({ repositoryIdentity: null, faviconPath: null }),101 peek: () =>
102 Effect.succeed({
103 repositoryIdentity: null,
104 faviconPath: null,
105 repositoryIdentityResolved: false,
106 }),
102 request: () => Effect.void,107 request: () => Effect.void,
103 getAvailable: () => Effect.succeed({ repositoryIdentity: null, faviconPath: null }),108 getAvailable: () =>
109 Effect.succeed({
110 repositoryIdentity: null,
111 faviconPath: null,
112 repositoryIdentityResolved: false,
113 }),
104 invalidate: () => Effect.void,114 invalidate: () => Effect.void,
105 subscribeChanges: Effect.never,115 subscribeChanges: Effect.never,
106 }),116 }),
apps/server/src/project/ProjectEnrichmentService.test.ts+104−0
@@ -168,6 +168,110 @@ it.effect("keeps repository workers available when every favicon worker is hung" · 167 unmodified lines
168 }),168 }),
169);169);
170 170
171it.effect("getAvailable returns immediately while repository identity is still unresolved", () =>
172 Effect.gen(function* () {
173 const repositoryStarted = yield* Deferred.make<void>();
174 const releaseRepository = yield* Deferred.make<void>();
175 const metadataLayer = Layer.merge(
176 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
177 resolve: (workspaceRoot) =>
178 Deferred.succeed(repositoryStarted, undefined).pipe(
179 Effect.andThen(Deferred.await(releaseRepository)),
180 Effect.as(identity(workspaceRoot)),
181 ),
182 }),
183 Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
184 resolvePath: (workspaceRoot) => Effect.succeed(`${workspaceRoot}/favicon.svg`),
185 }),
186 );
187
188 yield* Effect.gen(function* () {
189 const service = yield* ProjectEnrichment.ProjectEnrichmentService;
190
191 // Non-blocking path must not wait on hung git/identity resolution.
192 const immediate = yield* service.getAvailable("/pending-identity");
193 assert.isNull(immediate.repositoryIdentity);
194 assert.isNull(immediate.faviconPath);
195 assert.isFalse(immediate.repositoryIdentityResolved);
196
197 yield* Deferred.await(repositoryStarted);
198
199 // Background lane still resolves after the snapshot path has returned.
200 yield* Deferred.succeed(releaseRepository, undefined);
201 const resolved = yield* waitForAvailable(
202 service,
203 "/pending-identity",
204 (value) => value.repositoryIdentity !== null,
205 );
206 assert.equal(resolved.repositoryIdentity?.canonicalKey, "example.test/v1/pending-identity");
207 assert.isTrue(resolved.repositoryIdentityResolved);
208 }).pipe(Effect.provide(makeLayer(metadataLayer)));
209 }),
210);
211
212it.effect(
213 "reports successful cached null as resolved while cold and failed lookups stay unresolved",
214 () =>
215 Effect.gen(function* () {
216 const repositoryStarted = yield* Deferred.make<void>();
217 const releaseRepository = yield* Deferred.make<void>();
218 const metadataLayer = Layer.merge(
219 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
220 resolve: (workspaceRoot) => {
221 if (workspaceRoot === "/no-remote") {
222 return Effect.succeed(null);
223 }
224 if (workspaceRoot === "/fails") {
225 return Effect.die("repository resolver failed");
226 }
227 return Deferred.succeed(repositoryStarted, undefined).pipe(
228 Effect.andThen(Deferred.await(releaseRepository)),
229 Effect.as(identity(workspaceRoot)),
230 );
231 },
232 }),
233 Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
234 resolvePath: () => Effect.succeed(null),
235 }),
236 );
237
238 yield* Effect.gen(function* () {
239 const service = yield* ProjectEnrichment.ProjectEnrichmentService;
240 const changes = yield* service.subscribeChanges;
241
242 const cold = yield* service.peek("/never-requested");
243 assert.isNull(cold.repositoryIdentity);
244 assert.isFalse(cold.repositoryIdentityResolved);
245
246 const inFlight = yield* service.getAvailable("/in-flight");
247 assert.isNull(inFlight.repositoryIdentity);
248 assert.isFalse(inFlight.repositoryIdentityResolved);
249 yield* Deferred.await(repositoryStarted);
250 assert.isFalse((yield* service.peek("/in-flight")).repositoryIdentityResolved);
251
252 yield* service.request("/no-remote");
253 const nullChange = yield* PubSub.take(changes);
254 assert.equal(nullChange.workspaceRoot, "/no-remote");
255 assert.isTrue(nullChange.repositoryIdentityResolved);
256 assert.isNull(nullChange.enrichment.repositoryIdentity);
257 assert.isTrue(nullChange.enrichment.repositoryIdentityResolved);
258
259 // Warm success null stays resolved and does not republish.
260 const warmNull = yield* service.getAvailable("/no-remote");
261 assert.isNull(warmNull.repositoryIdentity);
262 assert.isTrue(warmNull.repositoryIdentityResolved);
263
264 yield* service.request("/fails");
265 const failChange = yield* PubSub.take(changes);
266 assert.equal(failChange.workspaceRoot, "/fails");
267 assert.isFalse(failChange.repositoryIdentityResolved);
268 const failed = yield* service.peek("/fails");
269 assert.isNull(failed.repositoryIdentity);
270 assert.isFalse(failed.repositoryIdentityResolved);
271 }).pipe(Effect.provide(makeLayer(metadataLayer)));
272 }),
273);
274
171it.effect("deduplicates requests, bounds pending work, and reloads invalidated roots", () =>275it.effect("deduplicates requests, bounds pending work, and reloads invalidated roots", () =>
172 Effect.gen(function* () {276 Effect.gen(function* () {
173 const firstStarted = yield* Deferred.make<void>();277 const firstStarted = yield* Deferred.make<void>();
apps/server/src/project/ProjectEnrichmentService.ts+13−1
@@ -24,6 +24,8 @@ const DEFAULT_FAILURE_TTL = Duration.seconds(5); · 23 unmodified lines
24export interface ProjectEnrichment {24export interface ProjectEnrichment {
25 readonly repositoryIdentity: RepositoryIdentity | null;25 readonly repositoryIdentity: RepositoryIdentity | null;
26 readonly faviconPath: string | null;26 readonly faviconPath: string | null;
27 /** True when identity resolution completed successfully, including cached null. */
28 readonly repositoryIdentityResolved: boolean;
27}29}
28 30
29export interface ProjectEnrichmentChange {31export interface ProjectEnrichmentChange {
@@ -73,6 +75,13 @@ function availableValue<A, E>(cached: Option.Option<Exit.Exit<A, E>>): A | null · 43 unmodified lines
73 });75 });
74}76}
75 77
78function isSuccessfullyResolved<A, E>(cached: Option.Option<Exit.Exit<A, E>>): boolean {
79 return Option.match(cached, {
80 onNone: () => false,
81 onSome: (exit) => Exit.isSuccess(exit),
82 });
83}
84
76interface EnrichmentWorkLane {85interface EnrichmentWorkLane {
77 readonly pendingRoots: Ref.Ref<ReadonlySet<string>>;86 readonly pendingRoots: Ref.Ref<ReadonlySet<string>>;
78 readonly queue: Queue.Queue<string>;87 readonly queue: Queue.Queue<string>;
@@ -159,12 +168,14 @@ export const make = Effect.fn("ProjectEnrichmentService.make")(function* ( · 80 unmodified lines
159 const repositoryIdentity = yield* Cache.get(repositoryIdentityCache, workspaceRoot);168 const repositoryIdentity = yield* Cache.get(repositoryIdentityCache, workspaceRoot);
160 yield* logFailure(workspaceRoot, "repositoryIdentity", repositoryIdentity);169 yield* logFailure(workspaceRoot, "repositoryIdentity", repositoryIdentity);
161 const faviconPath = yield* Cache.getSuccess(faviconCache, workspaceRoot);170 const faviconPath = yield* Cache.getSuccess(faviconCache, workspaceRoot);
171 const repositoryIdentityResolved = Exit.isSuccess(repositoryIdentity);
162 yield* PubSub.publish(changes, {172 yield* PubSub.publish(changes, {
163 workspaceRoot,173 workspaceRoot,
164 repositoryIdentityResolved: Exit.isSuccess(repositoryIdentity),174 repositoryIdentityResolved,
165 enrichment: {175 enrichment: {
166 repositoryIdentity: availableValue(Option.some(repositoryIdentity)),176 repositoryIdentity: availableValue(Option.some(repositoryIdentity)),
167 faviconPath: availableValue(faviconPath),177 faviconPath: availableValue(faviconPath),
178 repositoryIdentityResolved,
168 },179 },
169 });180 });
170 },181 },
@@ -227,6 +238,7 @@ export const make = Effect.fn("ProjectEnrichmentService.make")(function* ( · 56 unmodified lines
227 return {238 return {
228 repositoryIdentity: availableValue(repositoryIdentity),239 repositoryIdentity: availableValue(repositoryIdentity),
229 faviconPath: availableValue(faviconPath),240 faviconPath: availableValue(faviconPath),
241 repositoryIdentityResolved: isSuccessfullyResolved(repositoryIdentity),
230 };242 };
231 });243 });
232 244
apps/server/src/ws.ts+117−12
@@ -70,7 +70,11 @@ import * as ThreadLaunchService from "./orchestration-v2/ThreadLaunchService.ts" · 69 unmodified lines
70import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts";70import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts";
71import {71import {
72 archivedShellStreamItemFromSnapshot,72 archivedShellStreamItemFromSnapshot,
73 coalesceShellApplicationEvents,
74 composeShellStreamWithEnrichment,
75 shellStreamItemFromEnrichmentRefresh,
73 shellStreamItemFromSnapshot,76 shellStreamItemFromSnapshot,
77 shellStreamItemsFromInitialSnapshot,
74} from "./orchestration-v2/ShellStream.ts";78} from "./orchestration-v2/ShellStream.ts";
75import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";79import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
76import * as OrchestrationEventStore from "./persistence/Services/OrchestrationEventStore.ts";80import * as OrchestrationEventStore from "./persistence/Services/OrchestrationEventStore.ts";
@@ -429,13 +433,28 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => · 352 unmodified lines
429 Effect.forEach(433 Effect.forEach(
430 projects,434 projects,
431 (project) =>435 (project) =>
436 // Non-blocking: emit with cached identity (or null) and schedule
437 // background resolution. subscribeChanges is attached before
438 // loadSnapshot, so later identity completions push refreshed
439 // shells for multi-env grouping without blocking the initial
440 // snapshot or completion marker on slow git probes.
432 projectEnrichment.getAvailable(project.workspaceRoot).pipe(441 projectEnrichment.getAvailable(project.workspaceRoot).pipe(
433 Effect.map((enrichment) => ({442 Effect.map((enrichment) => ({
434 ...project,443 project: {
435 repositoryIdentity: enrichment.repositoryIdentity,444 ...project,
445 repositoryIdentity: enrichment.repositoryIdentity,
446 },
447 repositoryIdentityResolved: enrichment.repositoryIdentityResolved,
436 })),448 })),
437 ),449 ),
438 { concurrency: 16 },450 { concurrency: 16 },
451 ).pipe(
452 Effect.map((enriched) => ({
453 projects: enriched.map((entry) => entry.project),
454 resolvedRepositoryIdentityRoots: enriched
455 .filter((entry) => entry.repositoryIdentityResolved)
456 .map((entry) => entry.project.workspaceRoot),
457 })),
439 ),458 ),
440 );459 );
441 const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService;460 const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService;
@@ -660,8 +679,11 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => · 218 unmodified lines
660 } as const;679 } as const;
661 }),680 }),
662 );681 );
663 const projects = yield* enrichProjectShells(base.projects);682 const enriched = yield* enrichProjectShells(base.projects);
664 return { ...base, projects };683 return {
684 snapshot: { ...base, projects: enriched.projects } as OrchestrationV2ShellSnapshot,
685 resolvedRepositoryIdentityRoots: enriched.resolvedRepositoryIdentityRoots,
686 };
665 });687 });
666 const snapshot = yield* loadSnapshot().pipe(688 const snapshot = yield* loadSnapshot().pipe(
667 Effect.mapError(689 Effect.mapError(
@@ -717,15 +739,98 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => · 49 unmodified lines
717 739
718 const enrichmentRefreshes = Stream.fromSubscription(enrichmentChanges).pipe(740 const enrichmentRefreshes = Stream.fromSubscription(enrichmentChanges).pipe(
719 Stream.filter((change) => change.repositoryIdentityResolved),741 Stream.filter((change) => change.repositoryIdentityResolved),
720 Stream.debounce("25 millis"),742 Stream.groupedWithin(64, Duration.millis(25)),
721 Stream.mapEffect(() => loadSnapshot()),743 Stream.mapEffect((changes) =>
722 Stream.map((snapshot) => ({ kind: "snapshot" as const, snapshot })),744 loadSnapshot().pipe(
745 Effect.map(({ snapshot }) =>
746 shellStreamItemFromEnrichmentRefresh({
747 snapshot,
748 changes: Array.from(changes),
749 }),
750 ),
751 ),
752 ),
723 );753 );
724 754
725 return Stream.merge(755 // Always attach the enrichment subscription before the first load so
726 Stream.concat(Stream.make({ kind: "snapshot" as const, snapshot }), live),756 // completions that race HTTP snapshot fetch still push a refresh.
727 enrichmentRefreshes,757 // When the client already holds a shell snapshot (cached, or loaded
728 ).pipe(758 // over HTTP) it passes that snapshot's sequence. We still emit one
759 // enriched snapshot up front: getAvailable may have been cold on the
760 // HTTP path (null identity), and enrichment PubSub events published
761 // before this subscribe attached are dropped. Rehydrating here fills
762 // repositoryIdentity for cross-environment project grouping even on
763 // afterSequence resumes. Application events after the sequence still
764 // stream as deltas; overlapping events are deduped by sequence on the
765 // client.
766 //
767 // After the unmarked authoritative frame, emit a same-sequence
768 // metadata-only frame for roots that already resolved successfully
769 // (including cached null). Cold/failed roots stay unmarked and use
770 // the PubSub enrichment path when they complete later.
771 const completionMarker =
772 input.requestCompletionMarker === true
773 ? Stream.make({ kind: "synchronized" as const })
774 : Stream.empty;
775 const initialSnapshotItems = (loaded: {
776 readonly snapshot: OrchestrationV2ShellSnapshot;
777 readonly resolvedRepositoryIdentityRoots: ReadonlyArray<string>;
778 }) =>
779 Stream.fromIterable(
780 shellStreamItemsFromInitialSnapshot({
781 snapshot: loaded.snapshot,
782 resolvedRepositoryIdentityRoots: loaded.resolvedRepositoryIdentityRoots,
783 }),
784 );
785 // Initial unmarked (+ optional same-load marked) always drains first.
786 // Enrichment merges only with the post-prefix tail so a ready marked
787 // refresh cannot interleave before the authoritative initial frame.
788 const completionThenLive = (afterSequence: number) =>
789 Stream.concat(completionMarker, liveFrom(afterSequence));
790
791 const stream = yield* Effect.gen(function* () {
792 const loaded = yield* loadSnapshot();
793 const initial = initialSnapshotItems(loaded);
794 if (input.afterSequence === undefined) {
795 return composeShellStreamWithEnrichment({
796 initial,
797 tail: completionThenLive(loaded.snapshot.snapshotSequence),
798 enrichment: enrichmentRefreshes,
799 });
800 }
801
802 const highWater = yield* applicationEvents.latestApplicationSequence;
803 const replayGap = highWater - input.afterSequence;
804 if (replayGap < 0 || replayGap > 1_000) {
805 return composeShellStreamWithEnrichment({
806 initial,
807 tail: completionThenLive(loaded.snapshot.snapshotSequence),
808 enrichment: enrichmentRefreshes,
809 });
810 }
811
812 const replay = toShellStream(
813 applicationEvents.readApplicationEvents({
814 afterSequence: input.afterSequence,
815 throughSequence: highWater,
816 }),
817 );
818 return composeShellStreamWithEnrichment({
819 initial,
820 tail: Stream.concat(Stream.concat(replay, completionMarker), liveFrom(highWater)),
821 enrichment: enrichmentRefreshes,
822 });
823 }).pipe(
824 Effect.mapError(
825 (cause) =>
826 new OrchestrationV2GetShellSnapshotError({
827 message: "Failed to prepare the application shell stream",
828 cause,
829 }),
830 ),
831 );
832
833 return stream.pipe(
729 Stream.mapError(834 Stream.mapError(
730 (cause) =>835 (cause) =>
731 new OrchestrationV2GetShellSnapshotError({836 new OrchestrationV2GetShellSnapshotError({
@@ -753,7 +858,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => · 21 unmodified lines
753 .pipe(858 .pipe(
754 Effect.flatMap((snapshot) =>859 Effect.flatMap((snapshot) =>
755 enrichProjectShells(snapshot.projects).pipe(860 enrichProjectShells(snapshot.projects).pipe(
756 Effect.map((projects) => ({ ...snapshot, projects })),861 Effect.map(({ projects }) => ({ ...snapshot, projects })),
757 ),862 ),
758 ),863 ),
759 Effect.mapError(864 Effect.mapError(
packages/client-runtime/src/platform/orchestrationCache.test.ts+87−13
@@ -1,5 +1,11 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { EnvironmentId } from "@t3tools/contracts";2import {
3 EnvironmentId,
4 MessageId,
5 RuntimeRequestId,
6 type OrchestrationV2ShellSnapshot,
7} from "@t3tools/contracts";
8import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";10import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";11import * as Schema from "effect/Schema";
@@ -10,39 +16,107 @@ import { · 4 unmodified lines
10 StoredOrchestrationThreadSnapshot,16 StoredOrchestrationThreadSnapshot,
11 decodeOrDiscardOrchestrationCache,17 decodeOrDiscardOrchestrationCache,
12} from "./orchestrationCache.ts";18} from "./orchestrationCache.ts";
13import { v2Projection, v2ShellSnapshot, v2ThreadId } from "../state/orchestrationV2TestFixtures.ts";19import {
20 v2Now,
21 v2Projection,
22 v2ShellSnapshot,
23 v2ThreadShell,
24 v2ThreadId,
25} from "../state/orchestrationV2TestFixtures.ts";
14 26
15const environmentId = EnvironmentId.make("environment-cache-test");27const environmentId = EnvironmentId.make("environment-cache-test");
16const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredOrchestrationShellSnapshot);28const StoredShellSnapshotJson = Schema.fromJsonString(StoredOrchestrationShellSnapshot);
17const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredOrchestrationThreadSnapshot);29const StoredThreadSnapshotJson = Schema.fromJsonString(StoredOrchestrationThreadSnapshot);
30const encodeStoredShellSnapshot = Schema.encodeSync(StoredOrchestrationShellSnapshot);
31const decodeStoredShellSnapshotSync = Schema.decodeUnknownSync(StoredOrchestrationShellSnapshot);
32const encodeStoredShellSnapshotJson = Schema.encodeSync(StoredShellSnapshotJson);
33const decodeStoredShellSnapshotJson = Schema.decodeUnknownSync(StoredShellSnapshotJson);
34const encodeStoredThreadSnapshotJson = Schema.encodeSync(StoredThreadSnapshotJson);
35const decodeStoredThreadSnapshotJson = Schema.decodeUnknownSync(StoredThreadSnapshotJson);
36
37class TestCacheDecodeError extends Schema.TaggedErrorClass<TestCacheDecodeError>()(
38 "TestCacheDecodeError",
39 {
40 message: Schema.String,
41 },
42) {}
43
44const shellSnapshotWithSummaries: OrchestrationV2ShellSnapshot = {
45 ...v2ShellSnapshot,
46 threads: [
47 {
48 ...v2ThreadShell,
49 pendingRuntimeRequest: {
50 id: RuntimeRequestId.make("runtime-request-v2"),
51 kind: "command",
52 createdAt: v2Now,
53 },
54 latestVisibleMessage: {
55 id: MessageId.make("message-v2"),
56 role: "assistant",
57 text: "Done",
58 updatedAt: v2Now,
59 },
60 latestUserMessageAt: v2Now,
61 settledAt: v2Now,
62 },
63 ],
64};
18 65
19describe("orchestration cache envelopes", () => {66describe("orchestration cache envelopes", () => {
20 it.effect("accepts V2 shell and thread cache envelopes", () =>67 it.effect("round-trips V2 shell and thread cache envelopes as JSON", () =>
21 Effect.gen(function* () {68 Effect.sync(() => {
22 const shell = yield* decodeStoredShellSnapshot({69 const encodedShell = encodeStoredShellSnapshotJson({
23 schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION,70 schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION,
24 environmentId,71 environmentId,
25 snapshot: v2ShellSnapshot,72 snapshot: shellSnapshotWithSummaries,
26 });73 });
27 const thread = yield* decodeStoredThreadSnapshot({74 const shell = decodeStoredShellSnapshotJson(encodedShell);
75 const encodedThread = encodeStoredThreadSnapshotJson({
28 schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION,76 schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION,
29 environmentId,77 environmentId,
30 threadId: v2ThreadId,78 threadId: v2ThreadId,
31 thread: v2Projection,79 thread: v2Projection,
32 });80 });
81 const thread = decodeStoredThreadSnapshotJson(encodedThread);
82 const [threadShell] = shell.snapshot.threads;
33 83
34 expect(shell.snapshot).toEqual(v2ShellSnapshot);84 expect(threadShell?.latestVisibleMessage?.text).toBe("Done");
35 expect(thread.thread).toEqual(v2Projection);85 expect(
86 threadShell?.latestVisibleMessage === null ||
87 threadShell?.latestVisibleMessage === undefined
88 ? null
89 : DateTime.formatIso(threadShell.latestVisibleMessage.updatedAt),
90 ).toBe("2026-06-20T00:00:00.000Z");
91 expect(
92 threadShell?.latestUserMessageAt === null || threadShell?.latestUserMessageAt === undefined
93 ? null
94 : DateTime.formatIso(threadShell.latestUserMessageAt),
95 ).toBe("2026-06-20T00:00:00.000Z");
96 expect(
97 threadShell?.settledAt === null || threadShell?.settledAt === undefined
98 ? null
99 : DateTime.formatIso(threadShell.settledAt),
100 ).toBe("2026-06-20T00:00:00.000Z");
101 expect(DateTime.formatIso(thread.snapshot.projection.thread.updatedAt)).toBe(
102 "2026-06-20T00:00:00.000Z",
103 );
104 expect(thread.snapshot.projection).toEqual(v2Projection);
105 expect(thread.snapshot.snapshotSequence).toBe(4);
36 }),106 }),
37 );107 );
38 108
39 it.effect("discards V1-versioned cache envelopes after a decode failure", () =>109 it.effect("discards V1-versioned cache envelopes after a decode failure", () =>
40 Effect.gen(function* () {110 Effect.gen(function* () {
41 let discardCount = 0;111 let discardCount = 0;
42 const decoded = decodeStoredShellSnapshot({112 const encodedShell = encodeStoredShellSnapshot({
43 schemaVersion: 1,113 schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION,
44 environmentId,114 environmentId,
45 snapshot: v2ShellSnapshot,115 snapshot: v2ShellSnapshot,
116 });
117 const decoded = Effect.try({
118 try: () => decodeStoredShellSnapshotSync({ ...encodedShell, schemaVersion: 1 }),
119 catch: (cause) => new TestCacheDecodeError({ message: String(cause) }),
46 }).pipe(Effect.map(Option.some));120 }).pipe(Effect.map(Option.some));
47 121
48 const result = yield* decodeOrDiscardOrchestrationCache(122 const result = yield* decodeOrDiscardOrchestrationCache(
packages/client-runtime/src/platform/orchestrationCache.ts+8−4
@@ -1,7 +1,8 @@
1import {1import {
2 EnvironmentId,2 EnvironmentId,
3 OrchestrationV2ShellSnapshot,3 OrchestrationV2ShellSnapshotJson,
4 OrchestrationV2ThreadProjection,4 OrchestrationV2ThreadDetailSnapshot,
5 OrchestrationV2ThreadProjectionJson,
5 ThreadId,6 ThreadId,
6} from "@t3tools/contracts";7} from "@t3tools/contracts";
7import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
@@ -13,14 +14,17 @@ export const ORCHESTRATION_CACHE_SCHEMA_VERSION = 2 as const; · 5 unmodified lines
13export const StoredOrchestrationShellSnapshot = Schema.Struct({14export const StoredOrchestrationShellSnapshot = Schema.Struct({
14 schemaVersion: Schema.Literal(ORCHESTRATION_CACHE_SCHEMA_VERSION),15 schemaVersion: Schema.Literal(ORCHESTRATION_CACHE_SCHEMA_VERSION),
15 environmentId: EnvironmentId,16 environmentId: EnvironmentId,
16 snapshot: OrchestrationV2ShellSnapshot,17 snapshot: OrchestrationV2ShellSnapshotJson,
17});18});
18 19
19export const StoredOrchestrationThreadSnapshot = Schema.Struct({20export const StoredOrchestrationThreadSnapshot = Schema.Struct({
20 schemaVersion: Schema.Literal(ORCHESTRATION_CACHE_SCHEMA_VERSION),21 schemaVersion: Schema.Literal(ORCHESTRATION_CACHE_SCHEMA_VERSION),
21 environmentId: EnvironmentId,22 environmentId: EnvironmentId,
22 threadId: ThreadId,23 threadId: ThreadId,
23 thread: OrchestrationV2ThreadProjection,24 snapshot: OrchestrationV2ThreadDetailSnapshot.mapFields((fields) => ({
25 ...fields,
26 projection: OrchestrationV2ThreadProjectionJson,
27 })),
24});28});
25 29
26/** Invalid orchestration caches are disposable and must never block live synchronization. */30/** Invalid orchestration caches are disposable and must never block live synchronization. */
packages/client-runtime/src/state/shell-sync.test.ts+418−3
@@ -5,10 +5,14 @@ import { · 4 unmodified lines
5 type OrchestrationV2ShellStreamItem,5 type OrchestrationV2ShellStreamItem,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import { describe, expect, it } from "@effect/vitest";7import { describe, expect, it } from "@effect/vitest";
8import * as Deferred from "effect/Deferred";
8import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
10import * as Exit from "effect/Exit";
11import * as Fiber from "effect/Fiber";
9import * as Option from "effect/Option";12import * as Option from "effect/Option";
10import * as Queue from "effect/Queue";13import * as Queue from "effect/Queue";
11import * as Ref from "effect/Ref";14import * as Ref from "effect/Ref";
15import * as Scope from "effect/Scope";
12import * as Stream from "effect/Stream";16import * as Stream from "effect/Stream";
13import * as SubscriptionRef from "effect/SubscriptionRef";17import * as SubscriptionRef from "effect/SubscriptionRef";
14 18
@@ -22,8 +26,8 @@ import * as ConnectionWakeups from "../connection/wakeups.ts"; · 7 unmodified lines
22import * as Persistence from "../platform/persistence.ts";26import * as Persistence from "../platform/persistence.ts";
23import * as RpcSession from "../rpc/session.ts";27import * as RpcSession from "../rpc/session.ts";
24import type { WsRpcProtocolClient } from "../rpc/protocol.ts";28import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
25import { makeEnvironmentShellState } from "./shell.ts";29import { makeEnvironmentShellState, ShellSnapshotLoader } from "./shell.ts";
26import { v2ShellSnapshot } from "./orchestrationV2TestFixtures.ts";30import { v2Project, v2ShellSnapshot } from "./orchestrationV2TestFixtures.ts";
27 31
28const TARGET = new PrimaryConnectionTarget({32const TARGET = new PrimaryConnectionTarget({
29 environmentId: EnvironmentId.make("environment-1"),33 environmentId: EnvironmentId.make("environment-1"),
@@ -70,7 +74,7 @@ describe("environment shell synchronization", () => { · 40 unmodified lines
70 } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);74 } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
71 const cache = Persistence.EnvironmentCacheStore.of({75 const cache = Persistence.EnvironmentCacheStore.of({
72 loadShell: () => Effect.succeed(Option.none()),76 loadShell: () => Effect.succeed(Option.none()),
73 saveShell: () => Effect.never,77 saveShell: () => Effect.void,
74 loadThread: () => Effect.succeed(Option.none()),78 loadThread: () => Effect.succeed(Option.none()),
75 saveThread: () => Effect.void,79 saveThread: () => Effect.void,
76 removeThread: () => Effect.void,80 removeThread: () => Effect.void,
@@ -352,4 +356,415 @@ describe("environment shell synchronization", () => { · 275 unmodified lines
352 expect(yield* Ref.get(loaderCalls)).toBe(2);356 expect(yield* Ref.get(loaderCalls)).toBe(2);
353 }),357 }),
354 );358 );
359
360 it.effect("flushes the latest live snapshot without blocking connection state updates", () =>
361 Effect.gen(function* () {
362 const events = yield* Queue.unbounded<OrchestrationV2ShellStreamItem>();
363 const saved = yield* Ref.make<ReadonlyArray<OrchestrationV2ShellSnapshot>>([]);
364 const saveStarted = yield* Deferred.make<void>();
365 const releaseSave = yield* Deferred.make<void>();
366 const client = {
367 [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events),
368 } as unknown as WsRpcProtocolClient;
369 const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE);
370 const activeSession = yield* SubscriptionRef.make<Option.Option<RpcSession.RpcSession>>(
371 Option.some(session(client)),
372 );
373 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
374 target: TARGET,
375 state: supervisorState,
376 session: activeSession,
377 prepared: yield* SubscriptionRef.make(Option.some(PREPARED)),
378 connect: Effect.void,
379 disconnect: Effect.void,
380 retryNow: Effect.void,
381 } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
382 const cache = Persistence.EnvironmentCacheStore.of({
383 loadShell: () => Effect.succeed(Option.none()),
384 saveShell: (_environmentId, snapshot) =>
385 Deferred.succeed(saveStarted, undefined).pipe(
386 Effect.andThen(Deferred.await(releaseSave)),
387 Effect.andThen(Ref.update(saved, (values) => [...values, snapshot])),
388 ),
389 loadThread: () => Effect.succeed(Option.none()),
390 saveThread: () => Effect.void,
391 removeThread: () => Effect.void,
392 loadServerConfig: () => Effect.succeed(Option.none()),
393 saveServerConfig: () => Effect.void,
394 loadVcsRefs: () => Effect.succeed(Option.none()),
395 saveVcsRefs: () => Effect.void,
396 clear: () => Effect.void,
397 });
398 const snapshotLoader = ShellSnapshotLoader.of({
399 load: () => Effect.succeed(Option.none()),
400 });
401
402 const shellState = yield* makeEnvironmentShellState().pipe(
403 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
404 Effect.provideService(Persistence.EnvironmentCacheStore, cache),
405 Effect.provideService(ShellSnapshotLoader, snapshotLoader),
406 );
407
408 yield* SubscriptionRef.set(supervisorState, {
409 desired: true,
410 network: "online",
411 phase: "connecting",
412 stage: "synchronizing",
413 attempt: 1,
414 generation: 0,
415 lastFailure: null,
416 retryAt: null,
417 });
418 yield* Queue.offer(events, {
419 kind: "snapshot",
420 snapshot: LIVE_SHELL_SNAPSHOT,
421 });
422 yield* Queue.offer(events, { kind: "synchronized" });
423 yield* SubscriptionRef.changes(shellState).pipe(
424 Stream.filter((state) => state.status === "live"),
425 Stream.runHead,
426 );
427
428 yield* SubscriptionRef.set(supervisorState, {
429 desired: true,
430 network: "online",
431 phase: "connecting",
432 stage: "synchronizing",
433 attempt: 2,
434 generation: 1,
435 lastFailure: null,
436 retryAt: null,
437 });
438 yield* SubscriptionRef.set(supervisorState, {
439 desired: false,
440 network: "online",
441 phase: "available",
442 stage: null,
443 attempt: 2,
444 generation: 2,
445 lastFailure: null,
446 retryAt: null,
447 });
448 yield* Deferred.await(saveStarted);
449
450 yield* SubscriptionRef.set(supervisorState, {
451 desired: true,
452 network: "online",
453 phase: "connecting",
454 stage: "synchronizing",
455 attempt: 3,
456 generation: 3,
457 lastFailure: null,
458 retryAt: null,
459 });
460 for (let index = 0; index < 100; index += 1) {
461 if ((yield* SubscriptionRef.get(shellState)).status === "synchronizing") break;
462 yield* Effect.yieldNow;
463 }
464
465 expect((yield* SubscriptionRef.get(shellState)).status).toBe("synchronizing");
466 expect(yield* Ref.get(saved)).toEqual([]);
467
468 yield* Deferred.succeed(releaseSave, undefined);
469 for (let index = 0; index < 100; index += 1) {
470 if ((yield* Ref.get(saved)).length > 0) break;
471 yield* Effect.yieldNow;
472 }
473
474 expect(yield* Ref.get(saved)).toEqual([LIVE_SHELL_SNAPSHOT]);
475 }),
476 );
477
478 it.effect("re-persists same-sequence enrichment when an older in-flight save completes", () =>
479 Effect.gen(function* () {
480 const repositoryIdentity = {
481 canonicalKey: "github.com/example/repo",
482 locator: {
483 source: "git-remote" as const,
484 remoteName: "origin",
485 remoteUrl: "https://github.com/example/repo.git",
486 },
487 };
488 const unenrichedSnapshot: OrchestrationV2ShellSnapshot = {
489 ...LIVE_SHELL_SNAPSHOT,
490 snapshotSequence: 1,
491 projects: [{ ...v2Project, repositoryIdentity: null }],
492 };
493 const enrichedSnapshot: OrchestrationV2ShellSnapshot = {
494 ...LIVE_SHELL_SNAPSHOT,
495 snapshotSequence: 1,
496 projects: [{ ...v2Project, repositoryIdentity }],
497 };
498
499 const events = yield* Queue.unbounded<OrchestrationV2ShellStreamItem>();
500 const saved = yield* Ref.make<ReadonlyArray<OrchestrationV2ShellSnapshot>>([]);
501 const unenrichedSaveStarted = yield* Deferred.make<void>();
502 const releaseUnenrichedSave = yield* Deferred.make<void>();
503 const client = {
504 [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events),
505 } as unknown as WsRpcProtocolClient;
506 const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE);
507 const activeSession = yield* SubscriptionRef.make<Option.Option<RpcSession.RpcSession>>(
508 Option.some(session(client)),
509 );
510 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
511 target: TARGET,
512 state: supervisorState,
513 session: activeSession,
514 prepared: yield* SubscriptionRef.make(Option.some(PREPARED)),
515 connect: Effect.void,
516 disconnect: Effect.void,
517 retryNow: Effect.void,
518 } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
519 const cache = Persistence.EnvironmentCacheStore.of({
520 loadShell: () => Effect.succeed(Option.none()),
521 saveShell: (_environmentId, snapshot) =>
522 Effect.gen(function* () {
523 if (snapshot.projects[0]?.repositoryIdentity == null) {
524 yield* Deferred.succeed(unenrichedSaveStarted, undefined);
525 yield* Deferred.await(releaseUnenrichedSave);
526 }
527 yield* Ref.update(saved, (values) => [...values, snapshot]);
528 }),
529 loadThread: () => Effect.succeed(Option.none()),
530 saveThread: () => Effect.void,
531 removeThread: () => Effect.void,
532 loadServerConfig: () => Effect.succeed(Option.none()),
533 saveServerConfig: () => Effect.void,
534 loadVcsRefs: () => Effect.succeed(Option.none()),
535 saveVcsRefs: () => Effect.void,
536 clear: () => Effect.void,
537 });
538 const snapshotLoader = ShellSnapshotLoader.of({
539 load: () => Effect.succeed(Option.none()),
540 });
541
542 const shellState = yield* makeEnvironmentShellState().pipe(
543 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
544 Effect.provideService(Persistence.EnvironmentCacheStore, cache),
545 Effect.provideService(ShellSnapshotLoader, snapshotLoader),
546 );
547
548 yield* SubscriptionRef.set(supervisorState, {
549 desired: true,
550 network: "online",
551 phase: "connecting",
552 stage: "synchronizing",
553 attempt: 1,
554 generation: 0,
555 lastFailure: null,
556 retryAt: null,
557 });
558 yield* Queue.offer(events, {
559 kind: "snapshot",
560 snapshot: unenrichedSnapshot,
561 });
562 yield* Queue.offer(events, { kind: "synchronized" });
563 yield* SubscriptionRef.changes(shellState).pipe(
564 Stream.filter((state) => state.status === "live"),
565 Stream.runHead,
566 );
567
568 // Disconnect flush starts an immediate persist of the unenriched snapshot
569 // (debounced path needs TestClock advancement; flush does not).
570 yield* SubscriptionRef.set(supervisorState, {
571 desired: false,
572 network: "online",
573 phase: "available",
574 stage: null,
575 attempt: 1,
576 generation: 1,
577 lastFailure: null,
578 retryAt: null,
579 });
580 yield* Deferred.await(unenrichedSaveStarted);
581
582 // Same sequence, different content. Session stays open so the stream can
583 // still apply enrichment while the older save is in flight.
584 yield* Queue.offer(events, {
585 kind: "snapshot",
586 snapshot: enrichedSnapshot,
587 });
588 yield* SubscriptionRef.changes(shellState).pipe(
589 Stream.filter(
590 (state) =>
591 Option.isSome(state.snapshot) &&
592 state.snapshot.value.projects[0]?.repositoryIdentity != null,
593 ),
594 Stream.runHead,
595 );
596
597 // Completing the older same-sequence save must re-persist the latest object.
598 yield* Deferred.succeed(releaseUnenrichedSave, undefined);
599 for (let index = 0; index < 100; index += 1) {
600 const values = yield* Ref.get(saved);
601 if (values.length > 0 && values.at(-1)?.projects[0]?.repositoryIdentity != null) {
602 break;
603 }
604 yield* Effect.yieldNow;
605 }
606
607 const finalSaved = (yield* Ref.get(saved)).at(-1);
608 expect(finalSaved?.snapshotSequence).toBe(1);
609 expect(finalSaved?.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity);
610 }),
611 );
612
613 it.effect("scope teardown flush stays stable while a save is blocked", () =>
614 Effect.gen(function* () {
615 const lateSnapshot: OrchestrationV2ShellSnapshot = {
616 ...LIVE_SHELL_SNAPSHOT,
617 snapshotSequence: 2,
618 projects: [{ ...v2Project, title: "Late stream event" }],
619 };
620 const events = yield* Queue.unbounded<OrchestrationV2ShellStreamItem>();
621 const saved = yield* Ref.make<ReadonlyArray<OrchestrationV2ShellSnapshot>>([]);
622 const saveStarted = yield* Deferred.make<void>();
623 const releaseSave = yield* Deferred.make<void>();
624 const client = {
625 [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events),
626 } as unknown as WsRpcProtocolClient;
627 const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE);
628 const activeSession = yield* SubscriptionRef.make<Option.Option<RpcSession.RpcSession>>(
629 Option.some(session(client)),
630 );
631 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
632 target: TARGET,
633 state: supervisorState,
634 session: activeSession,
635 prepared: yield* SubscriptionRef.make(Option.some(PREPARED)),
636 connect: Effect.void,
637 disconnect: Effect.void,
638 retryNow: Effect.void,
639 } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
640 const cache = Persistence.EnvironmentCacheStore.of({
641 loadShell: () => Effect.succeed(Option.none()),
642 saveShell: (_environmentId, snapshot) =>
643 Deferred.succeed(saveStarted, undefined).pipe(
644 Effect.andThen(Deferred.await(releaseSave)),
645 Effect.andThen(Ref.update(saved, (values) => [...values, snapshot])),
646 ),
647 loadThread: () => Effect.succeed(Option.none()),
648 saveThread: () => Effect.void,
649 removeThread: () => Effect.void,
650 loadServerConfig: () => Effect.succeed(Option.none()),
651 saveServerConfig: () => Effect.void,
652 loadVcsRefs: () => Effect.succeed(Option.none()),
653 saveVcsRefs: () => Effect.void,
654 clear: () => Effect.void,
655 });
656 const snapshotLoader = ShellSnapshotLoader.of({
657 load: () => Effect.succeed(Option.none()),
658 });
659
660 const scope = yield* Scope.make();
661 const shellState = yield* makeEnvironmentShellState().pipe(
662 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
663 Effect.provideService(Persistence.EnvironmentCacheStore, cache),
664 Effect.provideService(ShellSnapshotLoader, snapshotLoader),
665 Scope.provide(scope),
666 );
667
668 yield* SubscriptionRef.set(supervisorState, {
669 desired: true,
670 network: "online",
671 phase: "connecting",
672 stage: "synchronizing",
673 attempt: 1,
674 generation: 0,
675 lastFailure: null,
676 retryAt: null,
677 });
678 yield* Queue.offer(events, {
679 kind: "snapshot",
680 snapshot: LIVE_SHELL_SNAPSHOT,
681 });
682 yield* Queue.offer(events, { kind: "synchronized" });
683 yield* SubscriptionRef.changes(shellState).pipe(
684 Stream.filter((state) => state.status === "live"),
685 Stream.runHead,
686 );
687
688 // Close scope so the finalizer flush blocks on save. Workers must be
689 // interrupted before that flush; late stream events must not extend it.
690 const closeFiber = yield* Effect.forkChild(Scope.close(scope, Exit.void));
691 yield* Deferred.await(saveStarted);
692 yield* Queue.offer(events, {
693 kind: "snapshot",
694 snapshot: lateSnapshot,
695 });
696 for (let index = 0; index < 50; index += 1) {
697 yield* Effect.yieldNow;
698 }
699 yield* Deferred.succeed(releaseSave, undefined);
700 yield* Fiber.join(closeFiber);
701
702 const values = yield* Ref.get(saved);
703 expect(values.length).toBeGreaterThan(0);
704 expect(values.every((snapshot) => snapshot.snapshotSequence === 1)).toBe(true);
705 expect(values.every((snapshot) => snapshot.projects[0]?.title !== "Late stream event")).toBe(
706 true,
707 );
708 }),
709 );
710
711 it.effect("applies authoritative lower-sequence HTTP resets over client-ahead cache", () =>
712 Effect.gen(function* () {
713 const cachedSnapshot: OrchestrationV2ShellSnapshot = {
714 ...v2ShellSnapshot,
715 snapshotSequence: 20,
716 projects: [{ ...v2Project, title: "Client ahead" }],
717 };
718 const httpSnapshot: OrchestrationV2ShellSnapshot = {
719 ...v2ShellSnapshot,
720 snapshotSequence: 4,
721 projects: [{ ...v2Project, title: "Server reset" }],
722 };
723 const events = yield* Queue.unbounded<OrchestrationV2ShellStreamItem>();
724 const client = {
725 [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events),
726 } as unknown as WsRpcProtocolClient;
727 const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE);
728 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
729 target: TARGET,
730 state: supervisorState,
731 session: yield* SubscriptionRef.make(Option.some(session(client))),
732 prepared: yield* SubscriptionRef.make(Option.some(PREPARED)),
733 connect: Effect.void,
734 disconnect: Effect.void,
735 retryNow: Effect.void,
736 } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
737 const cache = Persistence.EnvironmentCacheStore.of({
738 loadShell: () => Effect.succeed(Option.some(cachedSnapshot)),
739 saveShell: () => Effect.void,
740 loadThread: () => Effect.succeed(Option.none()),
741 saveThread: () => Effect.void,
742 removeThread: () => Effect.void,
743 loadServerConfig: () => Effect.succeed(Option.none()),
744 saveServerConfig: () => Effect.void,
745 loadVcsRefs: () => Effect.succeed(Option.none()),
746 saveVcsRefs: () => Effect.void,
747 clear: () => Effect.void,
748 });
749 const snapshotLoader = ShellSnapshotLoader.of({
750 load: () => Effect.succeed(Option.some(httpSnapshot)),
751 });
752 const shellState = yield* makeEnvironmentShellState().pipe(
753 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
754 Effect.provideService(Persistence.EnvironmentCacheStore, cache),
755 Effect.provideService(ShellSnapshotLoader, snapshotLoader),
756 );
757
758 yield* SubscriptionRef.changes(shellState).pipe(
759 Stream.filter(
760 (state) => Option.isSome(state.snapshot) && state.snapshot.value.snapshotSequence === 4,
761 ),
762 Stream.runHead,
763 );
764
765 const state = yield* SubscriptionRef.get(shellState);
766 expect(Option.getOrThrow(state.snapshot).snapshotSequence).toBe(4);
767 expect(Option.getOrThrow(state.snapshot).projects[0]?.title).toBe("Server reset");
768 }),
769 );
355});770});
packages/client-runtime/src/state/shell.ts+48−11
@@ -24,7 +24,7 @@ import { EnvironmentCacheStore } from "../platform/persistence.ts"; · 23 unmodified lines
24import { subscribeDynamic } from "../rpc/client.ts";24import { subscribeDynamic } from "../rpc/client.ts";
25import type { RpcSession } from "../rpc/session.ts";25import type { RpcSession } from "../rpc/session.ts";
26import { ShellSnapshotLoader } from "./shellSnapshotHttp.ts";26import { ShellSnapshotLoader } from "./shellSnapshotHttp.ts";
27import { applyShellStreamEvent } from "./shellReducer.ts";27import { applyShellStreamEvent, mergeShellSnapshotProjects } from "./shellReducer.ts";
28import type { EnvironmentCatalogState } from "./connections.ts";28import type { EnvironmentCatalogState } from "./connections.ts";
29import { followStreamInEnvironment } from "./runtime.ts";29import { followStreamInEnvironment } from "./runtime.ts";
30 30
@@ -72,23 +72,49 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") · 41 unmodified lines
72 status: shellStatusForSnapshot(cachedSnapshot),72 status: shellStatusForSnapshot(cachedSnapshot),
73 error: Option.none(),73 error: Option.none(),
74 });74 });
75 const awaitingCompletion = yield* Ref.make(false);
76 const latestLiveSnapshot = yield* Ref.make<Option.Option<OrchestrationV2ShellSnapshot>>(
77 Option.none(),
78 );
75 const persistence = yield* Queue.sliding<OrchestrationV2ShellSnapshot>(1);79 const persistence = yield* Queue.sliding<OrchestrationV2ShellSnapshot>(1);
76 80
77 const persist = Effect.fn("EnvironmentShellState.persist")(function* (81 const persist = Effect.fn("EnvironmentShellState.persist")(function* (
78 snapshot: OrchestrationV2ShellSnapshot,82 snapshot: OrchestrationV2ShellSnapshot,
79 ) {83 ) {
80 yield* cache.saveShell(environmentId, snapshot).pipe(84 let nextSnapshot = snapshot;
81 Effect.catch((error) =>85 while (true) {
82 Effect.logWarning("Could not persist environment shell cache.").pipe(86 yield* cache.saveShell(environmentId, nextSnapshot).pipe(
83 Effect.annotateLogs({87 Effect.catch((error) =>
84 environmentId,88 Effect.logWarning("Could not persist environment shell cache.").pipe(
85 ...safeErrorLogAttributes(error),89 Effect.annotateLogs({
86 }),90 environmentId,
91 ...safeErrorLogAttributes(error),
92 }),
93 ),
87 ),94 ),
88 ),95 );
89 );96
97 const latestSnapshot = yield* Ref.get(latestLiveSnapshot);
98 // Same sequence can still mean newer content (e.g. repository enrichment).
99 if (Option.isNone(latestSnapshot) || latestSnapshot.value === nextSnapshot) {
100 return;
101 }
102 nextSnapshot = latestSnapshot.value;
103 }
104 });
105
106 const flushLiveShellSnapshot = Effect.gen(function* () {
107 const snapshot = yield* Ref.get(latestLiveSnapshot);
108 if (Option.isNone(snapshot)) {
109 return;
110 }
111 yield* persist(snapshot.value);
90 });112 });
91 113
114 // Register before scoped worker fibers so reverse finalizer order interrupts
115 // those fibers first and this flush sees a stable latestLiveSnapshot.
116 yield* Effect.addFinalizer(() => flushLiveShellSnapshot);
117
92 yield* Stream.fromQueue(persistence).pipe(118 yield* Stream.fromQueue(persistence).pipe(
93 Stream.debounce("500 millis"),119 Stream.debounce("500 millis"),
94 Stream.runForEach(persist),120 Stream.runForEach(persist),
@@ -102,6 +128,8 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") · 7 unmodified lines
102 status: shellStatusForSnapshot(current.snapshot),128 status: shellStatusForSnapshot(current.snapshot),
103 })),129 })),
104 ),130 ),
131 Effect.andThen(flushLiveShellSnapshot.pipe(Effect.forkScoped)),
132 Effect.asVoid,
105 );133 );
106 const setSynchronizing = SubscriptionRef.update(state, (current) => ({134 const setSynchronizing = SubscriptionRef.update(state, (current) => ({
107 ...current,135 ...current,
@@ -149,7 +177,15 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") · 41 unmodified lines
149 const current = yield* SubscriptionRef.get(state);177 const current = yield* SubscriptionRef.get(state);
150 const nextSnapshot =178 const nextSnapshot =
151 item.kind === "snapshot"179 item.kind === "snapshot"
152 ? item.snapshot180 ? mergeShellSnapshotProjects(
181 Option.getOrNull(current.snapshot),
182 item.snapshot,
183 item.resolvedRepositoryIdentityRoots === undefined
184 ? undefined
185 : {
186 resolvedRepositoryIdentityRoots: item.resolvedRepositoryIdentityRoots,
187 },
188 )
153 : Option.match(current.snapshot, {189 : Option.match(current.snapshot, {
154 onNone: () => null,190 onNone: () => null,
155 onSome: (snapshot) =>191 onSome: (snapshot) =>
@@ -161,6 +197,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") · 5 unmodified lines
161 return;197 return;
162 }198 }
163 199
200 yield* Ref.set(latestLiveSnapshot, Option.some(nextSnapshot));
164 const waiting = yield* Ref.get(awaitingCompletion);201 const waiting = yield* Ref.get(awaitingCompletion);
165 yield* SubscriptionRef.set(state, {202 yield* SubscriptionRef.set(state, {
166 snapshot: Option.some(nextSnapshot),203 snapshot: Option.some(nextSnapshot),
packages/client-runtime/src/state/shellReducer.test.ts+325−1
@@ -2,7 +2,25 @@ import { ProjectId, ThreadId } from "@t3tools/contracts"; · 1 unmodified line
2import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
3 3
4import { v2Project, v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts";4import { v2Project, v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts";
5import { applyShellStreamEvent } from "./shellReducer.ts";5import { applyShellStreamEvent, mergeShellSnapshotProjects } from "./shellReducer.ts";
6
7const repositoryIdentity = {
8 canonicalKey: "github.com/example/repo",
9 locator: {
10 source: "git-remote" as const,
11 remoteName: "origin",
12 remoteUrl: "https://github.com/example/repo.git",
13 },
14};
15
16const otherIdentity = {
17 canonicalKey: "github.com/example/other",
18 locator: {
19 source: "git-remote" as const,
20 remoteName: "origin",
21 remoteUrl: "https://github.com/example/other.git",
22 },
23};
6 24
7describe("applyShellStreamEvent", () => {25describe("applyShellStreamEvent", () => {
8 it("ignores stale project updates without mutating the snapshot", () => {26 it("ignores stale project updates without mutating the snapshot", () => {
@@ -42,6 +60,312 @@ describe("applyShellStreamEvent", () => { · 33 unmodified lines
42 expect(removed.projects).toEqual([]);60 expect(removed.projects).toEqual([]);
43 });61 });
44 62
63 it("keeps prior repositoryIdentity when a delta arrives with null identity", () => {
64 const withIdentity = applyShellStreamEvent(v2ShellSnapshot, {
65 kind: "project.updated",
66 sequence: 1,
67 project: { ...v2Project, repositoryIdentity },
68 });
69 expect(withIdentity.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity);
70
71 const withoutIdentity = applyShellStreamEvent(withIdentity, {
72 kind: "project.updated",
73 sequence: 2,
74 project: { ...v2Project, title: "Still same root", repositoryIdentity: null },
75 });
76 expect(withoutIdentity.projects[0]?.title).toBe("Still same root");
77 expect(withoutIdentity.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity);
78 });
79
80 it("does not keep prior repositoryIdentity after a workspace root move", () => {
81 const withIdentity = applyShellStreamEvent(v2ShellSnapshot, {
82 kind: "project.updated",
83 sequence: 1,
84 project: { ...v2Project, repositoryIdentity },
85 });
86
87 const moved = applyShellStreamEvent(withIdentity, {
88 kind: "project.updated",
89 sequence: 2,
90 project: {
91 ...v2Project,
92 workspaceRoot: "/tmp/other-root",
93 repositoryIdentity: null,
94 },
95 });
96 expect(moved.projects[0]?.workspaceRoot).toBe("/tmp/other-root");
97 expect(moved.projects[0]?.repositoryIdentity).toBeNull();
98 });
99
100 it("merges full snapshot projects without dropping known repositoryIdentity", () => {
101 const previous = {
102 ...v2ShellSnapshot,
103 projects: [{ ...v2Project, repositoryIdentity }],
104 };
105 const next = mergeShellSnapshotProjects(previous, {
106 ...v2ShellSnapshot,
107 snapshotSequence: 2,
108 projects: [{ ...v2Project, title: "Refreshed", repositoryIdentity: null }],
109 });
110 expect(next.projects[0]?.title).toBe("Refreshed");
111 expect(next.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity);
112 });
113
114 it("authoritative lower-sequence reset replaces client-ahead structural state", () => {
115 const previous = {
116 ...v2ShellSnapshot,
117 snapshotSequence: 9,
118 projects: [{ ...v2Project, title: "Client ahead", repositoryIdentity }],
119 threads: [{ ...v2ThreadShell, title: "Local-only thread" }],
120 };
121 const next = mergeShellSnapshotProjects(previous, {
122 ...v2ShellSnapshot,
123 snapshotSequence: 3,
124 projects: [{ ...v2Project, title: "Server reset", repositoryIdentity: null }],
125 threads: [{ ...v2ThreadShell, title: "Server thread" }],
126 });
127
128 expect(next.snapshotSequence).toBe(3);
129 expect(next.projects[0]?.title).toBe("Server reset");
130 expect(next.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity);
131 expect(next.threads[0]?.title).toBe("Server thread");
132 });
133
134 it("client-ahead survives lower marked enrichment then still resets on unmarked authoritative", () => {
135 const previous = {
136 ...v2ShellSnapshot,
137 snapshotSequence: 20,
138 projects: [{ ...v2Project, title: "Client ahead", repositoryIdentity }],
139 threads: [{ ...v2ThreadShell, title: "Local-only thread" }],
140 };
141 const serverSnapshot = {
142 ...v2ShellSnapshot,
143 snapshotSequence: 3,
144 projects: [{ ...v2Project, title: "Server reset", repositoryIdentity: null }],
145 threads: [{ ...v2ThreadShell, title: "Server thread" }],
146 };
147
148 // Lower-sequence marked enrichment is identity-only and must not replace structure.
149 const afterEnrichment = mergeShellSnapshotProjects(previous, serverSnapshot, {
150 resolvedRepositoryIdentityRoots: [v2Project.workspaceRoot],
151 });
152 expect(afterEnrichment.snapshotSequence).toBe(20);
153 expect(afterEnrichment.projects[0]?.title).toBe("Client ahead");
154 expect(afterEnrichment.projects[0]?.repositoryIdentity).toBeNull();
155 expect(afterEnrichment.threads[0]?.title).toBe("Local-only thread");
156
157 // Unmarked authoritative at the same lower sequence still performs the reset.
158 const afterAuthoritative = mergeShellSnapshotProjects(afterEnrichment, serverSnapshot);
159 expect(afterAuthoritative.snapshotSequence).toBe(3);
160 expect(afterAuthoritative.projects[0]?.title).toBe("Server reset");
161 expect(afterAuthoritative.projects[0]?.repositoryIdentity).toBeNull();
162 expect(afterAuthoritative.threads[0]?.title).toBe("Server thread");
163 });
164
165 it("authoritative null retains identity then same-sequence marked enrichment clears it", () => {
166 const previous = {
167 ...v2ShellSnapshot,
168 snapshotSequence: 9,
169 projects: [{ ...v2Project, title: "Client ahead", repositoryIdentity }],
170 threads: [{ ...v2ThreadShell, title: "Local-only thread" }],
171 };
172 const serverSnapshot = {
173 ...v2ShellSnapshot,
174 snapshotSequence: 3,
175 projects: [{ ...v2Project, title: "Server reset", repositoryIdentity: null }],
176 threads: [{ ...v2ThreadShell, title: "Server thread" }],
177 };
178
179 const afterAuthoritative = mergeShellSnapshotProjects(previous, serverSnapshot);
180 expect(afterAuthoritative.snapshotSequence).toBe(3);
181 expect(afterAuthoritative.projects[0]?.title).toBe("Server reset");
182 expect(afterAuthoritative.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity);
183 expect(afterAuthoritative.threads[0]?.title).toBe("Server thread");
184
185 const afterEnrichment = mergeShellSnapshotProjects(afterAuthoritative, serverSnapshot, {
186 resolvedRepositoryIdentityRoots: [v2Project.workspaceRoot],
187 });
188 expect(afterEnrichment.snapshotSequence).toBe(3);
189 expect(afterEnrichment.projects[0]?.title).toBe("Server reset");
190 expect(afterEnrichment.projects[0]?.repositoryIdentity).toBeNull();
191 expect(afterEnrichment.threads[0]?.title).toBe("Server thread");
192 });
193
194 it("takes identity from a lower-sequence enrichment without reverting live thread state", () => {
195 const previous = {
196 ...v2ShellSnapshot,
197 snapshotSequence: 5,
198 projects: [{ ...v2Project, repositoryIdentity: null }],
199 threads: [{ ...v2ThreadShell, title: "Newest title" }],
200 };
201 const next = mergeShellSnapshotProjects(
202 previous,
203 {
204 ...v2ShellSnapshot,
205 snapshotSequence: 4,
206 projects: [{ ...v2Project, repositoryIdentity }],
207 threads: [{ ...v2ThreadShell, title: "Stale title" }],
208 },
209 { resolvedRepositoryIdentityRoots: [v2Project.workspaceRoot] },
210 );
211
212 expect(next.snapshotSequence).toBe(5);
213 expect(next.threads[0]?.title).toBe("Newest title");
214 expect(next.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity);
215 });
216
217 it("lower-sequence enrichment cannot resurrect empty structural state", () => {
218 const previous = {
219 ...v2ShellSnapshot,
220 snapshotSequence: 7,
221 projects: [],
222 threads: [],
223 archivedThreads: [],
224 };
225 const next = mergeShellSnapshotProjects(
226 previous,
227 {
228 ...v2ShellSnapshot,
229 snapshotSequence: 4,
230 projects: [{ ...v2Project, repositoryIdentity }],
231 threads: [v2ThreadShell],
232 },
233 { resolvedRepositoryIdentityRoots: [v2Project.workspaceRoot] },
234 );
235
236 expect(next.snapshotSequence).toBe(7);
237 expect(next.projects).toEqual([]);
238 expect(next.threads).toEqual([]);
239 });
240
241 it("resolved null clears identity only for the marked root", () => {
242 const otherProject = {
243 ...v2Project,
244 id: ProjectId.make("project-other"),
245 workspaceRoot: "/workspace/other",
246 repositoryIdentity: otherIdentity,
247 };
248 const previous = {
249 ...v2ShellSnapshot,
250 snapshotSequence: 5,
251 projects: [{ ...v2Project, repositoryIdentity }, otherProject],
252 };
253 const next = mergeShellSnapshotProjects(
254 previous,
255 {
256 ...v2ShellSnapshot,
257 snapshotSequence: 4,
258 projects: [
259 { ...v2Project, repositoryIdentity: null },
260 { ...otherProject, repositoryIdentity: null },
261 ],
262 },
263 { resolvedRepositoryIdentityRoots: [v2Project.workspaceRoot] },
264 );
265
266 expect(next.snapshotSequence).toBe(5);
267 expect(next.projects).toHaveLength(2);
268 expect(next.projects[0]?.repositoryIdentity).toBeNull();
269 expect(next.projects[1]?.repositoryIdentity).toEqual(otherIdentity);
270 });
271
272 it("unrelated unresolved roots retain identity in the same enrichment snapshot", () => {
273 const otherProject = {
274 ...v2Project,
275 id: ProjectId.make("project-other"),
276 workspaceRoot: "/workspace/other",
277 repositoryIdentity: otherIdentity,
278 };
279 const previous = {
280 ...v2ShellSnapshot,
281 snapshotSequence: 3,
282 projects: [{ ...v2Project, repositoryIdentity }, otherProject],
283 threads: [{ ...v2ThreadShell, title: "Current thread" }],
284 };
285 const next = mergeShellSnapshotProjects(
286 previous,
287 {
288 ...v2ShellSnapshot,
289 snapshotSequence: 3,
290 projects: [
291 { ...v2Project, repositoryIdentity },
292 { ...otherProject, repositoryIdentity: null },
293 ],
294 threads: [{ ...v2ThreadShell, title: "Stale thread" }],
295 },
296 { resolvedRepositoryIdentityRoots: [v2Project.workspaceRoot] },
297 );
298
299 expect(next.threads[0]?.title).toBe("Current thread");
300 expect(next.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity);
301 expect(next.projects[1]?.repositoryIdentity).toEqual(otherIdentity);
302 });
303
304 it("clears identity for multiple roots resolved in one enrichment batch", () => {
305 const otherProject = {
306 ...v2Project,
307 id: ProjectId.make("project-other"),
308 workspaceRoot: "/workspace/other",
309 repositoryIdentity: otherIdentity,
310 };
311 const previous = {
312 ...v2ShellSnapshot,
313 snapshotSequence: 3,
314 projects: [{ ...v2Project, repositoryIdentity }, otherProject],
315 };
316 const next = mergeShellSnapshotProjects(
317 previous,
318 {
319 ...v2ShellSnapshot,
320 snapshotSequence: 3,
321 projects: [
322 { ...v2Project, repositoryIdentity: null },
323 { ...otherProject, repositoryIdentity: null },
324 ],
325 },
326 {
327 resolvedRepositoryIdentityRoots: [v2Project.workspaceRoot, otherProject.workspaceRoot],
328 },
329 );
330
331 expect(next.projects.map((project) => project.repositoryIdentity)).toEqual([null, null]);
332 });
333
334 it("does not replace a live identity with one from an unmarked lower-sequence enrichment", () => {
335 const liveIdentity = {
336 canonicalKey: "github.com/example/live",
337 locator: {
338 source: "git-remote" as const,
339 remoteName: "origin",
340 remoteUrl: "https://github.com/example/live.git",
341 },
342 };
343 const staleIdentity = {
344 canonicalKey: "github.com/example/stale",
345 locator: {
346 source: "git-remote" as const,
347 remoteName: "origin",
348 remoteUrl: "https://github.com/example/stale.git",
349 },
350 };
351 const next = mergeShellSnapshotProjects(
352 {
353 ...v2ShellSnapshot,
354 snapshotSequence: 5,
355 projects: [{ ...v2Project, repositoryIdentity: liveIdentity }],
356 },
357 {
358 ...v2ShellSnapshot,
359 snapshotSequence: 4,
360 projects: [{ ...v2Project, repositoryIdentity: staleIdentity }],
361 },
362 { resolvedRepositoryIdentityRoots: [] },
363 );
364
365 expect(next.snapshotSequence).toBe(5);
366 expect(next.projects[0]?.repositoryIdentity).toEqual(liveIdentity);
367 });
368
45 it("moves a thread between active and archive without duplicating it", () => {369 it("moves a thread between active and archive without duplicating it", () => {
46 const archived = applyShellStreamEvent(v2ShellSnapshot, {370 const archived = applyShellStreamEvent(v2ShellSnapshot, {
47 kind: "thread.updated",371 kind: "thread.updated",
packages/client-runtime/src/state/shellReducer.ts+89−2
@@ -1,4 +1,5 @@
1import type {1import type {
2 OrchestrationProjectShell,
2 OrchestrationV2ShellSnapshot,3 OrchestrationV2ShellSnapshot,
3 OrchestrationV2ShellStreamItem,4 OrchestrationV2ShellStreamItem,
4} from "@t3tools/contracts";5} from "@t3tools/contracts";
@@ -12,6 +13,85 @@ function upsertById<T extends { readonly id: unknown }>( · 7 unmodified lines
12 return items.map((candidate, candidateIndex) => (candidateIndex === index ? item : candidate));13 return items.map((candidate, candidateIndex) => (candidateIndex === index ? item : candidate));
13}14}
14 15
16function retainRepositoryIdentity(
17 previous: OrchestrationProjectShell | undefined,
18 next: OrchestrationProjectShell,
19): OrchestrationProjectShell {
20 if (
21 next.repositoryIdentity == null &&
22 previous?.repositoryIdentity != null &&
23 previous.workspaceRoot === next.workspaceRoot
24 ) {
25 return { ...next, repositoryIdentity: previous.repositoryIdentity };
26 }
27 return next;
28}
29
30export interface MergeShellSnapshotOptions {
31 /**
32 * Enrichment refresh: structure and sequence must not roll back when at or
33 * below cache; listed roots accept identity exactly (including null).
34 * Omit this options object for authoritative HTTP/initial WebSocket snapshots.
35 */
36 readonly resolvedRepositoryIdentityRoots: ReadonlyArray<string>;
37}
38
39/**
40 * Merge an incoming full shell snapshot into prior client state.
41 *
42 * Authoritative snapshots (no options) replace structure and sequence even when
43 * lower than cache, while retaining a prior non-null identity when the candidate
44 * is still unresolved/null for the same root.
45 *
46 * Enrichment snapshots (options present) never resurrect or roll back
47 * projects/threads/archives/sequence when at or below cache; they only patch
48 * repository identity for matching current projects.
49 */
50export function mergeShellSnapshotProjects(
51 previous: OrchestrationV2ShellSnapshot | null | undefined,
52 next: OrchestrationV2ShellSnapshot,
53 options?: MergeShellSnapshotOptions,
54): OrchestrationV2ShellSnapshot {
55 if (previous === null || previous === undefined) {
56 return next;
57 }
58
59 const isEnrichment = options !== undefined;
60 const resolvedRootSet = isEnrichment ? new Set(options.resolvedRepositoryIdentityRoots) : null;
61
62 if (isEnrichment && next.snapshotSequence <= previous.snapshotSequence) {
63 const nextById = new Map(next.projects.map((project) => [project.id, project] as const));
64 return {
65 ...previous,
66 projects: previous.projects.map((project) => {
67 const candidate = nextById.get(project.id);
68 if (candidate === undefined || candidate.workspaceRoot !== project.workspaceRoot) {
69 return project;
70 }
71 if (resolvedRootSet?.has(project.workspaceRoot) === true) {
72 return { ...project, repositoryIdentity: candidate.repositoryIdentity };
73 }
74 if (project.repositoryIdentity == null && candidate.repositoryIdentity != null) {
75 return { ...project, repositoryIdentity: candidate.repositoryIdentity };
76 }
77 return project;
78 }),
79 };
80 }
81
82 const previousById = new Map(previous.projects.map((project) => [project.id, project] as const));
83 return {
84 ...next,
85 projects: next.projects.map((project) => {
86 const prior = previousById.get(project.id);
87 if (resolvedRootSet?.has(project.workspaceRoot) === true) {
88 return project;
89 }
90 return retainRepositoryIdentity(prior, project);
91 }),
92 };
93}
94
15/** Applies one committed V2 shell delta while preserving active/archive exclusivity. */95/** Applies one committed V2 shell delta while preserving active/archive exclusivity. */
16export function applyShellStreamEvent(96export function applyShellStreamEvent(
17 snapshot: OrchestrationV2ShellSnapshot,97 snapshot: OrchestrationV2ShellSnapshot,
@@ -20,12 +100,19 @@ export function applyShellStreamEvent( · 2 unmodified lines
20 if (event.sequence <= snapshot.snapshotSequence) return snapshot;100 if (event.sequence <= snapshot.snapshotSequence) return snapshot;
21 101
22 switch (event.kind) {102 switch (event.kind) {
23 case "project.updated":103 case "project.updated": {
104 // Enrichment is async. A project mutation can land with null
105 // repositoryIdentity while an earlier snapshot already resolved it.
106 // Keep the prior identity for the same workspace root so multi-env
107 // grouping does not split until a full snapshot refresh arrives.
108 const previous = snapshot.projects.find((project) => project.id === event.project.id);
109 const project = retainRepositoryIdentity(previous, event.project);
24 return {110 return {
25 ...snapshot,111 ...snapshot,
26 projects: upsertById(snapshot.projects, event.project),112 projects: upsertById(snapshot.projects, project),
27 snapshotSequence: event.sequence,113 snapshotSequence: event.sequence,
28 };114 };
115 }
29 case "project.removed":116 case "project.removed":
30 return {117 return {
31 ...snapshot,118 ...snapshot,
packages/contracts/src/orchestrationV2.test.ts+26−0
@@ -23,16 +23,42 @@ import { · 22 unmodified lines
23 OrchestrationV2CheckpointScope,23 OrchestrationV2CheckpointScope,
24 OrchestrationV2Command,24 OrchestrationV2Command,
25 OrchestrationV2DomainEvent,25 OrchestrationV2DomainEvent,
26 OrchestrationV2ShellSnapshot,
26 OrchestrationV2Subagent,27 OrchestrationV2Subagent,
27 OrchestrationV2ThreadProjection,28 OrchestrationV2ThreadProjection,
28 OrchestrationV2TurnItem,29 OrchestrationV2TurnItem,
29} from "./orchestrationV2.ts";30} from "./orchestrationV2.ts";
30 31
31const now = DateTime.makeUnsafe("2026-04-20T00:00:00.000Z");32const now = DateTime.makeUnsafe("2026-04-20T00:00:00.000Z");
33const LegacyShellStreamItem = Schema.Union([
34 Schema.Struct({ kind: Schema.Literal("synchronized") }),
35 Schema.Struct({
36 kind: Schema.Literal("snapshot"),
37 snapshot: OrchestrationV2ShellSnapshot,
38 }),
39]);
40const decodeLegacyShellStreamItem = Schema.decodeUnknownSync(LegacyShellStreamItem);
32const decodeOrchestrationV2Command = Schema.decodeUnknownSync(OrchestrationV2Command);41const decodeOrchestrationV2Command = Schema.decodeUnknownSync(OrchestrationV2Command);
33const decodeOrchestrationV2TurnItem = Schema.decodeUnknownSync(OrchestrationV2TurnItem);42const decodeOrchestrationV2TurnItem = Schema.decodeUnknownSync(OrchestrationV2TurnItem);
34 43
35describe("orchestration V2 contracts", () => {44describe("orchestration V2 contracts", () => {
45 it("lets legacy snapshot decoders ignore enrichment metadata", () => {
46 const decoded = decodeLegacyShellStreamItem({
47 kind: "snapshot",
48 snapshot: {
49 schemaVersion: 1,
50 snapshotSequence: 0,
51 projects: [],
52 threads: [],
53 archivedThreads: [],
54 },
55 resolvedRepositoryIdentityRoots: ["/workspace/project"],
56 });
57
58 expect(decoded.kind).toBe("snapshot");
59 expect("resolvedRepositoryIdentityRoots" in decoded).toBe(false);
60 });
61
36 it("decodes nested checkpoint scopes without making child scopes advance app run count", () => {62 it("decodes nested checkpoint scopes without making child scopes advance app run count", () => {
37 const rootScope = Schema.decodeUnknownSync(OrchestrationV2CheckpointScope)({63 const rootScope = Schema.decodeUnknownSync(OrchestrationV2CheckpointScope)({
38 id: "scope-root-1",64 id: "scope-root-1",
packages/contracts/src/orchestrationV2.ts+77−0
@@ -1185,6 +1185,12 @@ export const OrchestrationV2ShellStreamItem = Schema.Union([ · 1184 unmodified lines
1185 Schema.Struct({1185 Schema.Struct({
1186 kind: Schema.Literal("snapshot"),1186 kind: Schema.Literal("snapshot"),
1187 snapshot: OrchestrationV2ShellSnapshot,1187 snapshot: OrchestrationV2ShellSnapshot,
1188 /**
1189 * Workspace roots whose repository-identity resolution completed for this
1190 * enrichment refresh. Omitted on authoritative HTTP/initial WebSocket
1191 * snapshots. Older clients ignore the field.
1192 */
1193 resolvedRepositoryIdentityRoots: Schema.optionalKey(Schema.Array(Schema.String)),
1188 }),1194 }),
1189 Schema.Struct({1195 Schema.Struct({
1190 kind: Schema.Literal("project.updated"),1196 kind: Schema.Literal("project.updated"),
@@ -1528,6 +1534,77 @@ export const OrchestrationV2TurnItemJson = Schema.Union([ · 337 unmodified lines
1528]);1534]);
1529export type OrchestrationV2TurnItemJson = typeof OrchestrationV2TurnItemJson.Type;1535export type OrchestrationV2TurnItemJson = typeof OrchestrationV2TurnItemJson.Type;
1530 1536
1537export const OrchestrationV2ProjectedTurnItemJson = OrchestrationV2ProjectedTurnItem.mapFields(
1538 (fields) => ({
1539 ...fields,
1540 item: OrchestrationV2TurnItemJson,
1541 }),
1542);
1543export type OrchestrationV2ProjectedTurnItemJson = typeof OrchestrationV2ProjectedTurnItemJson.Type;
1544
1545export const OrchestrationV2ThreadProjectionJson = OrchestrationV2ThreadProjection.mapFields(
1546 (fields) => ({
1547 ...fields,
1548 thread: OrchestrationV2AppThreadJson,
1549 runs: Schema.Array(OrchestrationV2RunJson),
1550 attempts: Schema.Array(OrchestrationV2RunAttemptJson),
1551 nodes: Schema.Array(OrchestrationV2ExecutionNodeJson),
1552 subagents: Schema.Array(OrchestrationV2SubagentJson),
1553 providerSessions: Schema.Array(OrchestrationV2ProviderSessionJson),
1554 providerThreads: Schema.Array(OrchestrationV2ProviderThreadJson),
1555 providerTurns: Schema.Array(OrchestrationV2ProviderTurnJson),
1556 runtimeRequests: Schema.Array(OrchestrationV2RuntimeRequestJson),
1557 messages: Schema.Array(OrchestrationV2ConversationMessageJson),
1558 plans: Schema.Array(OrchestrationV2PlanArtifact),
1559 turnItems: Schema.Array(OrchestrationV2TurnItemJson),
1560 checkpointScopes: Schema.Array(OrchestrationV2CheckpointScopeJson),
1561 checkpoints: Schema.Array(OrchestrationV2CheckpointJson),
1562 contextHandoffs: Schema.Array(OrchestrationV2ContextHandoffJson),
1563 contextTransfers: Schema.Array(OrchestrationV2ContextTransferJson),
1564 visibleTurnItems: Schema.Array(OrchestrationV2ProjectedTurnItemJson),
1565 updatedAt: Schema.DateTimeUtcFromString,
1566 }),
1567);
1568export type OrchestrationV2ThreadProjectionJson = typeof OrchestrationV2ThreadProjectionJson.Type;
1569
1570export const OrchestrationV2PendingRuntimeRequestSummaryJson =
1571 OrchestrationV2PendingRuntimeRequestSummary.mapFields((fields) => ({
1572 ...fields,
1573 createdAt: Schema.DateTimeUtcFromString,
1574 }));
1575export type OrchestrationV2PendingRuntimeRequestSummaryJson =
1576 typeof OrchestrationV2PendingRuntimeRequestSummaryJson.Type;
1577
1578export const OrchestrationV2LatestVisibleMessageSummaryJson =
1579 OrchestrationV2LatestVisibleMessageSummary.mapFields((fields) => ({
1580 ...fields,
1581 updatedAt: Schema.DateTimeUtcFromString,
1582 }));
1583export type OrchestrationV2LatestVisibleMessageSummaryJson =
1584 typeof OrchestrationV2LatestVisibleMessageSummaryJson.Type;
1585
1586export const OrchestrationV2ThreadShellJson = OrchestrationV2ThreadShell.mapFields((fields) => ({
1587 ...fields,
1588 pendingRuntimeRequest: Schema.NullOr(OrchestrationV2PendingRuntimeRequestSummaryJson),
1589 latestVisibleMessage: Schema.NullOr(OrchestrationV2LatestVisibleMessageSummaryJson),
1590 latestUserMessageAt: Schema.NullOr(Schema.DateTimeUtcFromString),
1591 createdAt: Schema.DateTimeUtcFromString,
1592 updatedAt: Schema.DateTimeUtcFromString,
1593 archivedAt: Schema.NullOr(Schema.DateTimeUtcFromString),
1594 settledAt: Schema.NullOr(Schema.DateTimeUtcFromString),
1595 deletedAt: Schema.NullOr(Schema.DateTimeUtcFromString),
1596}));
1597export type OrchestrationV2ThreadShellJson = typeof OrchestrationV2ThreadShellJson.Type;
1598
1599export const OrchestrationV2ShellSnapshotJson = OrchestrationV2ShellSnapshot.mapFields(
1600 (fields) => ({
1601 ...fields,
1602 threads: Schema.Array(OrchestrationV2ThreadShellJson),
1603 archivedThreads: Schema.Array(OrchestrationV2ThreadShellJson),
1604 }),
1605);
1606export type OrchestrationV2ShellSnapshotJson = typeof OrchestrationV2ShellSnapshotJson.Type;
1607
1531const OrchestrationV2JsonEventBaseFields = {1608const OrchestrationV2JsonEventBaseFields = {
1532 ...OrchestrationV2EventBase.fields,1609 ...OrchestrationV2EventBase.fields,
1533 occurredAt: Schema.DateTimeUtcFromString,1610 occurredAt: Schema.DateTimeUtcFromString,
packages/effect-acp/src/protocol.test.ts+55−0
@@ -708,6 +708,61 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { · 707 unmodified lines
708 }),708 }),
709 );709 );
710 710
711 it.effect("drops agent-internal JSON-RPC responses with non-numeric request ids", () =>
712 Effect.gen(function* () {
713 const { stdio, input } = yield* makeInMemoryStdio();
714 const notifications =
715 yield* Deferred.make<ReadonlyArray<AcpProtocol.AcpIncomingNotification>>();
716 const transport = yield* AcpProtocol.makeAcpPatchedProtocol({
717 stdio,
718 serverRequestMethods: new Set(),
719 });
720
721 yield* transport.incoming.pipe(
722 Stream.take(1),
723 Stream.runCollect,
724 Effect.flatMap((notificationChunk) => Deferred.succeed(notifications, notificationChunk)),
725 Effect.forkScoped,
726 );
727
728 yield* Queue.offer(
729 input,
730 encoder.encode(
731 `${encodeUnknownJsonString({
732 jsonrpc: "2.0",
733 id: "skills-reload",
734 result: {
735 reloaded: true,
736 },
737 })}\n`,
738 ),
739 );
740 yield* Queue.offer(
741 input,
742 yield* encodeJsonl(SessionUpdateNotification, {
743 jsonrpc: "2.0",
744 method: "session/update",
745 params: {
746 sessionId: "session-1",
747 update: {
748 sessionUpdate: "plan",
749 entries: [
750 {
751 content: "Reload skills",
752 priority: "high",
753 status: "in_progress",
754 },
755 ],
756 },
757 },
758 }),
759 );
760
761 const [update] = yield* Deferred.await(notifications);
762 assert.equal(update?._tag, "SessionUpdate");
763 }),
764 );
765
711 it.effect("cleans up interrupted extension requests before a late response arrives", () =>766 it.effect("cleans up interrupted extension requests before a late response arrives", () =>
712 Effect.gen(function* () {767 Effect.gen(function* () {
713 const { stdio, input, output } = yield* makeInMemoryStdio();768 const { stdio, input, output } = yield* makeInMemoryStdio();
packages/effect-acp/src/protocol.ts+12−2
@@ -117,6 +117,9 @@ const encodeJsonRpcNotification = Schema.encodeUnknownExit( · 116 unmodified lines
117 ),117 ),
118);118);
119 119
120const isEffectRpcRequestId = (requestId: string): boolean =>
121 requestId !== "" && /^-?\d+$/.test(requestId);
122
120export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(function* (123export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(function* (
121 options: AcpPatchedProtocolOptions,124 options: AcpPatchedProtocolOptions,
122): Effect.fn.Return<AcpPatchedProtocol, never, Scope.Scope> {125): Effect.fn.Return<AcpPatchedProtocol, never, Scope.Scope> {
@@ -452,12 +455,19 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 329 unmodified lines
452 return observeIncoming.pipe(Effect.andThen(Queue.offer(serverQueue, message)), Effect.asVoid);455 return observeIncoming.pipe(Effect.andThen(Queue.offer(serverQueue, message)), Effect.asVoid);
453 };456 };
454 457
458 const forwardToRpcClient = (
459 message: RpcMessage.ResponseChunkEncoded | RpcMessage.ResponseExitEncoded,
460 ) =>
461 isEffectRpcRequestId(message.requestId)
462 ? Queue.offer(clientQueue, message).pipe(Effect.asVoid)
463 : Effect.void;
464
455 const handleExitEncoded = (message: RpcMessage.ResponseExitEncoded) =>465 const handleExitEncoded = (message: RpcMessage.ResponseExitEncoded) =>
456 Ref.get(extPending).pipe(466 Ref.get(extPending).pipe(
457 Effect.flatMap((pending) => {467 Effect.flatMap((pending) => {
458 const pendingRequest = pending.get(String(message.requestId));468 const pendingRequest = pending.get(String(message.requestId));
459 if (!pendingRequest) {469 if (!pendingRequest) {
460 return Queue.offer(clientQueue, message).pipe(Effect.asVoid);470 return forwardToRpcClient(message);
461 }471 }
462 if (message.exit._tag === "Success") {472 if (message.exit._tag === "Success") {
463 return completeExtPendingSuccess(message.requestId, message.exit.value);473 return completeExtPendingSuccess(message.requestId, message.exit.value);
@@ -504,7 +514,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 40 unmodified lines
504 message.requestId,514 message.requestId,
505 ),515 ),
506 )516 )
507 : Queue.offer(clientQueue, message).pipe(Effect.asVoid);517 : forwardToRpcClient(message);
508 }),518 }),
509 );519 );
510 case "Defect":520 case "Defect":

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t