Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion apps/mobile/src/connection/environment-cache-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,12 +498,21 @@ describe("mobile SQLite environment cache store", () => {
Effect.provideService(MobileDatabase.MobileDatabase, memory.database),
);

yield* store.saveShell(ENVIRONMENT_ID, SHELL_SNAPSHOT);
const origin = {
serverVersion: "1.0.0",
databaseMigration: 70,
omitIdleSubagents: true,
omitOlderSettled: false,
snapshotAt: 1_800_000_000_000,
};
yield* store.saveShell(ENVIRONMENT_ID, SHELL_SNAPSHOT, origin);
yield* store.saveThread(ENVIRONMENT_ID, THREAD_SNAPSHOT);

const shell = Option.getOrThrow(yield* store.loadShell(ENVIRONMENT_ID));
const thread = Option.getOrThrow(yield* store.loadThread(ENVIRONMENT_ID, THREAD_ID));

expect(shell.origin).toEqual(origin);

expect(DateTime.formatIso(shell.threads[0]!.updatedAt)).toBe("2026-07-29T12:00:00.000Z");
expect(DateTime.formatIso(shell.threads[0]!.latestUserMessageAt!)).toBe(
"2026-07-29T12:00:00.000Z",
Expand Down
37 changes: 26 additions & 11 deletions apps/mobile/src/connection/environment-cache-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,13 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {
operation: "load-shell",
decode: decodeStoredShellSnapshot,
select: (stored) =>
stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(),
stored.environmentId === environmentId
? Option.some(
stored.origin === undefined
? stored.snapshot
: { ...stored.snapshot, origin: stored.origin },
)
: Option.none(),
}).pipe(
Effect.zipWith(
Effect.promise(() => projectFaviconDatabaseCache.hydrate()),
Expand All @@ -115,16 +121,25 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {
),
),
),
saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) {
const payload = yield* encodeStoredShellSnapshot({
schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION,
environmentId,
snapshot,
}).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause)));
yield* database
.saveCache(environmentId, "shell", "snapshot", ORCHESTRATION_CACHE_SCHEMA_VERSION, payload)
.pipe(Effect.mapError(mapDatabaseError("save-shell")));
}),
saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(
function* (environmentId, snapshot, origin) {
const payload = yield* encodeStoredShellSnapshot({
schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION,
environmentId,
snapshot,
...(origin === undefined ? {} : { origin }),
}).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause)));
yield* database
.saveCache(
environmentId,
"shell",
"snapshot",
ORCHESTRATION_CACHE_SCHEMA_VERSION,
payload,
)
.pipe(Effect.mapError(mapDatabaseError("save-shell")));
},
),
loadThread: Effect.fn("MobileEnvironmentCache.loadThread")((environmentId, threadId) =>
loadDecodedCache({
database,
Expand Down
33 changes: 24 additions & 9 deletions apps/server/src/ws.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1434,10 +1434,15 @@ it.effect("a publish whose push fails still refreshes the new remote", () =>
),
);

// Pruned history cannot be replayed: a cursor below the boundary must resync from a snapshot.
it.effect.each(["thread", "shell"] as const)(
"%s resume from a cursor below the pruned boundary loads a snapshot",
(scope) =>
// Pruned history cannot be replayed: a cursor below the boundary must resync from a snapshot,
// or, for a shell client that loads it over HTTP, be told to without the server building one.
it.effect.each([
{ scope: "thread", httpSnapshotFallback: false },
{ scope: "shell", httpSnapshotFallback: false },
{ scope: "shell", httpSnapshotFallback: true },
] as const)(
"$scope resume from a cursor below the pruned boundary (HTTP fallback: $httpSnapshotFallback)",
({ scope, httpSnapshotFallback }) =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
const now = "2026-10-13T00:00:00.000Z";
Expand All @@ -1461,11 +1466,18 @@ it.effect.each(["thread", "shell"] as const)(
) VALUES (${OrchestrationEventStore.EVENT_LOG_PRUNED_THROUGH}, 2, ${second!.sequence}, ${now})
`;
const subscription = Effect.gen(function* () {
const stream =
const items: ReadonlyArray<unknown> =
scope === "thread"
? yield* subscribeOrchestrationV2Thread({ threadId, afterSequence: first!.sequence })
: yield* subscribeOrchestrationV2Shell({ afterSequence: first!.sequence });
return stream;
? yield* Stream.runCollect(
yield* subscribeOrchestrationV2Thread({ threadId, afterSequence: first!.sequence }),
)
: yield* Stream.runCollect(
yield* subscribeOrchestrationV2Shell({
afterSequence: first!.sequence,
...(httpSnapshotFallback ? { httpSnapshotFallback: true as const } : {}),
}),
);
return items;
});
const exit = yield* subscription.pipe(
Effect.provideService(ThreadManagement.ThreadManagementService, {
Expand All @@ -1487,7 +1499,10 @@ it.effect.each(["thread", "shell"] as const)(
} as ProjectEnrichment.ProjectEnrichmentService["Service"]),
Effect.exit,
);
assert.deepEqual(Exit.isFailure(exit) ? Cause.squash(exit.cause) : exit, "snapshot");
assert.deepEqual(
Exit.isFailure(exit) ? Cause.squash(exit.cause) : Array.from(exit.value),
httpSnapshotFallback ? [{ kind: "snapshot-required" }] : "snapshot",
);
}).pipe(Effect.scoped, Effect.provide(ReplayTestLayer)),
);

Expand Down
22 changes: 15 additions & 7 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import {
OrchestrationV2ThreadLaunchError,
type OrchestrationProjectShell,
type OrchestrationV2ShellSnapshot,
type OrchestrationV2ShellStreamItem,
type OrchestrationV2ThreadStreamItem,
type ApplicationStoredEvent,
type ProjectEntriesFailure,
Expand Down Expand Up @@ -1115,6 +1116,7 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs
export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subscribeShell")(
function* (input: {
readonly afterSequence?: number;
readonly httpSnapshotFallback?: true;
readonly requestCompletionMarker?: boolean;
readonly omitIdleSubagents?: boolean;
readonly omitOlderSettled?: boolean;
Expand Down Expand Up @@ -1325,14 +1327,20 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc
});
}

const fromSnapshot = Effect.gen(function* () {
const loaded = yield* loadSnapshot();
return composeShellStreamWithEnrichment({
initial: initialSnapshotItems(loaded),
tail: completionThenLive(loaded.snapshot.snapshotSequence),
enrichment: enrichmentRefreshes,
});
const snapshotRequired: Stream.Stream<OrchestrationV2ShellStreamItem> = Stream.make({
kind: "snapshot-required",
});
const fromSnapshot =
input.httpSnapshotFallback === true
? Effect.succeed(snapshotRequired)
: Effect.gen(function* () {
const loaded = yield* loadSnapshot();
return composeShellStreamWithEnrichment({
initial: initialSnapshotItems(loaded),
tail: completionThenLive(loaded.snapshot.snapshotSequence),
enrichment: enrichmentRefreshes,
});
});
const highWater = yield* applicationEvents.latestApplicationSequence;
if (!(yield* canReplayPersistedRange(input.afterSequence, highWater, SHELL_RESUME_MAX_GAP))) {
return yield* fromSnapshot;
Expand Down
9 changes: 7 additions & 2 deletions apps/web/src/connection/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -846,7 +846,11 @@ export const layer = Layer.effectContext(
Effect.mapError((cause) => persistenceError("load-shell", cause)),
Effect.map((stored) =>
stored.environmentId === environmentId
? Option.some(stored.snapshot)
? Option.some(
stored.origin === undefined
? stored.snapshot
: { ...stored.snapshot, origin: stored.origin },
)
: Option.none(),
),
),
Expand All @@ -857,11 +861,12 @@ export const layer = Layer.effectContext(
);
}),
saveShell: Effect.fn("storage.saveShell")(
function* (environmentId, snapshot) {
function* (environmentId, snapshot, origin) {
const encoded = yield* encodeStoredShellSnapshot({
schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION,
environmentId,
snapshot,
...(origin === undefined ? {} : { origin }),
}).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause)));
yield* writeDatabaseValue(database, SHELL_STORE_NAME, environmentId, encoded);
},
Expand Down
15 changes: 15 additions & 0 deletions packages/client-runtime/src/platform/orchestrationCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,25 @@ export const ORCHESTRATION_CACHE_SCHEMA_VERSION = 3 as const;
// Older caches may contain human turns that bypassed server history budgets.
export const ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION = 5 as const;

/**
* The server snapshot the cached rows descend from. Written only for rows that have one, so a
* cache from before it existed decodes and never resumes.
*/
export const ShellCacheOrigin = Schema.Struct({
serverVersion: Schema.String,
databaseMigration: Schema.NullOr(Schema.Int),
omitIdleSubagents: Schema.Boolean,
omitOlderSettled: Schema.Boolean,
/** Epoch milliseconds of that snapshot; replay does not renew it. */
snapshotAt: Schema.Number,
});
export type ShellCacheOrigin = typeof ShellCacheOrigin.Type;

export const StoredOrchestrationShellSnapshot = Schema.Struct({
schemaVersion: Schema.Literal(ORCHESTRATION_CACHE_SCHEMA_VERSION),
environmentId: EnvironmentId,
snapshot: OrchestrationV2ShellSnapshotJson,
origin: Schema.optionalKey(ShellCacheOrigin),
});

export const StoredOrchestrationThreadSnapshot = Schema.Struct({
Expand Down
11 changes: 10 additions & 1 deletion packages/client-runtime/src/platform/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ import * as Schema from "effect/Schema";
import type { ConnectionRegistration } from "../connection/catalog.ts";
import type { DeferredShellSnapshot } from "../state/shellPullRequests.ts";
import type { ConnectionTarget, PersistedConnectionTarget } from "../connection/model.ts";
import type { ShellCacheOrigin } from "./orchestrationCache.ts";

export type { ShellCacheOrigin };

export interface CachedShellSnapshot extends DeferredShellSnapshot {
readonly origin?: ShellCacheOrigin;
}

export class ConnectionPersistenceError extends Schema.TaggedError<ConnectionPersistenceError>()(
"ConnectionPersistenceError",
Expand Down Expand Up @@ -82,10 +89,12 @@ export class EnvironmentCacheStore extends Context.Service<
{
readonly loadShell: (
environmentId: EnvironmentId,
) => Effect.Effect<Option.Option<DeferredShellSnapshot>, ConnectionPersistenceError>;
) => Effect.Effect<Option.Option<CachedShellSnapshot>, ConnectionPersistenceError>;
/** `origin` only when the rows descend from a server snapshot. */
readonly saveShell: (
environmentId: EnvironmentId,
snapshot: OrchestrationV2ShellSnapshot,
origin?: ShellCacheOrigin,
) => Effect.Effect<void, ConnectionPersistenceError>;
readonly loadThread: (
environmentId: EnvironmentId,
Expand Down
Loading
Loading