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 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import type { ApplicationStoredEvent, OrchestrationV2ShellSnapshot } from "@t3tools/contracts";
3+import * as Effect from "effect/Effect";
4+import * as Stream from "effect/Stream";
5+
6+import {
7+ coalesceShellApplicationEvents,
8+ composeShellStreamWithEnrichment,
9+ shellStreamItemFromEnrichmentRefresh,
10+ shellStreamItemsFromInitialSnapshot,
11+} from "./ShellStream.ts";
12+
13+function project(sequence: number, id: string): ApplicationStoredEvent {
14+ return {
15+ sequence,
16+ aggregateKind: "project",
17+ aggregateId: id,
18+ } as ApplicationStoredEvent;
19+}
20+
21+function thread(sequence: number, id: string): ApplicationStoredEvent {
22+ return {
23+ sequence,
24+ event: { threadId: id },
25+ } as ApplicationStoredEvent;
26+}
27+
28+const emptyShellSnapshot = {
29+ schemaVersion: 1,
30+ snapshotSequence: 0,
31+ projects: [],
32+ threads: [],
33+ archivedThreads: [],
34+} as OrchestrationV2ShellSnapshot;
35+
36+describe("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+
50+describe("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+
69+describe("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+
101+describe("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 @@
11 import type {
22 OrchestrationV2ArchivedShellStreamItem,
3+ OrchestrationV2ShellSnapshot,
34 OrchestrationV2ThreadShellSnapshot,
45 OrchestrationV2ShellStreamItem,
56 OrchestrationV2StoredEvent,
67 } from "@t3tools/contracts";
8+import * as Stream from "effect/Stream";
9+
10+/** Keep only the newest shell-relevant event per project/thread aggregate. */
11+export 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+ */
32+export 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. */
41+export 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+ */
58+export 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+}
778
879 /** Converts a committed event and its resulting shell snapshot into one delta. */
980 export function shellStreamItemFromSnapshot(input: {
apps/server/src/orchestration-v2/http.ts+121−0
@@ -0,0 +1,121 @@
1+import {
2+ AuthOrchestrationReadScope,
3+ EnvironmentHttpApi,
4+ type OrchestrationProjectShell,
5+} from "@t3tools/contracts";
6+import * as Effect from "effect/Effect";
7+import * as Predicate from "effect/Predicate";
8+import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
9+import * as SqlClient from "effect/unstable/sql/SqlClient";
10+
11+import {
12+ annotateEnvironmentRequest,
13+ failEnvironmentInternal,
14+ failEnvironmentNotFound,
15+ requireEnvironmentScope,
16+} from "../auth/http.ts";
17+import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
18+import * as OrchestrationEventStore from "../persistence/Services/OrchestrationEventStore.ts";
19+import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";
20+import * as ThreadManagementService from "./ThreadManagementService.ts";
21+
22+function 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+ */
36+export 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
9898 ).pipe(
9999 Layer.provide(
100100 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+ }),
102107 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+ }),
104114 invalidate: () => Effect.void,
105115 subscribeChanges: Effect.never,
106116 }),
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
168168 }),
169169 );
170170
171+it.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+
212+it.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+
171275 it.effect("deduplicates requests, bounds pending work, and reloads invalidated roots", () =>
172276 Effect.gen(function* () {
173277 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
2424 export interface ProjectEnrichment {
2525 readonly repositoryIdentity: RepositoryIdentity | null;
2626 readonly faviconPath: string | null;
27+ /** True when identity resolution completed successfully, including cached null. */
28+ readonly repositoryIdentityResolved: boolean;
2729 }
2830
2931 export interface ProjectEnrichmentChange {
@@ -73,6 +75,13 @@ function availableValue<A, E>(cached: Option.Option<Exit.Exit<A, E>>): A | null · 43 unmodified lines
7375 });
7476 }
7577
78+function 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+
7685 interface EnrichmentWorkLane {
7786 readonly pendingRoots: Ref.Ref<ReadonlySet<string>>;
7887 readonly queue: Queue.Queue<string>;
@@ -159,12 +168,14 @@ export const make = Effect.fn("ProjectEnrichmentService.make")(function* ( · 80 unmodified lines
159168 const repositoryIdentity = yield* Cache.get(repositoryIdentityCache, workspaceRoot);
160169 yield* logFailure(workspaceRoot, "repositoryIdentity", repositoryIdentity);
161170 const faviconPath = yield* Cache.getSuccess(faviconCache, workspaceRoot);
171+ const repositoryIdentityResolved = Exit.isSuccess(repositoryIdentity);
162172 yield* PubSub.publish(changes, {
163173 workspaceRoot,
164− repositoryIdentityResolved: Exit.isSuccess(repositoryIdentity),
174+ repositoryIdentityResolved,
165175 enrichment: {
166176 repositoryIdentity: availableValue(Option.some(repositoryIdentity)),
167177 faviconPath: availableValue(faviconPath),
178+ repositoryIdentityResolved,
168179 },
169180 });
170181 },
@@ -227,6 +238,7 @@ export const make = Effect.fn("ProjectEnrichmentService.make")(function* ( · 56 unmodified lines
227238 return {
228239 repositoryIdentity: availableValue(repositoryIdentity),
229240 faviconPath: availableValue(faviconPath),
241+ repositoryIdentityResolved: isSuccessfullyResolved(repositoryIdentity),
230242 };
231243 });
232244
apps/server/src/ws.ts+117−12
@@ -70,7 +70,11 @@ import * as ThreadLaunchService from "./orchestration-v2/ThreadLaunchService.ts" · 69 unmodified lines
7070 import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts";
7171 import {
7272 archivedShellStreamItemFromSnapshot,
73+ coalesceShellApplicationEvents,
74+ composeShellStreamWithEnrichment,
75+ shellStreamItemFromEnrichmentRefresh,
7376 shellStreamItemFromSnapshot,
77+ shellStreamItemsFromInitialSnapshot,
7478 } from "./orchestration-v2/ShellStream.ts";
7579 import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
7680 import * as OrchestrationEventStore from "./persistence/Services/OrchestrationEventStore.ts";
@@ -429,13 +433,28 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => · 352 unmodified lines
429433 Effect.forEach(
430434 projects,
431435 (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.
432441 projectEnrichment.getAvailable(project.workspaceRoot).pipe(
433442 Effect.map((enrichment) => ({
434− ...project,
435− repositoryIdentity: enrichment.repositoryIdentity,
443+ project: {
444+ ...project,
445+ repositoryIdentity: enrichment.repositoryIdentity,
446+ },
447+ repositoryIdentityResolved: enrichment.repositoryIdentityResolved,
436448 })),
437449 ),
438450 { 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+ })),
439458 ),
440459 );
441460 const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService;
@@ -660,8 +679,11 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => · 218 unmodified lines
660679 } as const;
661680 }),
662681 );
663− const projects = yield* enrichProjectShells(base.projects);
664− return { ...base, projects };
682+ const enriched = yield* enrichProjectShells(base.projects);
683+ return {
684+ snapshot: { ...base, projects: enriched.projects } as OrchestrationV2ShellSnapshot,
685+ resolvedRepositoryIdentityRoots: enriched.resolvedRepositoryIdentityRoots,
686+ };
665687 });
666688 const snapshot = yield* loadSnapshot().pipe(
667689 Effect.mapError(
@@ -717,15 +739,98 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => · 49 unmodified lines
717739
718740 const enrichmentRefreshes = Stream.fromSubscription(enrichmentChanges).pipe(
719741 Stream.filter((change) => change.repositoryIdentityResolved),
720− Stream.debounce("25 millis"),
721− Stream.mapEffect(() => loadSnapshot()),
722− Stream.map((snapshot) => ({ kind: "snapshot" as const, snapshot })),
742+ Stream.groupedWithin(64, Duration.millis(25)),
743+ Stream.mapEffect((changes) =>
744+ loadSnapshot().pipe(
745+ Effect.map(({ snapshot }) =>
746+ shellStreamItemFromEnrichmentRefresh({
747+ snapshot,
748+ changes: Array.from(changes),
749+ }),
750+ ),
751+ ),
752+ ),
723753 );
724754
725− return Stream.merge(
726− Stream.concat(Stream.make({ kind: "snapshot" as const, snapshot }), live),
727− enrichmentRefreshes,
728− ).pipe(
755+ // Always attach the enrichment subscription before the first load so
756+ // completions that race HTTP snapshot fetch still push a refresh.
757+ // When the client already holds a shell snapshot (cached, or loaded
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(
729834 Stream.mapError(
730835 (cause) =>
731836 new OrchestrationV2GetShellSnapshotError({
@@ -753,7 +858,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => · 21 unmodified lines
753858 .pipe(
754859 Effect.flatMap((snapshot) =>
755860 enrichProjectShells(snapshot.projects).pipe(
756− Effect.map((projects) => ({ ...snapshot, projects })),
861+ Effect.map(({ projects }) => ({ ...snapshot, projects })),
757862 ),
758863 ),
759864 Effect.mapError(
packages/client-runtime/src/platform/orchestrationCache.test.ts+87−13
@@ -1,5 +1,11 @@
11 import { describe, expect, it } from "@effect/vitest";
2−import { EnvironmentId } from "@t3tools/contracts";
2+import {
3+ EnvironmentId,
4+ MessageId,
5+ RuntimeRequestId,
6+ type OrchestrationV2ShellSnapshot,
7+} from "@t3tools/contracts";
8+import * as DateTime from "effect/DateTime";
39 import * as Effect from "effect/Effect";
410 import * as Option from "effect/Option";
511 import * as Schema from "effect/Schema";
@@ -10,39 +16,107 @@ import { · 4 unmodified lines
1016 StoredOrchestrationThreadSnapshot,
1117 decodeOrDiscardOrchestrationCache,
1218 } from "./orchestrationCache.ts";
13−import { v2Projection, v2ShellSnapshot, v2ThreadId } from "../state/orchestrationV2TestFixtures.ts";
19+import {
20+ v2Now,
21+ v2Projection,
22+ v2ShellSnapshot,
23+ v2ThreadShell,
24+ v2ThreadId,
25+} from "../state/orchestrationV2TestFixtures.ts";
1426
1527 const environmentId = EnvironmentId.make("environment-cache-test");
16−const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredOrchestrationShellSnapshot);
17−const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredOrchestrationThreadSnapshot);
28+const StoredShellSnapshotJson = Schema.fromJsonString(StoredOrchestrationShellSnapshot);
29+const StoredThreadSnapshotJson = Schema.fromJsonString(StoredOrchestrationThreadSnapshot);
30+const encodeStoredShellSnapshot = Schema.encodeSync(StoredOrchestrationShellSnapshot);
31+const decodeStoredShellSnapshotSync = Schema.decodeUnknownSync(StoredOrchestrationShellSnapshot);
32+const encodeStoredShellSnapshotJson = Schema.encodeSync(StoredShellSnapshotJson);
33+const decodeStoredShellSnapshotJson = Schema.decodeUnknownSync(StoredShellSnapshotJson);
34+const encodeStoredThreadSnapshotJson = Schema.encodeSync(StoredThreadSnapshotJson);
35+const decodeStoredThreadSnapshotJson = Schema.decodeUnknownSync(StoredThreadSnapshotJson);
36+
37+class TestCacheDecodeError extends Schema.TaggedErrorClass<TestCacheDecodeError>()(
38+ "TestCacheDecodeError",
39+ {
40+ message: Schema.String,
41+ },
42+) {}
43+
44+const 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+};
1865
1966 describe("orchestration cache envelopes", () => {
20− it.effect("accepts V2 shell and thread cache envelopes", () =>
21− Effect.gen(function* () {
22− const shell = yield* decodeStoredShellSnapshot({
67+ it.effect("round-trips V2 shell and thread cache envelopes as JSON", () =>
68+ Effect.sync(() => {
69+ const encodedShell = encodeStoredShellSnapshotJson({
2370 schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION,
2471 environmentId,
25− snapshot: v2ShellSnapshot,
72+ snapshot: shellSnapshotWithSummaries,
2673 });
27− const thread = yield* decodeStoredThreadSnapshot({
74+ const shell = decodeStoredShellSnapshotJson(encodedShell);
75+ const encodedThread = encodeStoredThreadSnapshotJson({
2876 schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION,
2977 environmentId,
3078 threadId: v2ThreadId,
3179 thread: v2Projection,
3280 });
81+ const thread = decodeStoredThreadSnapshotJson(encodedThread);
82+ const [threadShell] = shell.snapshot.threads;
3383
34− expect(shell.snapshot).toEqual(v2ShellSnapshot);
35− expect(thread.thread).toEqual(v2Projection);
84+ expect(threadShell?.latestVisibleMessage?.text).toBe("Done");
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);
36106 }),
37107 );
38108
39109 it.effect("discards V1-versioned cache envelopes after a decode failure", () =>
40110 Effect.gen(function* () {
41111 let discardCount = 0;
42− const decoded = decodeStoredShellSnapshot({
43− schemaVersion: 1,
112+ const encodedShell = encodeStoredShellSnapshot({
113+ schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION,
44114 environmentId,
45115 snapshot: v2ShellSnapshot,
116+ });
117+ const decoded = Effect.try({
118+ try: () => decodeStoredShellSnapshotSync({ ...encodedShell, schemaVersion: 1 }),
119+ catch: (cause) => new TestCacheDecodeError({ message: String(cause) }),
46120 }).pipe(Effect.map(Option.some));
47121
48122 const result = yield* decodeOrDiscardOrchestrationCache(
packages/client-runtime/src/platform/orchestrationCache.ts+8−4
@@ -1,7 +1,8 @@
11 import {
22 EnvironmentId,
3− OrchestrationV2ShellSnapshot,
4− OrchestrationV2ThreadProjection,
3+ OrchestrationV2ShellSnapshotJson,
4+ OrchestrationV2ThreadDetailSnapshot,
5+ OrchestrationV2ThreadProjectionJson,
56 ThreadId,
67 } from "@t3tools/contracts";
78 import * as Effect from "effect/Effect";
@@ -13,14 +14,17 @@ export const ORCHESTRATION_CACHE_SCHEMA_VERSION = 2 as const; · 5 unmodified lines
1314 export const StoredOrchestrationShellSnapshot = Schema.Struct({
1415 schemaVersion: Schema.Literal(ORCHESTRATION_CACHE_SCHEMA_VERSION),
1516 environmentId: EnvironmentId,
16− snapshot: OrchestrationV2ShellSnapshot,
17+ snapshot: OrchestrationV2ShellSnapshotJson,
1718 });
1819
1920 export const StoredOrchestrationThreadSnapshot = Schema.Struct({
2021 schemaVersion: Schema.Literal(ORCHESTRATION_CACHE_SCHEMA_VERSION),
2122 environmentId: EnvironmentId,
2223 threadId: ThreadId,
23− thread: OrchestrationV2ThreadProjection,
24+ snapshot: OrchestrationV2ThreadDetailSnapshot.mapFields((fields) => ({
25+ ...fields,
26+ projection: OrchestrationV2ThreadProjectionJson,
27+ })),
2428 });
2529
2630 /** 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
55 type OrchestrationV2ShellStreamItem,
66 } from "@t3tools/contracts";
77 import { describe, expect, it } from "@effect/vitest";
8+import * as Deferred from "effect/Deferred";
89 import * as Effect from "effect/Effect";
10+import * as Exit from "effect/Exit";
11+import * as Fiber from "effect/Fiber";
912 import * as Option from "effect/Option";
1013 import * as Queue from "effect/Queue";
1114 import * as Ref from "effect/Ref";
15+import * as Scope from "effect/Scope";
1216 import * as Stream from "effect/Stream";
1317 import * as SubscriptionRef from "effect/SubscriptionRef";
1418
@@ -22,8 +26,8 @@ import * as ConnectionWakeups from "../connection/wakeups.ts"; · 7 unmodified lines
2226 import * as Persistence from "../platform/persistence.ts";
2327 import * as RpcSession from "../rpc/session.ts";
2428 import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
25−import { makeEnvironmentShellState } from "./shell.ts";
26−import { v2ShellSnapshot } from "./orchestrationV2TestFixtures.ts";
29+import { makeEnvironmentShellState, ShellSnapshotLoader } from "./shell.ts";
30+import { v2Project, v2ShellSnapshot } from "./orchestrationV2TestFixtures.ts";
2731
2832 const TARGET = new PrimaryConnectionTarget({
2933 environmentId: EnvironmentId.make("environment-1"),
@@ -70,7 +74,7 @@ describe("environment shell synchronization", () => { · 40 unmodified lines
7074 } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
7175 const cache = Persistence.EnvironmentCacheStore.of({
7276 loadShell: () => Effect.succeed(Option.none()),
73− saveShell: () => Effect.never,
77+ saveShell: () => Effect.void,
7478 loadThread: () => Effect.succeed(Option.none()),
7579 saveThread: () => Effect.void,
7680 removeThread: () => Effect.void,
@@ -352,4 +356,415 @@ describe("environment shell synchronization", () => { · 275 unmodified lines
352356 expect(yield* Ref.get(loaderCalls)).toBe(2);
353357 }),
354358 );
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+ );
355770 });
packages/client-runtime/src/state/shell.ts+48−11
@@ -24,7 +24,7 @@ import { EnvironmentCacheStore } from "../platform/persistence.ts"; · 23 unmodified lines
2424 import { subscribeDynamic } from "../rpc/client.ts";
2525 import type { RpcSession } from "../rpc/session.ts";
2626 import { ShellSnapshotLoader } from "./shellSnapshotHttp.ts";
27−import { applyShellStreamEvent } from "./shellReducer.ts";
27+import { applyShellStreamEvent, mergeShellSnapshotProjects } from "./shellReducer.ts";
2828 import type { EnvironmentCatalogState } from "./connections.ts";
2929 import { followStreamInEnvironment } from "./runtime.ts";
3030
@@ -72,23 +72,49 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") · 41 unmodified lines
7272 status: shellStatusForSnapshot(cachedSnapshot),
7373 error: Option.none(),
7474 });
75+ const awaitingCompletion = yield* Ref.make(false);
76+ const latestLiveSnapshot = yield* Ref.make<Option.Option<OrchestrationV2ShellSnapshot>>(
77+ Option.none(),
78+ );
7579 const persistence = yield* Queue.sliding<OrchestrationV2ShellSnapshot>(1);
7680
7781 const persist = Effect.fn("EnvironmentShellState.persist")(function* (
7882 snapshot: OrchestrationV2ShellSnapshot,
7983 ) {
80− yield* cache.saveShell(environmentId, snapshot).pipe(
81− Effect.catch((error) =>
82− Effect.logWarning("Could not persist environment shell cache.").pipe(
83− Effect.annotateLogs({
84− environmentId,
85− ...safeErrorLogAttributes(error),
86− }),
84+ let nextSnapshot = snapshot;
85+ while (true) {
86+ yield* cache.saveShell(environmentId, nextSnapshot).pipe(
87+ Effect.catch((error) =>
88+ Effect.logWarning("Could not persist environment shell cache.").pipe(
89+ Effect.annotateLogs({
90+ environmentId,
91+ ...safeErrorLogAttributes(error),
92+ }),
93+ ),
8794 ),
88− ),
89− );
95+ );
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);
90112 });
91113
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+
92118 yield* Stream.fromQueue(persistence).pipe(
93119 Stream.debounce("500 millis"),
94120 Stream.runForEach(persist),
@@ -102,6 +128,8 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") · 7 unmodified lines
102128 status: shellStatusForSnapshot(current.snapshot),
103129 })),
104130 ),
131+ Effect.andThen(flushLiveShellSnapshot.pipe(Effect.forkScoped)),
132+ Effect.asVoid,
105133 );
106134 const setSynchronizing = SubscriptionRef.update(state, (current) => ({
107135 ...current,
@@ -149,7 +177,15 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") · 41 unmodified lines
149177 const current = yield* SubscriptionRef.get(state);
150178 const nextSnapshot =
151179 item.kind === "snapshot"
152− ? item.snapshot
180+ ? mergeShellSnapshotProjects(
181+ Option.getOrNull(current.snapshot),
182+ item.snapshot,
183+ item.resolvedRepositoryIdentityRoots === undefined
184+ ? undefined
185+ : {
186+ resolvedRepositoryIdentityRoots: item.resolvedRepositoryIdentityRoots,
187+ },
188+ )
153189 : Option.match(current.snapshot, {
154190 onNone: () => null,
155191 onSome: (snapshot) =>
@@ -161,6 +197,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") · 5 unmodified lines
161197 return;
162198 }
163199
200+ yield* Ref.set(latestLiveSnapshot, Option.some(nextSnapshot));
164201 const waiting = yield* Ref.get(awaitingCompletion);
165202 yield* SubscriptionRef.set(state, {
166203 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
22 import { describe, expect, it } from "vite-plus/test";
33
44 import { v2Project, v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts";
5−import { applyShellStreamEvent } from "./shellReducer.ts";
5+import { applyShellStreamEvent, mergeShellSnapshotProjects } from "./shellReducer.ts";
6+
7+const 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+
16+const 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+};
624
725 describe("applyShellStreamEvent", () => {
826 it("ignores stale project updates without mutating the snapshot", () => {
@@ -42,6 +60,312 @@ describe("applyShellStreamEvent", () => { · 33 unmodified lines
4260 expect(removed.projects).toEqual([]);
4361 });
4462
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+
45369 it("moves a thread between active and archive without duplicating it", () => {
46370 const archived = applyShellStreamEvent(v2ShellSnapshot, {
47371 kind: "thread.updated",
packages/client-runtime/src/state/shellReducer.ts+89−2
@@ -1,4 +1,5 @@
11 import type {
2+ OrchestrationProjectShell,
23 OrchestrationV2ShellSnapshot,
34 OrchestrationV2ShellStreamItem,
45 } from "@t3tools/contracts";
@@ -12,6 +13,85 @@ function upsertById<T extends { readonly id: unknown }>( · 7 unmodified lines
1213 return items.map((candidate, candidateIndex) => (candidateIndex === index ? item : candidate));
1314 }
1415
16+function 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+
30+export 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+ */
50+export 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+
1595 /** Applies one committed V2 shell delta while preserving active/archive exclusivity. */
1696 export function applyShellStreamEvent(
1797 snapshot: OrchestrationV2ShellSnapshot,
@@ -20,12 +100,19 @@ export function applyShellStreamEvent( · 2 unmodified lines
20100 if (event.sequence <= snapshot.snapshotSequence) return snapshot;
21101
22102 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);
24110 return {
25111 ...snapshot,
26− projects: upsertById(snapshot.projects, event.project),
112+ projects: upsertById(snapshot.projects, project),
27113 snapshotSequence: event.sequence,
28114 };
115+ }
29116 case "project.removed":
30117 return {
31118 ...snapshot,
packages/contracts/src/orchestrationV2.test.ts+26−0
@@ -23,16 +23,42 @@ import { · 22 unmodified lines
2323 OrchestrationV2CheckpointScope,
2424 OrchestrationV2Command,
2525 OrchestrationV2DomainEvent,
26+ OrchestrationV2ShellSnapshot,
2627 OrchestrationV2Subagent,
2728 OrchestrationV2ThreadProjection,
2829 OrchestrationV2TurnItem,
2930 } from "./orchestrationV2.ts";
3031
3132 const now = DateTime.makeUnsafe("2026-04-20T00:00:00.000Z");
33+const LegacyShellStreamItem = Schema.Union([
34+ Schema.Struct({ kind: Schema.Literal("synchronized") }),
35+ Schema.Struct({
36+ kind: Schema.Literal("snapshot"),
37+ snapshot: OrchestrationV2ShellSnapshot,
38+ }),
39+]);
40+const decodeLegacyShellStreamItem = Schema.decodeUnknownSync(LegacyShellStreamItem);
3241 const decodeOrchestrationV2Command = Schema.decodeUnknownSync(OrchestrationV2Command);
3342 const decodeOrchestrationV2TurnItem = Schema.decodeUnknownSync(OrchestrationV2TurnItem);
3443
3544 describe("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+
3662 it("decodes nested checkpoint scopes without making child scopes advance app run count", () => {
3763 const rootScope = Schema.decodeUnknownSync(OrchestrationV2CheckpointScope)({
3864 id: "scope-root-1",
packages/contracts/src/orchestrationV2.ts+77−0
@@ -1185,6 +1185,12 @@ export const OrchestrationV2ShellStreamItem = Schema.Union([ · 1184 unmodified lines
11851185 Schema.Struct({
11861186 kind: Schema.Literal("snapshot"),
11871187 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)),
11881194 }),
11891195 Schema.Struct({
11901196 kind: Schema.Literal("project.updated"),
@@ -1528,6 +1534,77 @@ export const OrchestrationV2TurnItemJson = Schema.Union([ · 337 unmodified lines
15281534 ]);
15291535 export type OrchestrationV2TurnItemJson = typeof OrchestrationV2TurnItemJson.Type;
15301536
1537+export const OrchestrationV2ProjectedTurnItemJson = OrchestrationV2ProjectedTurnItem.mapFields(
1538+ (fields) => ({
1539+ ...fields,
1540+ item: OrchestrationV2TurnItemJson,
1541+ }),
1542+);
1543+export type OrchestrationV2ProjectedTurnItemJson = typeof OrchestrationV2ProjectedTurnItemJson.Type;
1544+
1545+export 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+);
1568+export type OrchestrationV2ThreadProjectionJson = typeof OrchestrationV2ThreadProjectionJson.Type;
1569+
1570+export const OrchestrationV2PendingRuntimeRequestSummaryJson =
1571+ OrchestrationV2PendingRuntimeRequestSummary.mapFields((fields) => ({
1572+ ...fields,
1573+ createdAt: Schema.DateTimeUtcFromString,
1574+ }));
1575+export type OrchestrationV2PendingRuntimeRequestSummaryJson =
1576+ typeof OrchestrationV2PendingRuntimeRequestSummaryJson.Type;
1577+
1578+export const OrchestrationV2LatestVisibleMessageSummaryJson =
1579+ OrchestrationV2LatestVisibleMessageSummary.mapFields((fields) => ({
1580+ ...fields,
1581+ updatedAt: Schema.DateTimeUtcFromString,
1582+ }));
1583+export type OrchestrationV2LatestVisibleMessageSummaryJson =
1584+ typeof OrchestrationV2LatestVisibleMessageSummaryJson.Type;
1585+
1586+export 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+}));
1597+export type OrchestrationV2ThreadShellJson = typeof OrchestrationV2ThreadShellJson.Type;
1598+
1599+export const OrchestrationV2ShellSnapshotJson = OrchestrationV2ShellSnapshot.mapFields(
1600+ (fields) => ({
1601+ ...fields,
1602+ threads: Schema.Array(OrchestrationV2ThreadShellJson),
1603+ archivedThreads: Schema.Array(OrchestrationV2ThreadShellJson),
1604+ }),
1605+);
1606+export type OrchestrationV2ShellSnapshotJson = typeof OrchestrationV2ShellSnapshotJson.Type;
1607+
15311608 const OrchestrationV2JsonEventBaseFields = {
15321609 ...OrchestrationV2EventBase.fields,
15331610 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
708708 }),
709709 );
710710
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+
711766 it.effect("cleans up interrupted extension requests before a late response arrives", () =>
712767 Effect.gen(function* () {
713768 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
117117 ),
118118 );
119119
120+const isEffectRpcRequestId = (requestId: string): boolean =>
121+ requestId !== "" && /^-?\d+$/.test(requestId);
122+
120123 export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(function* (
121124 options: AcpPatchedProtocolOptions,
122125 ): Effect.fn.Return<AcpPatchedProtocol, never, Scope.Scope> {
@@ -452,12 +455,19 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 329 unmodified lines
452455 return observeIncoming.pipe(Effect.andThen(Queue.offer(serverQueue, message)), Effect.asVoid);
453456 };
454457
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+
455465 const handleExitEncoded = (message: RpcMessage.ResponseExitEncoded) =>
456466 Ref.get(extPending).pipe(
457467 Effect.flatMap((pending) => {
458468 const pendingRequest = pending.get(String(message.requestId));
459469 if (!pendingRequest) {
460− return Queue.offer(clientQueue, message).pipe(Effect.asVoid);
470+ return forwardToRpcClient(message);
461471 }
462472 if (message.exit._tag === "Success") {
463473 return completeExtPendingSuccess(message.requestId, message.exit.value);
@@ -504,7 +514,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 40 unmodified lines
504514 message.requestId,
505515 ),
506516 )
507− : Queue.offer(clientQueue, message).pipe(Effect.asVoid);
517+ : forwardToRpcClient(message);
508518 }),
509519 );
510520 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