Skip to content
184 changes: 184 additions & 0 deletions packages/client-runtime/src/state/shell-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,190 @@ const makePersistenceHarness = Effect.fn("test.makeShellPersistenceHarness")(fun
});

describe("environment shell synchronization", () => {
it.effect.each([
{ bufferSize: Infinity, expectedSequences: [51], existingRows: false },
{ bufferSize: Infinity, expectedSequences: [51], existingRows: true },
// RpcClient defaults to a 16-event buffer, which splits larger server chunks.
{ bufferSize: 16, expectedSequences: [17, 33, 49, 51], existingRows: false },
{ bufferSize: 16, expectedSequences: [17, 33, 49, 51], existingRows: true },
])("batches $bufferSize events (existing: $existingRows)", (input) =>
Effect.gen(function* () {
const { bufferSize, expectedSequences, existingRows } = input;
const events = yield* Queue.bounded<OrchestrationV2ShellStreamItem>(bufferSize);
let rowVisits = 0;
const rows = Array.from({ length: 50 }, (_, index) => ({
...v2ThreadShell,
get id() {
rowVisits += 1;
return ThreadId.make(`thread-${index}`);
},
}));
const batchSnapshot = { ...LIVE_SHELL_SNAPSHOT, threads: existingRows ? rows : [] };
const client = {
[ORCHESTRATION_V2_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events),
} as unknown as WsRpcProtocolClient;
const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE);
const activeSession = yield* SubscriptionRef.make<Option.Option<RpcSession.RpcSession>>(
Option.some(session(client)),
);
const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
target: TARGET,
state: supervisorState,
session: activeSession,
prepared: yield* SubscriptionRef.make(Option.some(PREPARED)),
connect: Effect.void,
disconnect: Effect.void,
retryNow: Effect.void,
} satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
const cache = Persistence.EnvironmentCacheStore.of({
loadShell: () => Effect.succeedNone,
saveShell: () => Effect.void,
loadThread: () => Effect.succeedNone,
saveThread: () => Effect.void,
removeThread: () => Effect.void,
loadServerConfig: () => Effect.succeedNone,
saveServerConfig: () => Effect.void,
loadVcsRefs: () => Effect.succeedNone,
saveVcsRefs: () => Effect.void,
removeVcsRefs: () => Effect.void,
clearVcsRefs: () => Effect.void,
clear: () => Effect.void,
});
const shellState = yield* makeEnvironmentShellState().pipe(
Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
Effect.provideService(Persistence.EnvironmentCacheStore, cache),
Effect.provideService(
ShellSnapshotLoader.ShellSnapshotLoader,
ShellSnapshotLoader.ShellSnapshotLoader.of({
load: () => Effect.succeedNone,
}),
),
);
yield* SubscriptionRef.set(supervisorState, {
desired: true,
network: "online",
phase: "connected",
stage: null,
attempt: 1,
generation: 1,
lastFailure: null,
retryAt: null,
});
yield* Queue.offer(events, { kind: "snapshot", snapshot: batchSnapshot });
yield* Queue.offer(events, { kind: "synchronized" });
yield* SubscriptionRef.changes(shellState).pipe(
Stream.filter((state) => state.status === "live"),
Stream.runHead,
);

// Observe before publishing so no batch can arrive before the subscription.
const observed = yield* SubscriptionRef.changes(shellState).pipe(
Stream.drop(1),
Stream.takeUntil(
(state) => Option.isSome(state.snapshot) && state.snapshot.value.snapshotSequence === 51,
),
Stream.runCollect,
Effect.forkScoped({ startImmediately: true }),
);
rowVisits = 0;
yield* Queue.offerAll(
events,
Array.from({ length: 50 }, (_, index) => ({
kind: "thread.updated" as const,
sequence: 2 + index,
location: "active" as const,
thread: {
...v2ThreadShell,
get id() {
rowVisits += 1;
return ThreadId.make(`thread-${index}`);
},
title: "Updated",
},
})),
);
const states = yield* Fiber.join(observed);
if (existingRows) {
// Index each received batch once; distinct updates must not rescan every row.
expect(rowVisits).toBeLessThanOrEqual(50 * (expectedSequences.length + 10));
}
const snapshots = states.map((state) => Option.getOrThrow(state.snapshot));
expect(snapshots.map((snapshot) => snapshot.snapshotSequence)).toEqual(expectedSequences);
expect(snapshots.at(-1)!.threads.map((thread) => thread.id)).toEqual(
Array.from({ length: 50 }, (_, index) => `thread-${index}`),
);

const removedObserved = yield* SubscriptionRef.changes(shellState).pipe(
Stream.filter(
(state) => Option.isSome(state.snapshot) && state.snapshot.value.snapshotSequence === 52,
),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
);
yield* Queue.offerAll(events, [
{ kind: "future.event", sequence: 99 } as never,
{
kind: "thread.removed",
sequence: 52,
location: "active",
threadId: ThreadId.make("thread-0"),
},
{
kind: "thread.removed",
sequence: 51,
location: "active",
threadId: ThreadId.make("thread-1"),
},
]);
const removedState = Option.getOrThrow(yield* Fiber.join(removedObserved));
expect(removedState.generation).toBe(states.at(-1)!.generation);
expect([...removedState.removedAt]).toEqual([[ThreadId.make("thread-0"), 52]]);

// An authoritative snapshot resets even a higher live cursor in the same batch.
const resetRow = { ...v2ThreadShell, id: ThreadId.make("reset-row") };
const resetObserved = yield* SubscriptionRef.changes(shellState).pipe(
Stream.filter(
(state) => Option.isSome(state.snapshot) && state.snapshot.value.snapshotSequence === 4,
),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
);
yield* Queue.offerAll(events, [
{
kind: "thread.removed",
sequence: 52,
location: "active",
threadId: ThreadId.make("thread-0"),
},
{
kind: "snapshot",
snapshot: { ...LIVE_SHELL_SNAPSHOT, snapshotSequence: 2, threads: [resetRow] },
},
{ kind: "thread.removed", sequence: 2, location: "active", threadId: resetRow.id },
{
kind: "thread.updated",
sequence: 3,
location: "active",
thread: { ...resetRow, title: "After reset" },
},
{ kind: "synchronized" },
{
kind: "thread.updated",
sequence: 4,
location: "active",
thread: { ...resetRow, title: "After completion" },
},
]);
const resetState = Option.getOrThrow(yield* Fiber.join(resetObserved));
expect(resetState.status).toBe("live");
expect(resetState.generation).toBeGreaterThan(removedState.generation);
expect(resetState.removedAt.size).toBe(0);
expect(Option.getOrThrow(resetState.snapshot).threads).toEqual([
{ ...resetRow, title: "After completion" },
]);
}).pipe(Effect.scoped),
);

it.effect("skips unchanged shell writes, persists resets and flushes changed rows", () =>
Effect.gen(function* () {
const saved = yield* SubscriptionRef.make<ReadonlyArray<OrchestrationV2ShellSnapshot>>([]);
Expand Down
85 changes: 50 additions & 35 deletions packages/client-runtime/src/state/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ import type { RpcSession } from "../rpc/session.ts";
import * as ShellSnapshotLoader from "./shellSnapshotHttp.ts";
import type { DeferredShellSnapshot } from "./shellPullRequests.ts";
import {
applyShellStreamEvent,
applyShellStreamEvents,
type ShellStreamEvent,
mergeShellSnapshotProjects,
reuseUnchangedThreadShells,
sameThreadPullRequests,
Expand Down Expand Up @@ -211,57 +212,71 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")
) {
const initial = yield* SubscriptionRef.get(state);
let waiting = yield* Ref.get(awaitingCompletion);
/* oxlint-disable oxc/no-accumulating-spread -- This copies a fixed-shape state record, not a growing collection. */
let next = initial;
let receivedSnapshot = false;
let deltas: Array<ShellStreamEvent> = [];
const flushDeltas = () => {
if (deltas.length > 0 && Option.isSome(next.snapshot)) {
let sequence = next.snapshot.value.snapshotSequence;
let removedAt: Map<ThreadId, number> | undefined;
for (const delta of deltas) {
if (delta.sequence <= sequence) continue;
switch (delta.kind) {
case "thread.removed":
removedAt ??= new Map(next.removedAt);
removedAt.set(delta.threadId, delta.sequence);
break;
case "thread.updated":
case "project.updated":
case "project.removed":
break;
default:
continue;
}
sequence = delta.sequence;
}
next = {
...next,
removedAt: removedAt ?? next.removedAt,
snapshot: Option.some(applyShellStreamEvents(next.snapshot.value, deltas)),
status: waiting ? "synchronizing" : "live",
error: Option.none(),
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
deltas = [];
};
for (const item of items) {
if (item.kind !== "snapshot" && item.kind !== "synchronized") {
deltas.push(item);
continue;
}
// Snapshots can reset the cursor; completion markers change the sync status.
flushDeltas();
if (item.kind === "synchronized") {
waiting = false;
if (Option.isSome(next.snapshot)) {
next = { ...next, status: "live", error: Option.none() };
}
continue;
}
const authoritative =
item.kind === "snapshot" && item.resolvedRepositoryIdentityRoots === undefined;
const nextSnapshot =
item.kind === "snapshot"
? mergeShellSnapshotProjects(
Option.getOrNull(next.snapshot),
item.snapshot,
item.resolvedRepositoryIdentityRoots === undefined
? undefined
: {
resolvedRepositoryIdentityRoots: item.resolvedRepositoryIdentityRoots,
},
)
: Option.match(next.snapshot, {
onNone: () => null,
onSome: (snapshot) =>
item.sequence > snapshot.snapshotSequence
? applyShellStreamEvent(snapshot, item)
: snapshot,
});
if (nextSnapshot === null) continue;
receivedSnapshot ||= item.kind === "snapshot";
const removal =
item.kind === "thread.removed" &&
Option.exists(next.snapshot, (snapshot) => item.sequence > snapshot.snapshotSequence)
? item
: null;
const authoritative = item.resolvedRepositoryIdentityRoots === undefined;
const nextSnapshot = mergeShellSnapshotProjects(
Option.getOrNull(next.snapshot),
item.snapshot,
item.resolvedRepositoryIdentityRoots === undefined
? undefined
: { resolvedRepositoryIdentityRoots: item.resolvedRepositoryIdentityRoots },
);
receivedSnapshot = true;
next = {
snapshot: Option.some(nextSnapshot),
status: waiting ? "synchronizing" : "live",
error: Option.none(),
generation: authoritative ? ++lastShellGeneration : next.generation,
removedAt: authoritative
? EMPTY_REMOVALS
: removal === null
? next.removedAt
: new Map(next.removedAt).set(removal.threadId, removal.sequence),
removedAt: authoritative ? EMPTY_REMOVALS : next.removedAt,
};
}
/* oxlint-enable oxc/no-accumulating-spread */
flushDeltas();
yield* Ref.set(awaitingCompletion, waiting);
if (next === initial) return;
if (pendingLinkIds !== undefined && Option.isSome(next.snapshot)) {
Expand Down
Loading
Loading