| author | |
| committer | |
| log | 42c6623d492850c747b7ba06c1d0f1e1559d0ad5 |
| tree | 8f159eb26f27c3e872287b9adb9de780016e9fdb |
| parent | b707eeb052782cfd8b1ff0445f84b2aaf01da38b |
| signature | Signed by PGP key B5690EEEBB952194 |
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"); |
| 54 | const providerInstanceId = modelSelection.instanceId; | 54 | const providerInstanceId = modelSelection.instanceId; |
| 55 | const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | 55 | const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| 56 | 56 | ||
| 57 | const 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 | |||
| 57 | const addRolledBackRecoveryCandidate = Effect.fn("addRolledBackRecoveryCandidate")(function* ( | 74 | const 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 | ); |
| 867 | 920 | ||
| ... | @@ -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 | ); |
| 1057 | 1110 | ||
| 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 | } |
| 4058 | 4139 | ||
| 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 { |
| 66 | import { | 66 | import { |
| 67 | createOrchestrationV2TurnItemVisibility, | 67 | createOrchestrationV2TurnItemVisibility, |
| 68 | isOrchestrationV2SupersededInterrupt, | 68 | isOrchestrationV2SupersededInterrupt, |
| 69 | isOrchestrationV2TurnItemVisible, | ||
| 70 | } from "@t3tools/shared/orchestrationV2Timeline"; | 69 | } from "@t3tools/shared/orchestrationV2Timeline"; |
| 71 | import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; | 70 | import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; |
| 72 | import * as Context from "effect/Context"; | 71 | import * as Context from "effect/Context"; |
| ... | @@ -1182,22 +1181,18 @@ function sortMessagesByTurnItemOrder( | ... | @@ -1182,22 +1181,18 @@ function sortMessagesByTurnItemOrder( |
| 1182 | function activeLocalTurnItems( | 1181 | function activeLocalTurnItems( |
| 1183 | projection: OrchestrationV2ThreadProjection, | 1182 | projection: OrchestrationV2ThreadProjection, |
| 1184 | ): Array<OrchestrationV2ProjectedTurnItem> { | 1183 | ): Array<OrchestrationV2ProjectedTurnItem> { |
| 1185 | return projection.turnItems | 1184 | 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 | } |
| 1202 | 1197 | ||
| 1203 | function localVisibleTurnItems( | 1198 | function 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.type | 2721 | item.run_id, item.node_id, item.type |
| 2727 | FROM orchestration_v2_projection_turn_items AS item | 2722 | FROM orchestration_v2_projection_turn_items AS item |
| 2728 | LEFT JOIN orchestration_v2_projection_runs AS run | 2723 | 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 that | 2806 | -- Choose and sort rows by ID, then fetch payloads in that |
| 2812 | -- order. A turn window can hold megabytes of tool output, and | 2807 | -- 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, type | 2809 | SELECT item_rowid, ordinal, turn_item_id, run_id, type |
| 2815 | FROM eligible | 2810 | FROM eligible |
| 2816 | WHERE ordinal >= (SELECT ordinal FROM boundary) | 2811 | WHERE ordinal >= (SELECT ordinal FROM boundary) |
| 2817 | ORDER BY ordinal DESC, turn_item_id DESC | 2812 | 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 | END | 2817 | END |
| 2823 | ), retained AS MATERIALIZED ( | 2818 | ), retained AS MATERIALIZED ( |
| 2824 | SELECT ordinal, turn_item_id FROM selected | 2819 | SELECT item_rowid, ordinal, turn_item_id FROM selected |
| 2825 | UNION | 2820 | UNION |
| 2826 | SELECT request.ordinal, request.turn_item_id | 2821 | SELECT request.rowid AS item_rowid, request.ordinal, request.turn_item_id |
| 2827 | FROM orchestration_v2_projection_turn_items AS request | 2822 | 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 selected | 2824 | 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 | UNION | 2828 | UNION |
| 2834 | SELECT latest.ordinal, latest.turn_item_id | 2829 | SELECT latest.item_rowid, latest.ordinal, latest.turn_item_id |
| 2835 | FROM ( | 2830 | FROM ( |
| 2836 | SELECT ordinal, turn_item_id | 2831 | SELECT rowid AS item_rowid, ordinal, turn_item_id |
| 2837 | FROM orchestration_v2_projection_turn_items | 2832 | FROM orchestration_v2_projection_turn_items |
| 2838 | WHERE thread_id = ${threadId} | 2833 | WHERE thread_id = ${threadId} |
| 2839 | AND ${window.anchorItemId ?? null} IS NULL | 2834 | 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 ASC | 2839 | ORDER BY ordinal ASC, turn_item_id ASC |
| 2845 | ) | 2840 | ) |
| 2846 | -- CROSS JOIN keeps the sorted IDs as the outer loop, so SQLite | 2841 | -- 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_json | 2844 | SELECT item.payload_json |
| 2849 | FROM retained | 2845 | FROM retained |
| 2850 | CROSS JOIN orchestration_v2_projection_turn_items AS item | 2846 | CROSS JOIN orchestration_v2_projection_turn_items AS item |
| 2851 | ON item.turn_item_id = retained.turn_item_id | 2847 | ON item.rowid = retained.item_rowid |
| 2852 | ORDER BY retained.ordinal ASC, retained.turn_item_id ASC | 2848 | 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 visible | 2861 | // 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 === undefined | 2864 | 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_id | 2900 | INNER JOIN retained ON parent.node_id = retained.node_id |
| 2901 | WHERE parent.thread_id = ${threadId} AND parent.parent_node_id IS NOT NULL | 2901 | 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 NULL | 2903 | -- 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_nodes | 2977 | FROM orchestration_v2_projection_nodes |
| 2971 | WHERE thread_id = ${threadId} | 2978 | WHERE thread_id = ${threadId} |
| 2972 | ORDER BY COALESCE(started_at, ''), node_id ASC | 2979 | 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_nodes | 2982 | -- 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 ASC | 2985 | 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 === undefined | 2996 | : 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 | }); |
| 635 | 635 | ||
| 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", () => { |
| 654 | 654 | ||
| 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 | }); |
| 658 | 659 | ||
| 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 a | 567 | // 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 | } |