authorgravatar for saiansh2525@gmail.comYash Singh <saiansh2525@gmail.com> 2026-10-08 22:23:20-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 22:23:20-05:00
log42c6623d492850c747b7ba06c1d0f1e1559d0ad5
tree8f159eb26f27c3e872287b9adb9de780016e9fdb
parentb707eeb052782cfd8b1ff0445f84b2aaf01da38b
signature Signed by PGP key B5690EEEBB952194

fix(server): speed up long thread message sync (#17387)


5 files changed, 179 insertions(+), 60 deletions(-)

apps/server/src/orchestration-v2/ProjectionStore.test.ts+114-22
...@@ -54,6 +54,23 @@ const driver = ProviderDriverKind.make("codex");...@@ -54,6 +54,23 @@ const driver = ProviderDriverKind.make("codex");
54const providerInstanceId = modelSelection.instanceId;54const providerInstanceId = modelSelection.instanceId;
55const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));55const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
5656
57const traceSqlStatements = () => {
58 const statements: Array<string> = [];
59 const tracer = Tracer.make({
60 span(options) {
61 const span = new Tracer.NativeSpan(options);
62 const end = span.end.bind(span);
63 span.end = (endTime, exit) => {
64 end(endTime, exit);
65 const query = span.attributes.get("db.query.text");
66 if (typeof query === "string") statements.push(query);
67 };
68 return span;
69 },
70 });
71 return { statements, tracer };
72};
73
57const addRolledBackRecoveryCandidate = Effect.fn("addRolledBackRecoveryCandidate")(function* (74const addRolledBackRecoveryCandidate = Effect.fn("addRolledBackRecoveryCandidate")(function* (
58 suffix: string,75 suffix: string,
59) {76) {
...@@ -590,19 +607,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {...@@ -590,19 +607,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
590 });607 });
591 yield* sql`INSERT INTO orchestration_v2_projection_turn_items ${sql.insert(rows)}`;608 yield* sql`INSERT INTO orchestration_v2_projection_turn_items ${sql.insert(rows)}`;
592 }609 }
593 const statements: Array<string> = [];610 const { statements, tracer } = traceSqlStatements();
594 const tracer = Tracer.make({
595 span(options) {
596 const span = new Tracer.NativeSpan(options);
597 const end = span.end.bind(span);
598 span.end = (endTime, exit) => {
599 end(endTime, exit);
600 const query = span.attributes.get("db.query.text");
601 if (typeof query === "string") statements.push(query);
602 };
603 return span;
604 },
605 });
606 const initial = yield* Effect.acquireUseRelease(611 const initial = yield* Effect.acquireUseRelease(
607 Effect.sync(() => vi.spyOn(JSON, "parse")),612 Effect.sync(() => vi.spyOn(JSON, "parse")),
608 (parse) =>613 (parse) =>
...@@ -635,10 +640,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {...@@ -635,10 +640,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
635 /SEARCH item USING INDEX orchestration_v2_projection_turn_items_user_message_idx \(thread_id=\? AND ordinal<\?\)/,640 /SEARCH item USING INDEX orchestration_v2_projection_turn_items_user_message_idx \(thread_id=\? AND ordinal<\?\)/,
636 );641 );
637 const topLevel = windowPlan.filter((row) => row.parent === 0).map((row) => row.detail);642 const topLevel = windowPlan.filter((row) => row.parent === 0).map((row) => row.detail);
638 assert.include(643 assert.include(topLevel, "SEARCH item USING INTEGER PRIMARY KEY (rowid=?)");
639 topLevel,
640 "SEARCH item USING INDEX sqlite_autoindex_orchestration_v2_projection_turn_items_1 (turn_item_id=?)",
641 );
642 assert.notInclude(topLevel, "USE TEMP B-TREE FOR ORDER BY");644 assert.notInclude(topLevel, "USE TEMP B-TREE FOR ORDER BY");
643 const nodeStatement = statements.find((statement) =>645 const nodeStatement = statements.find((statement) =>
644 statement.includes("WITH RECURSIVE retained"),646 statement.includes("WITH RECURSIVE retained"),
...@@ -779,7 +781,12 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {...@@ -779,7 +781,12 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
779 nativeItemRef: null,781 nativeItemRef: null,
780 runtimeRequestId: null,782 runtimeRequestId: null,
781 checkpointScopeId: null,783 checkpointScopeId: null,
782 startedAt: now,784 startedAt:
785 index === 998
786 ? null
787 : index === 999
788 ? DateTime.makeUnsafe("2020-01-01T00:00:00.000Z")
789 : now,
783 completedAt: index === -1 ? null : now,790 completedAt: index === -1 ? null : now,
784 },791 },
785 });792 });
...@@ -838,7 +845,32 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {...@@ -838,7 +845,32 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
838 resolvedAt: null,845 resolvedAt: null,
839 },846 },
840 });847 });
841 const snapshot = yield* projectionStore.getThreadSnapshotWindow(threadId, { rowLimit: 75 });848 const { statements, tracer } = traceSqlStatements();
849 const snapshot = yield* projectionStore
850 .getThreadSnapshotWindow(threadId, { rowLimit: 75 })
851 .pipe(Effect.withTracer(tracer));
852 const nodeStatement = statements.find(
853 (statement) =>
854 statement.includes("payload_json") &&
855 statement.includes("orchestration_v2_projection_nodes") &&
856 !statement.includes("WITH RECURSIVE"),
857 );
858 assert.isDefined(nodeStatement);
859 const nodePlan = yield* (yield* SqlClient.SqlClient).unsafe<{ readonly detail: string }>(
860 `EXPLAIN QUERY PLAN ${nodeStatement}`,
861 );
862 const nodeLookups = nodePlan
863 .map((row) => row.detail)
864 .filter((detail) => detail.startsWith("SEARCH "));
865 // Cost must follow the retained cohort, even when the thread has
866 // thousands of completed nodes outside the requested window.
867 assert.deepEqual(nodeLookups, [
868 "SEARCH node USING INDEX sqlite_autoindex_orchestration_v2_projection_nodes_1 (node_id=?)",
869 ]);
870 assert.notInclude(
871 nodePlan.map((row) => row.detail),
872 "USE TEMP B-TREE FOR ORDER BY",
873 );
842 assert.lengthOf(snapshot.projection.visibleTurnItems, 75);874 assert.lengthOf(snapshot.projection.visibleTurnItems, 75);
843 assert.lengthOf(snapshot.projection.nodes, 79);875 assert.lengthOf(snapshot.projection.nodes, 79);
844 const retained = new Set(snapshot.projection.nodes.map((node) => node.id));876 const retained = new Set(snapshot.projection.nodes.map((node) => node.id));
...@@ -862,6 +894,27 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {...@@ -862,6 +894,27 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
862 );894 );
863 const full = yield* projectionStore.getThreadSnapshot(threadId);895 const full = yield* projectionStore.getThreadSnapshot(threadId);
864 assert.lengthOf(full.projection.nodes, 1003);896 assert.lengthOf(full.projection.nodes, 1003);
897 // Bounded reads keep the full read's payloads and ordering, including
898 // null start times and equal timestamps.
899 assert.deepEqual(
900 snapshot.projection.nodes,
901 full.projection.nodes.filter((node) => retained.has(node.id)),
902 );
903 const sql = yield* SqlClient.SqlClient;
904 const duplicateItemId = TurnItemId.make("item:bounded-node-history:duplicate");
905 yield* sql`
906 INSERT INTO orchestration_v2_projection_turn_items
907 SELECT ${duplicateItemId}, thread_id, run_id, node_id, provider_thread_id,
908 provider_turn_id, parent_item_id, ordinal + 1, type, status, updated_at,
909 json_set(payload_json, '$.id', ${duplicateItemId}, '$.ordinal', ordinal + 1)
910 FROM orchestration_v2_projection_turn_items
911 WHERE turn_item_id = ${TurnItemId.make("item:bounded-node-history:999")}
912 `;
913 const sharedNodeSnapshot = yield* projectionStore.getThreadSnapshotWindow(threadId, {
914 rowLimit: 76,
915 });
916 assert.lengthOf(sharedNodeSnapshot.projection.visibleTurnItems, 76);
917 assert.deepEqual(sharedNodeSnapshot.projection.nodes, snapshot.projection.nodes);
865 }),918 }),
866 );919 );
867920
...@@ -1055,6 +1108,34 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {...@@ -1055,6 +1108,34 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
1055 Array.from({ length: 1_000 }, (_, index) => `turn-item:bounded-sql-history:${index + 1}`),1108 Array.from({ length: 1_000 }, (_, index) => `turn-item:bounded-sql-history:${index + 1}`),
1056 );1109 );
10571110
1111 // Physical insertion order must not change the ordinal/ID timeline order.
1112 const tiedIds = [
1113 TurnItemId.make("turn-item:bounded-sql-history:tie:b"),
1114 TurnItemId.make("turn-item:bounded-sql-history:tie:a"),
1115 ];
1116 for (const id of tiedIds) {
1117 yield* sql`
1118 INSERT INTO orchestration_v2_projection_turn_items (
1119 turn_item_id, thread_id, run_id, node_id, provider_thread_id, provider_turn_id,
1120 parent_item_id, ordinal, type, status, updated_at, payload_json
1121 )
1122 SELECT ${id}, thread_id, run_id, node_id, provider_thread_id, provider_turn_id,
1123 parent_item_id, 2000, type, status, updated_at,
1124 json_set(payload_json, '$.id', ${id}, '$.ordinal', 2000)
1125 FROM orchestration_v2_projection_turn_items
1126 WHERE turn_item_id = ${itemId}
1127 `;
1128 }
1129 const tied = yield* projectionStore.getThreadSnapshotWindow(threadId, { rowLimit: 3 });
1130 assert.deepEqual(
1131 tied.projection.turnItems.map((item) => item.id),
1132 [TurnItemId.make("turn-item:bounded-sql-history:1000"), tiedIds[1], tiedIds[0]],
1133 );
1134 yield* sql`
1135 DELETE FROM orchestration_v2_projection_turn_items
1136 WHERE turn_item_id IN ${sql.in(tiedIds)}
1137 `;
1138
1058 const hiddenRunId = RunId.make("run:bounded-sql-history:hidden-suffix");1139 const hiddenRunId = RunId.make("run:bounded-sql-history:hidden-suffix");
1059 yield* sql`1140 yield* sql`
1060 INSERT INTO orchestration_v2_projection_runs (1141 INSERT INTO orchestration_v2_projection_runs (
...@@ -4056,9 +4137,20 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {...@@ -4056,9 +4137,20 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
4056 `;4137 `;
4057 }4138 }
40584139
4059 const boundedFork = yield* projectionStore.getThreadSnapshotWindow(targetThreadId, {4140 const { statements, tracer } = traceSqlStatements();
4060 rowLimit: 2,4141 const boundedFork = yield* projectionStore
4061 });4142 .getThreadSnapshotWindow(targetThreadId, { rowLimit: 2 })
4143 .pipe(Effect.withTracer(tracer));
4144 // Only the target's control state is hydrated. Fork ancestors contribute
4145 // timeline rows, runs and attempts, regardless of their own live work.
4146 assert.lengthOf(
4147 statements.filter((statement) => statement.includes("WITH RECURSIVE retained")),
4148 1,
4149 );
4150 assert.lengthOf(
4151 statements.filter((statement) => statement.includes("SELECT node.payload_json")),
4152 1,
4153 );
4062 assert.lengthOf(boundedFork.projection.turnItems, 0);4154 assert.lengthOf(boundedFork.projection.turnItems, 0);
4063 assert.deepEqual(4155 assert.deepEqual(
4064 boundedFork.projection.visibleTurnItems.map((row) => [row.visibility, row.item.type]),4156 boundedFork.projection.visibleTurnItems.map((row) => [row.visibility, row.item.type]),
apps/server/src/orchestration-v2/ProjectionStore.ts+58-36
...@@ -66,7 +66,6 @@ import {...@@ -66,7 +66,6 @@ import {
66import {66import {
67 createOrchestrationV2TurnItemVisibility,67 createOrchestrationV2TurnItemVisibility,
68 isOrchestrationV2SupersededInterrupt,68 isOrchestrationV2SupersededInterrupt,
69 isOrchestrationV2TurnItemVisible,
70} from "@t3tools/shared/orchestrationV2Timeline";69} from "@t3tools/shared/orchestrationV2Timeline";
71import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";70import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
72import * as Context from "effect/Context";71import * as Context from "effect/Context";
...@@ -1182,22 +1181,18 @@ function sortMessagesByTurnItemOrder(...@@ -1182,22 +1181,18 @@ function sortMessagesByTurnItemOrder(
1182function activeLocalTurnItems(1181function activeLocalTurnItems(
1183 projection: OrchestrationV2ThreadProjection,1182 projection: OrchestrationV2ThreadProjection,
1184): Array<OrchestrationV2ProjectedTurnItem> {1183): Array<OrchestrationV2ProjectedTurnItem> {
1185 return projection.turnItems1184 const isVisible = createOrchestrationV2TurnItemVisibility({
1186 .filter((item) =>1185 runs: projection.runs,
1187 isOrchestrationV2TurnItemVisible({1186 attempts: projection.attempts,
1188 item,1187 items: projection.turnItems,
1189 runs: projection.runs,1188 });
1190 attempts: projection.attempts,1189 return projection.turnItems.filter(isVisible).map((item, position) => ({
1191 items: projection.turnItems,1190 position,
1192 }),1191 visibility: "local" as const,
1193 )1192 sourceThreadId: item.threadId,
1194 .map((item, position) => ({1193 sourceItemId: item.id,
1195 position,1194 item,
1196 visibility: "local" as const,1195 }));
1197 sourceThreadId: item.threadId,
1198 sourceItemId: item.id,
1199 item,
1200 }));
1201}1196}
12021197
1203function localVisibleTurnItems(1198function localVisibleTurnItems(
...@@ -2722,7 +2717,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =...@@ -2722,7 +2717,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
2722 `2717 `
2723 : yield* sql<PayloadRow>`2718 : yield* sql<PayloadRow>`
2724 WITH eligible AS NOT MATERIALIZED (2719 WITH eligible AS NOT MATERIALIZED (
2725 SELECT item.payload_json, item.ordinal, item.turn_item_id,2720 SELECT item.rowid AS item_rowid, item.payload_json, item.ordinal, item.turn_item_id,
2726 item.run_id, item.node_id, item.type2721 item.run_id, item.node_id, item.type
2727 FROM orchestration_v2_projection_turn_items AS item2722 FROM orchestration_v2_projection_turn_items AS item
2728 LEFT JOIN orchestration_v2_projection_runs AS run2723 LEFT JOIN orchestration_v2_projection_runs AS run
...@@ -2811,7 +2806,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =...@@ -2811,7 +2806,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
2811 -- Choose and sort rows by ID, then fetch payloads in that2806 -- Choose and sort rows by ID, then fetch payloads in that
2812 -- order. A turn window can hold megabytes of tool output, and2807 -- order. A turn window can hold megabytes of tool output, and
2813 -- carrying it through the union and sort cost about a second.2808 -- carrying it through the union and sort cost about a second.
2814 SELECT ordinal, turn_item_id, run_id, type2809 SELECT item_rowid, ordinal, turn_item_id, run_id, type
2815 FROM eligible2810 FROM eligible
2816 WHERE ordinal >= (SELECT ordinal FROM boundary)2811 WHERE ordinal >= (SELECT ordinal FROM boundary)
2817 ORDER BY ordinal DESC, turn_item_id DESC2812 ORDER BY ordinal DESC, turn_item_id DESC
...@@ -2821,9 +2816,9 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =...@@ -2821,9 +2816,9 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
2821 ELSE ${window.rowLimit}2816 ELSE ${window.rowLimit}
2822 END2817 END
2823 ), retained AS MATERIALIZED (2818 ), retained AS MATERIALIZED (
2824 SELECT ordinal, turn_item_id FROM selected2819 SELECT item_rowid, ordinal, turn_item_id FROM selected
2825 UNION2820 UNION
2826 SELECT request.ordinal, request.turn_item_id2821 SELECT request.rowid AS item_rowid, request.ordinal, request.turn_item_id
2827 FROM orchestration_v2_projection_turn_items AS request2822 FROM orchestration_v2_projection_turn_items AS request
2828 WHERE request.run_id IN (2823 WHERE request.run_id IN (
2829 SELECT run_id FROM selected2824 SELECT run_id FROM selected
...@@ -2831,9 +2826,9 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =...@@ -2831,9 +2826,9 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
2831 )2826 )
2832 AND request.type = 'run_interrupt_request'2827 AND request.type = 'run_interrupt_request'
2833 UNION2828 UNION
2834 SELECT latest.ordinal, latest.turn_item_id2829 SELECT latest.item_rowid, latest.ordinal, latest.turn_item_id
2835 FROM (2830 FROM (
2836 SELECT ordinal, turn_item_id2831 SELECT rowid AS item_rowid, ordinal, turn_item_id
2837 FROM orchestration_v2_projection_turn_items2832 FROM orchestration_v2_projection_turn_items
2838 WHERE thread_id = ${threadId}2833 WHERE thread_id = ${threadId}
2839 AND ${window.anchorItemId ?? null} IS NULL2834 AND ${window.anchorItemId ?? null} IS NULL
...@@ -2844,11 +2839,12 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =...@@ -2844,11 +2839,12 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
2844 ORDER BY ordinal ASC, turn_item_id ASC2839 ORDER BY ordinal ASC, turn_item_id ASC
2845 )2840 )
2846 -- CROSS JOIN keeps the sorted IDs as the outer loop, so SQLite2841 -- CROSS JOIN keeps the sorted IDs as the outer loop, so SQLite
2847 -- skips sorting again once the payloads are attached.2842 -- skips sorting again once the payloads are attached. Carry the
2843 -- rowid through selection to avoid a second string-key index lookup.
2848 SELECT item.payload_json2844 SELECT item.payload_json
2849 FROM retained2845 FROM retained
2850 CROSS JOIN orchestration_v2_projection_turn_items AS item2846 CROSS JOIN orchestration_v2_projection_turn_items AS item
2851 ON item.turn_item_id = retained.turn_item_id2847 ON item.rowid = retained.item_rowid
2852 ORDER BY retained.ordinal ASC, retained.turn_item_id ASC2848 ORDER BY retained.ordinal ASC, retained.turn_item_id ASC
2853 `;2849 `;
2854 // Reuse the decoded items for cohort IDs and the resulting projection.2850 // Reuse the decoded items for cohort IDs and the resulting projection.
...@@ -2865,7 +2861,11 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =...@@ -2865,7 +2861,11 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
2865 // A run can contain thousands of completed nodes. Load the visible2861 // A run can contain thousands of completed nodes. Load the visible
2866 // nodes and live control dependencies, then walk only their ancestors.2862 // nodes and live control dependencies, then walk only their ancestors.
2867 const cohortNodeIds =2863 const cohortNodeIds =
2868 window === undefined2864 window === undefined ||
2865 (fields !== undefined &&
2866 fields.every(
2867 (field) => field === "runs" || field === "attempts" || field === "turnItems",
2868 ))
2869 ? cohortJson("nodeId")2869 ? cohortJson("nodeId")
2870 : encodeIdList(2870 : encodeIdList(
2871 (yield* sql<{ readonly node_id: string }>`2871 (yield* sql<{ readonly node_id: string }>`
...@@ -2900,7 +2900,14 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =...@@ -2900,7 +2900,14 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
2900 INNER JOIN retained ON parent.node_id = retained.node_id2900 INNER JOIN retained ON parent.node_id = retained.node_id
2901 WHERE parent.thread_id = ${threadId} AND parent.parent_node_id IS NOT NULL2901 WHERE parent.thread_id = ${threadId} AND parent.parent_node_id IS NOT NULL
2902 )2902 )
2903 SELECT node_id FROM retained WHERE node_id IS NOT NULL2903 -- Sort IDs before fetching bodies, so large payloads never enter
2904 -- SQLite's temporary sort. The node read restores this order by key.
2905 SELECT retained.node_id
2906 FROM retained
2907 LEFT JOIN orchestration_v2_projection_nodes AS node
2908 ON node.node_id = retained.node_id AND node.thread_id = ${threadId}
2909 WHERE retained.node_id IS NOT NULL
2910 ORDER BY COALESCE(node.started_at, ''), retained.node_id ASC
2904 `).map((row) => row.node_id),2911 `).map((row) => row.node_id),
2905 );2912 );
2906 const cohortProviderThreadIds = cohortJson("providerThreadId");2913 const cohortProviderThreadIds = cohortJson("providerThreadId");
...@@ -2970,13 +2977,20 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =...@@ -2970,13 +2977,20 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
2970 FROM orchestration_v2_projection_nodes2977 FROM orchestration_v2_projection_nodes
2971 WHERE thread_id = ${threadId}2978 WHERE thread_id = ${threadId}
2972 ORDER BY COALESCE(started_at, ''), node_id ASC2979 ORDER BY COALESCE(started_at, ''), node_id ASC
2973 `2980 `
2974 : sql<PayloadRow>`2981 : sql<PayloadRow & { readonly position: number }>`
2975 SELECT payload_json FROM orchestration_v2_projection_nodes2982 -- Keep retained IDs outermost so SQLite looks up each node by its
2976 WHERE thread_id = ${threadId}2983 -- primary key instead of scanning every historical node in the thread.
2977 AND node_id IN (SELECT value FROM json_each(${cohortNodeIds}))2984 SELECT node.payload_json, retained.key AS position
2978 ORDER BY COALESCE(started_at, ''), node_id ASC2985 FROM json_each(${cohortNodeIds}) AS retained
2979 `,2986 CROSS JOIN orchestration_v2_projection_nodes AS node
2987 ON node.node_id = retained.value
2988 WHERE node.thread_id = ${threadId}
2989 `.pipe(
2990 Effect.map((rows) =>
2991 rows.toSorted((left, right) => left.position - right.position),
2992 ),
2993 ),
2980 fields !== undefined && !fields.includes("subagents")2994 fields !== undefined && !fields.includes("subagents")
2981 ? Effect.succeed([])2995 ? Effect.succeed([])
2982 : window === undefined2996 : window === undefined
...@@ -3257,7 +3271,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =...@@ -3257,7 +3271,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
3257 visibleTurnItems: [],3271 visibleTurnItems: [],
3258 updatedAt: thread.updatedAt,3272 updatedAt: thread.updatedAt,
3259 } satisfies OrchestrationV2ThreadProjection;3273 } satisfies OrchestrationV2ThreadProjection;
3260 return fields === undefined ? withLocalVisibleTurnItems(projection) : projection;3274 return projection;
3261 }).pipe(3275 }).pipe(
3262 Effect.mapError((cause) =>3276 Effect.mapError((cause) =>
3263 isProjectionStoreThreadNotFoundError(cause)3277 isProjectionStoreThreadNotFoundError(cause)
...@@ -3289,7 +3303,15 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =...@@ -3289,7 +3303,15 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
3289 (window?.historyAnchor !== undefined && window.historyAnchor.threadId !== threadId)3303 (window?.historyAnchor !== undefined && window.historyAnchor.threadId !== threadId)
3290 ? { ...window, rowLimit: 0, anchorItemId: undefined }3304 ? { ...window, rowLimit: 0, anchorItemId: undefined }
3291 : window;3305 : window;
3292 const projection = yield* readCanonicalProjection(threadId, localWindow);3306 // Ancestors contribute timeline items, not their provider/control state.
3307 // Keep their runs and attempts for fork boundaries and visibility.
3308 const projection = yield* readCanonicalProjection(
3309 threadId,
3310 localWindow,
3311 window !== undefined && seenThreadIds.size > 0
3312 ? ["runs", "attempts", "turnItems"]
3313 : undefined,
3314 );
3293 const forkedFrom = projection.thread.forkedFrom;3315 const forkedFrom = projection.thread.forkedFrom;
3294 if (forkedFrom?.type !== "run" || seenThreadIds.has(forkedFrom.threadId)) {3316 if (forkedFrom?.type !== "run" || seenThreadIds.has(forkedFrom.threadId)) {
3295 return withLocalVisibleTurnItems(projection);3317 return withLocalVisibleTurnItems(projection);
apps/server/src/orchestration-v2/WireProjection.test.ts+1
...@@ -142,6 +142,7 @@ describe("orchestration V2 wire projection", () => {...@@ -142,6 +142,7 @@ describe("orchestration V2 wire projection", () => {
142 deletedAt: null,142 deletedAt: null,
143 },143 },
144 runs: [],144 runs: [],
145 attempts: [],
145 runtimeRequests: [],146 runtimeRequests: [],
146 messages: [147 messages: [
147 {148 {
apps/server/src/orchestration-v2/threadHistoryPaging.test.ts+2-1
...@@ -634,7 +634,7 @@ describe("threadHistoryPaging", () => {...@@ -634,7 +634,7 @@ describe("threadHistoryPaging", () => {
634 });634 });
635635
636 it("preserves oversized actionable state and reports the budget exception", () => {636 it("preserves oversized actionable state and reports the budget exception", () => {
637 const projection = makeProjection([]);637 const projection = makeProjection([makeRow(0)]);
638 const actionable = {638 const actionable = {
639 ...projection,639 ...projection,
640 plans: [640 plans: [
...@@ -654,6 +654,7 @@ describe("threadHistoryPaging", () => {...@@ -654,6 +654,7 @@ describe("threadHistoryPaging", () => {
654654
655 expect(bounded.payloadBudgetExceeded).toBe(true);655 expect(bounded.payloadBudgetExceeded).toBe(true);
656 expect(bounded.projection.plans[0]).toEqual(actionable.plans[0]);656 expect(bounded.projection.plans[0]).toEqual(actionable.plans[0]);
657 expect(bounded.projection.visibleTurnItems).toEqual(actionable.visibleTurnItems);
657 });658 });
658659
659 it("keeps paged historical plan detail in the turn item and only status in its artifact", () => {660 it("keeps paged historical plan detail in the turn item and only status in its artifact", () => {
apps/server/src/orchestration-v2/threadHistoryPaging.ts+4-1
...@@ -567,6 +567,9 @@ export function buildBoundedThreadProjection(input: {...@@ -567,6 +567,9 @@ export function buildBoundedThreadProjection(input: {
567 // Always from the full projection so inherited-only windows still carry a567 // Always from the full projection so inherited-only windows still carry a
568 // watermark for partial live reducers.568 // watermark for partial live reducers.
569 latestLocalTurnOrdinal,569 latestLocalTurnOrdinal,
570 payloadBudgetExceeded: bytesOfJson(projection) > policy.maxEncodedBytes,570 // Adding timeline rows and historical summaries cannot shrink control state.
571 // Avoid serializing the whole timeline when control state already exceeds the cap.
572 payloadBudgetExceeded:
573 controlBytes > policy.maxEncodedBytes || bytesOfJson(projection) > policy.maxEncodedBytes,
571 };574 };
572}575}