Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
166 commits
Select commit Hold shift + click to select a range
0f04d16
fix(server): scrolling back through a long thread no longer rebuilds …
bompus Oct 17, 2026
5dcc490
fix(server): reconcile history index cache with current upstream
bompus Oct 21, 2026
8209637
fix(web): SSH-launched servers no longer show a manual update prompt …
bompus Oct 30, 2026
8afe2dc
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 30, 2026
5de3655
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 30, 2026
a976f0e
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 30, 2026
f626316
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 30, 2026
2d3e41d
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 30, 2026
d4930ac
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 30, 2026
7761ba0
perf(server): stop serializing terminal chunks just to measure them (…
bompus Oct 30, 2026
d4ad9f7
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 30, 2026
e856461
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 30, 2026
d215f6e
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 30, 2026
0e91014
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 31, 2026
e16be7d
test(server): type the terminal byte-budget cases with the attach str…
bompus Oct 31, 2026
d1a1ca2
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 31, 2026
487f4f2
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 31, 2026
41b77f3
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 31, 2026
d902225
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 31, 2026
a2a1f7a
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 31, 2026
1cf7315
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 31, 2026
37ee735
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 31, 2026
d12b987
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 31, 2026
394e8df
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 31, 2026
6aa9cda
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 31, 2026
117a48e
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 31, 2026
c3227a3
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 31, 2026
81f2cb0
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 31, 2026
21e2fc4
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 31, 2026
a6d3cdb
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 31, 2026
4c17f77
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 31, 2026
a5f960b
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 31, 2026
2a7c983
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 31, 2026
8d45bcc
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 31, 2026
f0860d0
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 31, 2026
57b186b
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 31, 2026
9b04660
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 31, 2026
5d1d528
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 31, 2026
a8841c6
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 31, 2026
b8e7b30
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 31, 2026
c9e4dd3
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 31, 2026
db56917
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Oct 31, 2026
d1bfa6d
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 1, 2026
916353e
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 1, 2026
7ba929f
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 1, 2026
8d14008
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 1, 2026
70a8081
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 1, 2026
6b77588
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 1, 2026
583e93e
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 1, 2026
9693389
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 1, 2026
1a0f77a
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 1, 2026
1daf8a3
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 1, 2026
238413d
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 1, 2026
39e1f31
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 1, 2026
3df59ee
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 1, 2026
458a9e0
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 1, 2026
81e3296
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 1, 2026
359c13b
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 1, 2026
68f7108
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 1, 2026
b07ffe3
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 1, 2026
5c7de93
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 1, 2026
bf51c90
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 1, 2026
a0d06e7
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 1, 2026
0f9750b
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 1, 2026
ebdb2ab
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 1, 2026
1099450
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 1, 2026
92f8978
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 1, 2026
0c0aef4
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 1, 2026
58b40c6
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 1, 2026
f5a4763
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 1, 2026
06130a0
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 1, 2026
604dd08
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 1, 2026
031b81e
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 1, 2026
c4a3913
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
47b6515
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
2b38e37
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
13022ac
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
114f966
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
da4031f
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
4971dab
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
ce3f3f6
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
9823362
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
405a97c
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
239b39d
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
ca21e9d
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
fabb5fc
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
d14bdc3
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
0b705e2
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
182a8e7
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
cb6d7ae
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
44bcefc
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
92c4038
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
fa9ce4b
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
9bfafa7
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
890e5e6
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
cf29fa2
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
4caef5f
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
bf38d33
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
7af3e5c
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
fa89621
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
7ceb4e5
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
1035f07
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
e89a983
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
20a81a8
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
f9d3ce9
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
775397a
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
edf1f09
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
d77cc7c
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
8bb98fd
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
9193ec5
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
e8220ed
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 2, 2026
8de0026
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
ba8ad3d
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
74bca67
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
82e6941
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
f33a8b4
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
fe6531c
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
44866c4
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
6197c31
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
3d181b1
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
8ee6a08
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
d61d424
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
50722fb
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
221d9b5
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
f0252ce
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
9b9217b
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
e640818
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
65e7628
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
8bfd534
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
9be37c8
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
b620f37
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
302132e
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
5513847
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
697be19
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
b82f3d6
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
1357a14
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
bb7f255
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
003e4e2
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
24b38cf
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
ddce169
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
258f09a
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
76268bf
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
5d0040d
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
6a210a3
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
05310ee
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
9be0fb5
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
8512b6d
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
adc08e3
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 3, 2026
1eed173
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 4, 2026
fda2c16
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 4, 2026
211e0dd
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 4, 2026
1076679
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 4, 2026
de7f52f
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 4, 2026
d8c2fef
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 4, 2026
6a51b0b
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 4, 2026
d45eaef
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 4, 2026
baa7265
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 4, 2026
a5bddc7
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 4, 2026
3caf94a
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 4, 2026
570e123
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 4, 2026
68328fd
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 4, 2026
52f7564
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 4, 2026
cb767e7
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 4, 2026
c82d55c
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 4, 2026
0fdca26
Merge branch 'pingdotgg:main' into mirror/upstream-main
bompus Nov 4, 2026
c95785e
Merge remote-tracking branch 'origin/mirror/upstream-main' into repai…
bompus Nov 8, 2026
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
58 changes: 48 additions & 10 deletions apps/server/src/orchestration-v2/ProjectionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ import {
THREAD_HISTORY_INLINE_WORK_ITEM_LIMIT,
THREAD_HISTORY_TURN_SNAPSHOT_ROW_LIMIT,
} from "./threadHistoryPaging.ts";
import { makeThreadRevisionReader } from "./threadRevision.ts";

export class ProjectionStoreApplyEventError extends Schema.TaggedError<ProjectionStoreApplyEventError>()(
"ProjectionStoreApplyEventError",
Expand Down Expand Up @@ -5653,18 +5654,18 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
readonly item: TimelineIndexItem;
readonly synthetic?: OrchestrationV2TurnItem;
};
type TimelineIndex = {
readonly records: ProjectionRecords<"runs" | "attempts">;
readonly local: ReadonlyArray<TimelineIndexRow>;
readonly visible: ReadonlyArray<TimelineIndexRow>;
};
const readTimelineIndex: (
threadId: ThreadId,
seen: ReadonlySet<ThreadId>,
includePersistentOutput?: boolean,
) => Effect.Effect<
{
readonly records: ProjectionRecords<"runs" | "attempts">;
readonly local: ReadonlyArray<TimelineIndexRow>;
readonly visible: ReadonlyArray<TimelineIndexRow>;
},
ProjectionStoreV2Error
> = Effect.fn("ProjectionStore.readTimelineIndex")(
) => Effect.Effect<TimelineIndex, ProjectionStoreV2Error> = Effect.fn(
"ProjectionStore.readTimelineIndex",
)(
function* (threadId: ThreadId, seen: ReadonlySet<ThreadId>, includePersistentOutput = false) {
const records = yield* getThreadRecords(threadId, ["runs", "attempts"]);
const rows = yield* sql<{
Expand Down Expand Up @@ -5800,6 +5801,43 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
readTimelineIndex(threadId, new Set()).pipe(Effect.map((index) => index.visible)),
);

// Every history page needs the whole thread's index, so scrolling back through a long
// thread would rebuild it once per page. Entries are valid while the newest event of the
// thread and its fork sources is unchanged; a revision of 0 means no events to compare.
// A projection rebuild appends no events, so it needs a fresh store.
const readThreadRevision = yield* makeThreadRevisionReader;
const timelineIndexes = new Map<
string,
{ readonly sequence: number; readonly index: TimelineIndex }
>();
const TIMELINE_INDEX_CACHE_CAPACITY = 4;
// The index holds attempt and persistent-output fields only when includePersistentOutput
// is set, so the two variants of a thread are cached separately.
const readCurrentTimelineIndex = Effect.fn("ProjectionStore.readCurrentTimelineIndex")(
function* (threadId: ThreadId, includePersistentOutput = false) {
const key = `${threadId}:${includePersistentOutput}`;
const sequence = yield* readThreadRevision([threadId]).pipe(
Effect.mapError((cause) => new ProjectionStoreReadError({ threadId, cause })),
);
const cached = timelineIndexes.get(key);
if (cached !== undefined && cached.sequence === sequence && sequence > 0) {
timelineIndexes.delete(key);
timelineIndexes.set(key, cached);
return cached.index;
}
const index = yield* readTimelineIndex(threadId, new Set(), includePersistentOutput);
timelineIndexes.delete(key);
if (sequence > 0) {
timelineIndexes.set(key, { sequence, index });
if (timelineIndexes.size > TIMELINE_INDEX_CACHE_CAPACITY) {
const oldest = timelineIndexes.keys().next().value;
if (oldest !== undefined) timelineIndexes.delete(oldest);
}
}
return index;
},
);

const getThreadHistoryPage: ProjectionStoreV2Shape["getThreadHistoryPage"] = (
threadId,
cursor,
Expand All @@ -5810,7 +5848,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
sql
.withTransaction(
Effect.gen(function* () {
const index = yield* readTimelineIndex(threadId, new Set(), workMode !== undefined);
const index = yield* readCurrentTimelineIndex(threadId, workMode !== undefined);
if (index.records.thread.deletedAt !== null)
return yield* new ProjectionStoreThreadNotFoundError({ threadId });
const revisions = yield* sql<{
Expand Down Expand Up @@ -5927,7 +5965,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
sql
.withTransaction(
Effect.gen(function* () {
const index = yield* readTimelineIndex(threadId, new Set());
const index = yield* readCurrentTimelineIndex(threadId);
const attachmentOwners =
options.attachmentId === undefined
? undefined
Expand Down
33 changes: 33 additions & 0 deletions apps/server/src/orchestration-v2/ThreadFind.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -822,4 +822,37 @@ describe("V2 history materialization", () => {
assert.deepEqual(sizes, [1, 1, 75, 3]);
}).pipe(Effect.provide(layerTest)),
);

it.effect("keeps cached timeline reads current as the thread and its fork source change", () =>
Effect.gen(function* () {
const projection = yield* setup;
const parent = ThreadId.make("thread:parent");
const parentRun = RunId.make("run:parent");
yield* commit([thread(parent, projectId), run(parent, parentRun)]);
yield* putItems([{ ...item("p1", 1, "one", "assistant_message", parent), runId: parentRun }]);
const child = thread(threadId, projectId);
if (child.type !== "thread.created") return;
yield* commit([
{
...child,
id: EventId.make("child:fork"),
type: "thread.metadata-updated",
payload: {
...child.payload,
forkedFrom: { type: "run", threadId: parent, runId: parentRun },
},
},
]);
yield* putItems([item("c1", 1, "local")]);
const totalItems = projection
.getTimelinePage(threadId, { view: "activity", limit: 100 })
.pipe(Effect.map((page) => page.totalItems));
const initial = yield* totalItems;
assert.equal(yield* totalItems, initial);
yield* putItems([item("c2", 2, "local again")]);
assert.equal(yield* totalItems, initial + 1);
yield* putItems([{ ...item("p2", 2, "two", "assistant_message", parent), runId: parentRun }]);
assert.equal(yield* totalItems, initial + 2);
}).pipe(Effect.provide(layerTest)),
);
});
25 changes: 2 additions & 23 deletions apps/server/src/orchestration-v2/ThreadFind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
ProjectionStoreThreadNotFoundError,
type ProjectionStoreV2Error,
} from "./ProjectionStore.ts";
import { makeThreadRevisionReader } from "./threadRevision.ts";

interface FindRow {
readonly position: number;
Expand Down Expand Up @@ -183,7 +184,6 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* (
}),
),
);
const encodeThreadIds = Schema.encodeEffect(Schema.fromJsonString(Schema.Array(ThreadId)));
const encodeSources = Schema.encodeEffect(
Schema.fromJsonString(Schema.Array(Schema.Struct({ threadId: ThreadId, id: TurnItemId }))),
);
Expand Down Expand Up @@ -252,28 +252,7 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* (
sourceThreadIds: readonly ThreadId[];
}
>();
const readRevision = Effect.fn("ThreadFind.readRevision")(function* (
threadIds: readonly ThreadId[],
) {
const sources = yield* encodeThreadIds(threadIds);
const revision = yield* sql<{ sequence: number }>`
WITH RECURSIVE source_threads(thread_id) AS (
SELECT value FROM json_each(${sources})
UNION
SELECT json_extract(t.payload_json, '$.forkedFrom.threadId')
FROM orchestration_v2_projection_threads t
JOIN source_threads s ON s.thread_id = t.thread_id
WHERE json_extract(t.payload_json, '$.forkedFrom.type') = 'run'
)
SELECT COALESCE(MAX(sequence), 0) AS sequence FROM orchestration_events
-- The planner otherwise walks every V2 event backwards from the newest,
-- which takes seconds for a thread that has been idle a long time.
INDEXED BY idx_orch_events_stream_sequence
WHERE application_event_version = 2 AND aggregate_kind = 'thread'
AND stream_id IN (SELECT thread_id FROM source_threads)
`;
return revision[0]?.sequence ?? 0;
});
const readRevision = yield* makeThreadRevisionReader;
const snapshot = Effect.fn("ThreadFind.snapshot")(
function* (input: OrchestrationV2SearchThreadInput) {
const { threadId } = input;
Expand Down
33 changes: 33 additions & 0 deletions apps/server/src/orchestration-v2/threadRevision.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { ThreadId } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
import * as SqlClient from "effect/sql/SqlClient";

/**
* Latest event sequence across the given threads and every thread they fork
* from. Cached reads compare it with the value they were built at.
*/
export const makeThreadRevisionReader = Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
const encodeThreadIds = Schema.encodeEffect(Schema.fromJsonString(Schema.Array(ThreadId)));
return Effect.fn("ThreadFind.readRevision")(function* (threadIds: readonly ThreadId[]) {
const sources = yield* encodeThreadIds(threadIds);
const revision = yield* sql<{ sequence: number }>`
WITH RECURSIVE source_threads(thread_id) AS (
SELECT value FROM json_each(${sources})
UNION
SELECT json_extract(t.payload_json, '$.forkedFrom.threadId')
FROM orchestration_v2_projection_threads t
JOIN source_threads s ON s.thread_id = t.thread_id
WHERE json_extract(t.payload_json, '$.forkedFrom.type') = 'run'
)
SELECT COALESCE(MAX(sequence), 0) AS sequence FROM orchestration_events
-- The planner otherwise walks every V2 event backwards from the newest,
-- which takes seconds for a thread that has been idle a long time.
INDEXED BY idx_orch_events_stream_sequence
WHERE application_event_version = 2 AND aggregate_kind = 'thread'
AND stream_id IN (SELECT thread_id FROM source_threads)
`;
return revision[0]?.sequence ?? 0;
});
});
72 changes: 72 additions & 0 deletions apps/server/src/terminal/OutputProtocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,78 @@ describe("terminal output window", () => {
assert.equal((yield* Queue.take(responses))._tag, "Exit");
}).pipe(Effect.provide(RpcSerialization.layerJson), Effect.scoped),
);

const bigChunkEvents = [
{
name: "error messages",
event: {
threadId: "thread-1",
terminalId: "default",
type: "error" as const,
message: "e".repeat(64 * 1024),
},
},
{
name: "thread ids",
event: {
threadId: "t".repeat(64 * 1024),
terminalId: "default",
type: "output" as const,
data: "x",
},
},
];

it.effect.each(bigChunkEvents)("charges $name against the pending-byte limit", ({ event }) =>
Effect.gen(function* () {
const tag = WS_METHODS.terminalAttach;
const group = RpcGroup.make(
Rpc.make(tag, { success: TerminalAttachStreamEventSchema, stream: true }),
);
const output =
yield* Queue.unbounded<Schema.Schema.Type<typeof TerminalAttachStreamEventSchema>>();
const responses = yield* Queue.unbounded<RpcMessage.FromServerEncoded>();
const receive = yield* Deferred.make<Parameters<RpcServer.Protocol["Service"]["run"]>[0]>();
const protocol = yield* RpcServer.Protocol.make((write) =>
Effect.gen(function* () {
yield* Deferred.succeed(receive, write);
const serialization = yield* RpcSerialization.RpcSerialization;
return {
disconnects: yield* Queue.unbounded<number>(),
send: (_clientId, response) => Queue.offer(responses, response),
end: () => Effect.void,
clientIds: Effect.succeed(new Set([0])),
initialMessage: Effect.succeedNone,
supportsAck: true,
supportsTransferables: false,
supportsSpanPropagation: false,
supportsNotifications: true,
codecFor: serialization.codecFor,
};
}),
);
yield* RpcServer.make(group).pipe(
Effect.provide(group.toLayerHandler(tag, () => Stream.fromQueue(output))),
Effect.provideService(RpcServer.Protocol, withTerminalOutputWindow(protocol)),
Effect.forkScoped,
);
const write = yield* Deferred.await(receive);
yield* write(0, { _tag: "Request", id: "1", tag, payload: null, headers: [] });

yield* Queue.offer(output, event);
yield* TestClock.adjust(0);
assert.equal(yield* Queue.size(responses), 1);
yield* Queue.take(responses);
// One 64 KiB field fills the byte window on its own.
yield* Queue.offer(output, event);
yield* TestClock.adjust(0);
assert.equal(yield* Queue.size(responses), 0);
yield* write(0, { _tag: "Ack", requestId: "1" });
assert.equal((yield* Queue.take(responses))._tag, "Chunk");
yield* write(0, { _tag: "Interrupt", requestId: "1" });
assert.equal((yield* Queue.take(responses))._tag, "Exit");
}).pipe(Effect.provide(RpcSerialization.layerJson), Effect.scoped),
);
});

const output = (data: string): TerminalAttachStreamEvent => ({
Expand Down
69 changes: 56 additions & 13 deletions apps/server/src/terminal/OutputProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,65 @@ import type { RpcServer } from "effect/rpc";

const MAX_PENDING_CHUNKS = 8;
const MAX_PENDING_BYTES = 64 * 1024;
// Charge an envelope so tiny events cannot bypass the byte budget.
const EVENT_OVERHEAD = 128;
const isFull = (sizes: number[]) =>
sizes.length >= MAX_PENDING_CHUNKS ||
sizes.reduce((total, size) => total + size, 0) >= MAX_PENDING_BYTES;

const byteLengthOf = (value: unknown) => (typeof value === "string" ? Buffer.byteLength(value) : 0);

// Charges every variable-size field a terminal event can carry, so the byte
// budgets below cannot be bypassed by a large payload in an uncharged field.
const queuedSize = (event: TerminalEvent | TerminalAttachStreamEvent) =>
EVENT_OVERHEAD +
("threadId" in event ? byteLengthOf(event.threadId) + byteLengthOf(event.terminalId) : 0) +
(event.type === "output"
? byteLengthOf(event.data)
: event.type === "error"
? byteLengthOf(event.message)
: event.type === "activity"
? byteLengthOf(event.label)
: "snapshot" in event
? byteLengthOf(event.snapshot.threadId) +
byteLengthOf(event.snapshot.terminalId) +
byteLengthOf(event.snapshot.history) +
byteLengthOf(event.snapshot.cwd) +
byteLengthOf(event.snapshot.worktreePath) +
byteLengthOf(event.snapshot.label)
: 0);

const TERMINAL_EVENT_TYPES = new Set([
"started",
"output",
"exited",
"closed",
"error",
"cleared",
"restarted",
"activity",
"snapshot",
]);

// Windowed chunks carry terminal events, so charge them the way the output
// queue below does instead of serializing the frame a second time just to
// measure it. Only the event shapes queuedSize measures take the fast path;
// anything else keeps the exact JSON measurement.
const chunkSize = (values: ReadonlyArray<unknown>) =>
values.reduce<number>((total, value) => {
if (typeof value === "string") return total + EVENT_OVERHEAD + Buffer.byteLength(value);
if (
typeof value === "object" &&
value !== null &&
"type" in value &&
typeof value.type === "string" &&
TERMINAL_EVENT_TYPES.has(value.type)
) {
return total + queuedSize(value as TerminalEvent | TerminalAttachStreamEvent);
}
return total + Buffer.byteLength(JSON.stringify(value) ?? "");
}, EVENT_OVERHEAD);

export function withTerminalOutputWindow(
protocol: RpcServer.Protocol["Service"],
): RpcServer.Protocol["Service"] {
Expand Down Expand Up @@ -50,9 +105,7 @@ export function withTerminalOutputWindow(
} else if (response._tag === "Chunk") {
const window = windows.get(`${clientId}:${response.requestId}`);
if (window) {
// @effect-diagnostics-next-line preferSchemaOverJson:off
const size = Buffer.byteLength(JSON.stringify(response));
window.push(size);
window.push(chunkSize(response.values));
if (!isFull(window)) {
return send.pipe(
Effect.andThen(() =>
Expand All @@ -71,8 +124,6 @@ export function withTerminalOutputWindow(
}

const MAX_QUEUED_OUTPUT = 8 * 1024 * 1024;
// Charge an envelope so tiny events cannot bypass the byte budget.
const EVENT_OVERHEAD = 128;

type Listener<A> = (event: A) => Effect.Effect<void>;
// Acquisition must register cleanup synchronously with installing the listener.
Expand All @@ -81,14 +132,6 @@ type Subscribe<A, E> = (
registerCleanup: (stop: () => void) => void,
) => Effect.Effect<() => void, E>;

const queuedSize = (event: TerminalEvent | TerminalAttachStreamEvent) =>
EVENT_OVERHEAD +
(event.type === "output"
? Buffer.byteLength(event.data)
: "snapshot" in event
? Buffer.byteLength(event.snapshot.history)
: 0);

export function terminalOutputStream<A extends TerminalEvent | TerminalAttachStreamEvent, E>(
subscribe: Subscribe<A, E>,
resync?: Subscribe<A, E>,
Expand Down
Loading