authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-31 20:36:53-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-31 20:36:53-07:00
logb17cc3d1bf0f4a5deee6dd6a470e36970b864a9b
treee5bcbcae83c0a536d8a0aea5a89efe217196c34b
parent42a8fd5103b4af57b16be721a9864ca3a5c18c5b
signature Signed by PGP key B5690EEEBB952194

perf(server): reduce frequency of full tool call output being loaded into memory from db (#8988)


10 files changed, 363 insertions(+), 98 deletions(-)

apps/server/src/orchestration/Layers/CheckpointReactor.ts+1-1
...@@ -164,7 +164,7 @@ const make = Effect.gen(function* () {...@@ -164,7 +164,7 @@ const make = Effect.gen(function* () {
164164
165 const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* (threadId: ThreadId) {165 const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* (threadId: ThreadId) {
166 return yield* projectionSnapshotQuery166 return yield* projectionSnapshotQuery
167 .getThreadDetailById(threadId)167 .getThreadDetailById(threadId, { activityKinds: [] })
168 .pipe(Effect.map(Option.getOrUndefined));168 .pipe(Effect.map(Option.getOrUndefined));
169 });169 });
170170
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+114-56
...@@ -2235,7 +2235,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {...@@ -2235,7 +2235,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
2235 }),2235 }),
2236 );2236 );
22372237
2238 it.effect("clears stale pending user input from projected shell summaries", () =>2238 it.effect("reads only user-input activities when refreshing shell summaries", () =>
2239 Effect.gen(function* () {2239 Effect.gen(function* () {
2240 const projectionPipeline = yield* OrchestrationProjectionPipeline;2240 const projectionPipeline = yield* OrchestrationProjectionPipeline;
2241 const eventStore = yield* OrchestrationEventStore;2241 const eventStore = yield* OrchestrationEventStore;
...@@ -2293,70 +2293,128 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {...@@ -2293,70 +2293,128 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
2293 },2293 },
2294 });2294 });
22952295
2296 // Invalid JSON proves the summary query filters tool rows before decoding payloads.
2297 yield* sql`
2298 INSERT INTO projection_thread_activities (
2299 activity_id,
2300 thread_id,
2301 turn_id,
2302 tone,
2303 kind,
2304 summary,
2305 payload_json,
2306 sequence,
2307 created_at
2308 )
2309 VALUES
2310 (
2311 'activity-malformed-tool-output',
2312 'thread-stale-user-input',
2313 NULL,
2314 'info',
2315 'tool.completed',
2316 'Tool completed',
2317 '{not-json',
2318 NULL,
2319 '2026-02-26T12:35:02.000Z'
2320 ),
2321 (
2322 'activity-user-input-resolved-requested',
2323 'thread-stale-user-input',
2324 NULL,
2325 'info',
2326 'user-input.requested',
2327 'User input requested',
2328 json_object('requestId', 'user-input-resolved'),
2329 NULL,
2330 '2026-02-26T12:35:03.000Z'
2331 ),
2332 (
2333 'activity-user-input-resolved',
2334 'thread-stale-user-input',
2335 NULL,
2336 'info',
2337 'user-input.resolved',
2338 'User input resolved',
2339 json_object('requestId', 'user-input-resolved'),
2340 NULL,
2341 '2026-02-26T12:35:04.000Z'
2342 ),
2343 (
2344 'activity-user-input-stale-requested',
2345 'thread-stale-user-input',
2346 NULL,
2347 'info',
2348 'user-input.requested',
2349 'User input requested',
2350 json_object('requestId', 'user-input-stale'),
2351 NULL,
2352 '2026-02-26T12:35:05.000Z'
2353 ),
2354 (
2355 'activity-user-input-stale-failed',
2356 'thread-stale-user-input',
2357 NULL,
2358 'error',
2359 'provider.user-input.respond.failed',
2360 'Provider user input response failed',
2361 json_object(
2362 'requestId',
2363 'user-input-stale',
2364 'detail',
2365 'Unknown pending Codex user input request: user-input-stale'
2366 ),
2367 NULL,
2368 '2026-02-26T12:35:06.000Z'
2369 ),
2370 (
2371 'activity-user-input-active-requested',
2372 'thread-stale-user-input',
2373 NULL,
2374 'info',
2375 'user-input.requested',
2376 'User input requested',
2377 json_object('requestId', 'user-input-active'),
2378 NULL,
2379 '2026-02-26T12:35:07.000Z'
2380 ),
2381 (
2382 'activity-user-input-active-failed',
2383 'thread-stale-user-input',
2384 NULL,
2385 'error',
2386 'provider.user-input.respond.failed',
2387 'Provider user input response failed',
2388 json_object(
2389 'requestId',
2390 'user-input-active',
2391 'detail',
2392 'Provider is temporarily unavailable'
2393 ),
2394 NULL,
2395 '2026-02-26T12:35:08.000Z'
2396 )
2397 `;
2398
2296 yield* appendAndProject({2399 yield* appendAndProject({
2297 type: "thread.activity-appended",2400 type: "thread.message-sent",
2298 eventId: EventId.make("evt-stale-user-input-3"),2401 eventId: EventId.make("evt-stale-user-input-3"),
2299 aggregateKind: "thread",2402 aggregateKind: "thread",
2300 aggregateId: ThreadId.make("thread-stale-user-input"),2403 aggregateId: ThreadId.make("thread-stale-user-input"),
2301 occurredAt: "2026-02-26T12:35:02.000Z",2404 occurredAt: "2026-02-26T12:35:09.000Z",
2302 commandId: CommandId.make("cmd-stale-user-input-3"),2405 commandId: CommandId.make("cmd-stale-user-input-3"),
2303 causationEventId: null,2406 causationEventId: null,
2304 correlationId: CorrelationId.make("cmd-stale-user-input-3"),2407 correlationId: CorrelationId.make("cmd-stale-user-input-3"),
2305 metadata: {},2408 metadata: {},
2306 payload: {2409 payload: {
2307 threadId: ThreadId.make("thread-stale-user-input"),2410 threadId: ThreadId.make("thread-stale-user-input"),
2308 activity: {2411 messageId: MessageId.make("message-stale-user-input"),
2309 id: EventId.make("activity-stale-user-input-requested"),2412 role: "user",
2310 tone: "info",2413 text: "Continue",
2311 kind: "user-input.requested",2414 turnId: null,
2312 summary: "User input requested",2415 streaming: false,
2313 payload: {2416 createdAt: "2026-02-26T12:35:09.000Z",
2314 requestId: "user-input-request-stale-1",2417 updatedAt: "2026-02-26T12:35:09.000Z",
2315 questions: [
2316 {
2317 id: "sandbox_mode",
2318 header: "Sandbox",
2319 question: "Which mode should be used?",
2320 options: [
2321 {
2322 label: "workspace-write",
2323 description: "Allow workspace writes only",
2324 },
2325 ],
2326 },
2327 ],
2328 },
2329 turnId: null,
2330 createdAt: "2026-02-26T12:35:02.000Z",
2331 },
2332 },
2333 });
2334
2335 yield* appendAndProject({
2336 type: "thread.activity-appended",
2337 eventId: EventId.make("evt-stale-user-input-4"),
2338 aggregateKind: "thread",
2339 aggregateId: ThreadId.make("thread-stale-user-input"),
2340 occurredAt: "2026-02-26T12:35:03.000Z",
2341 commandId: CommandId.make("cmd-stale-user-input-4"),
2342 causationEventId: null,
2343 correlationId: CorrelationId.make("cmd-stale-user-input-4"),
2344 metadata: {},
2345 payload: {
2346 threadId: ThreadId.make("thread-stale-user-input"),
2347 activity: {
2348 id: EventId.make("activity-stale-user-input-failed"),
2349 tone: "error",
2350 kind: "provider.user-input.respond.failed",
2351 summary: "Provider user input response failed",
2352 payload: {
2353 requestId: "user-input-request-stale-1",
2354 detail:
2355 "Provider adapter request failed (codex) for item/tool/requestUserInput: Unknown pending Codex user input request: user-input-request-stale-1",
2356 },
2357 turnId: null,
2358 createdAt: "2026-02-26T12:35:03.000Z",
2359 },
2360 },2418 },
2361 });2419 });
23622420
...@@ -2367,7 +2425,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {...@@ -2367,7 +2425,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
2367 FROM projection_threads2425 FROM projection_threads
2368 WHERE thread_id = 'thread-stale-user-input'2426 WHERE thread_id = 'thread-stale-user-input'
2369 `;2427 `;
2370 assert.deepEqual(threadRows, [{ pendingUserInputCount: 0 }]);2428 assert.deepEqual(threadRows, [{ pendingUserInputCount: 1 }]);
2371 }),2429 }),
2372 );2430 );
23732431
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+2-2
...@@ -130,7 +130,7 @@ function isStalePendingApprovalFailureDetail(detail: string | null): boolean {...@@ -130,7 +130,7 @@ function isStalePendingApprovalFailureDetail(detail: string | null): boolean {
130 );130 );
131}131}
132132
133// A full refresh loads all thread history, so skip events that cannot change the summary.133// A refresh reads each persisted summary source, so skip events that cannot change the result.
134function shouldRefreshThreadShellSummary(event: OrchestrationEvent): boolean {134function shouldRefreshThreadShellSummary(event: OrchestrationEvent): boolean {
135 if (event.type === "thread.message-sent") {135 if (event.type === "thread.message-sent") {
136 return event.payload.role === "user";136 return event.payload.role === "user";
...@@ -588,7 +588,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -588,7 +588,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
588 const [messages, proposedPlans, activities, pendingApprovals] = yield* Effect.all([588 const [messages, proposedPlans, activities, pendingApprovals] = yield* Effect.all([
589 projectionThreadMessageRepository.listByThreadId({ threadId }),589 projectionThreadMessageRepository.listByThreadId({ threadId }),
590 projectionThreadProposedPlanRepository.listByThreadId({ threadId }),590 projectionThreadProposedPlanRepository.listByThreadId({ threadId }),
591 projectionThreadActivityRepository.listByThreadId({ threadId }),591 projectionThreadActivityRepository.listUserInputLifecycleByThreadId({ threadId }),
592 projectionPendingApprovalRepository.listByThreadId({ threadId }),592 projectionPendingApprovalRepository.listByThreadId({ threadId }),
593 ]);593 ]);
594594
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+71
...@@ -484,6 +484,77 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -484,6 +484,77 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
484 if (threadDetail._tag === "Some") {484 if (threadDetail._tag === "Some") {
485 assert.deepEqual(threadDetail.value, snapshot.threads[0]);485 assert.deepEqual(threadDetail.value, snapshot.threads[0]);
486 }486 }
487
488 yield* sql`
489 INSERT INTO projection_thread_activities (
490 activity_id,
491 thread_id,
492 turn_id,
493 tone,
494 kind,
495 summary,
496 payload_json,
497 created_at
498 )
499 VALUES
500 (
501 'activity-task-started',
502 'thread-1',
503 'turn-1',
504 'info',
505 'task.started',
506 'Ship the query filter',
507 '{"taskId":"task-1","detail":"Ship the query filter"}',
508 '2026-02-24T00:00:06.100Z'
509 ),
510 (
511 'activity-malformed-tool',
512 'thread-1',
513 'turn-1',
514 'info',
515 'tool.completed',
516 'Malformed tool output',
517 'not-json',
518 '2026-02-24T00:00:06.200Z'
519 )
520 `;
521
522 const detailWithoutActivities = yield* snapshotQuery.getThreadDetailById(
523 ThreadId.make("thread-1"),
524 { activityKinds: [] },
525 );
526 assert.equal(detailWithoutActivities._tag, "Some");
527 if (detailWithoutActivities._tag === "Some") {
528 assert.deepEqual(detailWithoutActivities.value.activities, []);
529 assert.deepEqual(detailWithoutActivities.value.messages, snapshot.threads[0]?.messages);
530 assert.deepEqual(
531 detailWithoutActivities.value.proposedPlans,
532 snapshot.threads[0]?.proposedPlans,
533 );
534 assert.deepEqual(
535 detailWithoutActivities.value.checkpoints,
536 snapshot.threads[0]?.checkpoints,
537 );
538 }
539
540 const detailWithTaskActivities = yield* snapshotQuery.getThreadDetailById(
541 ThreadId.make("thread-1"),
542 { activityKinds: ["task.started", "task.progress"] },
543 );
544 assert.equal(detailWithTaskActivities._tag, "Some");
545 if (detailWithTaskActivities._tag === "Some") {
546 assert.deepEqual(detailWithTaskActivities.value.activities, [
547 {
548 id: asEventId("activity-task-started"),
549 tone: "info",
550 kind: "task.started",
551 summary: "Ship the query filter",
552 payload: { taskId: "task-1", detail: "Ship the query filter" },
553 turnId: asTurnId("turn-1"),
554 createdAt: "2026-02-24T00:00:06.100Z",
555 },
556 ]);
557 }
487 }),558 }),
488 );559 );
489560
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+89-22
...@@ -64,6 +64,7 @@ import {...@@ -64,6 +64,7 @@ import {
64 type ProjectionFullThreadDiffContext,64 type ProjectionFullThreadDiffContext,
65 type ProjectionSnapshotCounts,65 type ProjectionSnapshotCounts,
66 type ProjectionThreadCheckpointContext,66 type ProjectionThreadCheckpointContext,
67 type ProjectionThreadDetailQuery,
67 type ProjectionSnapshotQueryShape,68 type ProjectionSnapshotQueryShape,
68} from "../Services/ProjectionSnapshotQuery.ts";69} from "../Services/ProjectionSnapshotQuery.ts";
6970
...@@ -141,6 +142,10 @@ const ProjectIdLookupInput = Schema.Struct({...@@ -141,6 +142,10 @@ const ProjectIdLookupInput = Schema.Struct({
141const ThreadIdLookupInput = Schema.Struct({142const ThreadIdLookupInput = Schema.Struct({
142 threadId: ThreadId,143 threadId: ThreadId,
143});144});
145const ThreadActivityKindsLookupInput = Schema.Struct({
146 threadId: ThreadId,
147 activityKinds: Schema.Array(Schema.String),
148});
144// Windowed reads order turns by the stable keyset (anchor, turn key), where149// Windowed reads order turns by the stable keyset (anchor, turn key), where
145// anchor is requested_at and turn key is150// anchor is requested_at and turn key is
146// COALESCE(turn_id, ''). Both are event-derived, so cursors survive the151// COALESCE(turn_id, ''). Both are event-derived, so cursors survive the
...@@ -1055,6 +1060,48 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1055,6 +1060,48 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1055 `,1060 `,
1056 });1061 });
10571062
1063 const listThreadActivityRowsByThreadAndKinds = SqlSchema.findAll({
1064 Request: ThreadActivityKindsLookupInput,
1065 Result: ProjectionThreadActivityDbRowSchema,
1066 execute: ({ threadId, activityKinds }) =>
1067 sql`
1068 SELECT
1069 activity_id AS "activityId",
1070 thread_id AS "threadId",
1071 turn_id AS "turnId",
1072 tone,
1073 kind,
1074 summary,
1075 payload_json AS "payload",
1076 sequence,
1077 created_at AS "createdAt"
1078 FROM (
1079 SELECT
1080 activity_id,
1081 thread_id,
1082 turn_id,
1083 tone,
1084 kind,
1085 summary,
1086 payload_json,
1087 sequence,
1088 created_at
1089 FROM projection_thread_activities
1090 WHERE thread_id = ${threadId}
1091 AND ${sql.in("kind", activityKinds)}
1092 ORDER BY
1093 sequence DESC,
1094 created_at DESC,
1095 activity_id DESC
1096 LIMIT ${THREAD_DETAIL_ACTIVITY_LIMIT}
1097 ) AS recent_activities
1098 ORDER BY
1099 sequence ASC,
1100 created_at ASC,
1101 activity_id ASC
1102 `,
1103 });
1104
1058 const getThreadSessionRowByThread = SqlSchema.findOneOption({1105 const getThreadSessionRowByThread = SqlSchema.findOneOption({
1059 Request: ThreadIdLookupInput,1106 Request: ThreadIdLookupInput,
1060 Result: ProjectionThreadSessionDbRowSchema,1107 Result: ProjectionThreadSessionDbRowSchema,
...@@ -2524,8 +2571,43 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -2524,8 +2571,43 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
2524 readonly beforeTurnKey: string;2571 readonly beforeTurnKey: string;
2525 }2572 }
25262573
2527 const getThreadDetailByIdBounded = (threadId: ThreadId, bounds: ThreadDetailBounds | undefined) =>2574 const getThreadDetailByIdBounded = (
2575 threadId: ThreadId,
2576 bounds: ThreadDetailBounds | undefined,
2577 query: ProjectionThreadDetailQuery | undefined = undefined,
2578 ) =>
2528 Effect.gen(function* () {2579 Effect.gen(function* () {
2580 const activityRowsEffect = (
2581 query?.activityKinds === undefined
2582 ? bounds === undefined
2583 ? listThreadActivityRowsByThread({ threadId })
2584 : listThreadActivityRowsByThreadWindow({ threadId, ...bounds })
2585 : query.activityKinds.length === 0
2586 ? Effect.succeed([])
2587 : listThreadActivityRowsByThreadAndKinds({
2588 threadId,
2589 activityKinds: query.activityKinds,
2590 })
2591 ).pipe(
2592 Effect.mapError(
2593 toPersistenceSqlOrDecodeError(
2594 "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query",
2595 "ProjectionSnapshotQuery.getThreadDetailById:listActivities:decodeRows",
2596 ),
2597 ),
2598 );
2599 const pinnedActivityRowsEffect =
2600 query?.activityKinds === undefined
2601 ? listPinnedThreadActivityRowsByThread({ threadId }).pipe(
2602 Effect.mapError(
2603 toPersistenceSqlOrDecodeError(
2604 "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:query",
2605 "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:decodeRows",
2606 ),
2607 ),
2608 )
2609 : Effect.succeed([]);
2610
2529 const [2611 const [
2530 threadRow,2612 threadRow,
2531 messageRows,2613 messageRows,
...@@ -2563,25 +2645,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -2563,25 +2645,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
2563 ),2645 ),
2564 ),2646 ),
2565 ),2647 ),
2566 (bounds === undefined2648 activityRowsEffect,
2567 ? listThreadActivityRowsByThread({ threadId })2649 pinnedActivityRowsEffect,
2568 : listThreadActivityRowsByThreadWindow({ threadId, ...bounds })
2569 ).pipe(
2570 Effect.mapError(
2571 toPersistenceSqlOrDecodeError(
2572 "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query",
2573 "ProjectionSnapshotQuery.getThreadDetailById:listActivities:decodeRows",
2574 ),
2575 ),
2576 ),
2577 listPinnedThreadActivityRowsByThread({ threadId }).pipe(
2578 Effect.mapError(
2579 toPersistenceSqlOrDecodeError(
2580 "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:query",
2581 "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:decodeRows",
2582 ),
2583 ),
2584 ),
2585 listCheckpointRowsByThread({ threadId }).pipe(2650 listCheckpointRowsByThread({ threadId }).pipe(
2586 Effect.mapError(2651 Effect.mapError(
2587 toPersistenceSqlOrDecodeError(2652 toPersistenceSqlOrDecodeError(
...@@ -2700,8 +2765,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -2700,8 +2765,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
2700 );2765 );
2701 });2766 });
27022767
2703 const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = (threadId) =>2768 const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = (
2704 getThreadDetailByIdBounded(threadId, undefined);2769 threadId,
2770 query,
2771 ) => getThreadDetailByIdBounded(threadId, undefined, query);
27052772
2706 // Bounds pathological fan-out: one user turn that spawned hundreds of2773 // Bounds pathological fan-out: one user turn that spawned hundreds of
2707 // subagent turns still pages in bounded chunks, at the cost of splitting the2774 // subagent turns still pages in bounded chunks, at the cost of splitting the
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+1-1
...@@ -481,7 +481,7 @@ const make = Effect.gen(function* () {...@@ -481,7 +481,7 @@ const make = Effect.gen(function* () {
481481
482 const resolveThread = Effect.fnUntraced(function* (threadId: ThreadId) {482 const resolveThread = Effect.fnUntraced(function* (threadId: ThreadId) {
483 return yield* projectionSnapshotQuery483 return yield* projectionSnapshotQuery
484 .getThreadDetailById(threadId)484 .getThreadDetailById(threadId, { activityKinds: [] })
485 .pipe(Effect.map(Option.getOrUndefined));485 .pipe(Effect.map(Option.getOrUndefined));
486 });486 });
487487
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+7-3
...@@ -48,6 +48,7 @@ import { canReplaceThreadTitle } from "../threadTitles.ts";...@@ -48,6 +48,7 @@ import { canReplaceThreadTitle } from "../threadTitles.ts";
4848
49const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`;49const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`;
50const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${taskId}`;50const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${taskId}`;
51const TASK_TITLE_ACTIVITY_KINDS = ["task.started", "task.progress"] as const;
5152
52// Fallback when the in-memory description cache no longer has the task name53// Fallback when the in-memory description cache no longer has the task name
53// (server restart, session-exit sweep, TTL/capacity eviction): earlier54// (server restart, session-exit sweep, TTL/capacity eviction): earlier
...@@ -949,9 +950,12 @@ const make = Effect.gen(function* () {...@@ -949,9 +950,12 @@ const make = Effect.gen(function* () {
949 ),950 ),
950 );951 );
951952
952 const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* (threadId: ThreadId) {953 const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* (
954 threadId: ThreadId,
955 activityKinds: ReadonlyArray<string> = [],
956 ) {
953 return yield* projectionSnapshotQuery957 return yield* projectionSnapshotQuery
954 .getThreadDetailById(threadId)958 .getThreadDetailById(threadId, { activityKinds })
955 .pipe(Effect.map(Option.getOrUndefined));959 .pipe(Effect.map(Option.getOrUndefined));
956 });960 });
957961
...@@ -2022,7 +2026,7 @@ const make = Effect.gen(function* () {...@@ -2022,7 +2026,7 @@ const make = Effect.gen(function* () {
2022 if (event.type === "task.completed") {2026 if (event.type === "task.completed") {
2023 taskTitle = yield* lookupTaskDescription(thread.id, event.payload.taskId);2027 taskTitle = yield* lookupTaskDescription(thread.id, event.payload.taskId);
2024 if (!taskTitle) {2028 if (!taskTitle) {
2025 const threadDetail = yield* getLoadedThreadDetail();2029 const threadDetail = yield* resolveThreadDetail(thread.id, TASK_TITLE_ACTIVITY_KINDS);
2026 taskTitle = findTaskTitleInActivities(threadDetail?.activities, event.payload.taskId);2030 taskTitle = findTaskTitleInActivities(threadDetail?.activities, event.payload.taskId);
2027 }2031 }
2028 }2032 }
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+10
...@@ -54,6 +54,15 @@ export interface ProjectionFullThreadDiffContext {...@@ -54,6 +54,15 @@ export interface ProjectionFullThreadDiffContext {
54 readonly toCheckpointRef: CheckpointRef | null;54 readonly toCheckpointRef: CheckpointRef | null;
55}55}
5656
57export interface ProjectionThreadDetailQuery {
58 /**
59 * Limit activities before SQLite returns and decodes their payloads.
60 * Any explicit filter omits pinned-request reads. An empty list also skips
61 * the activity query. Omit this option to preserve the full detail response.
62 */
63 readonly activityKinds?: ReadonlyArray<string>;
64}
65
57/**66/**
58 * ProjectionSnapshotQueryShape - Service API for read-model snapshots.67 * ProjectionSnapshotQueryShape - Service API for read-model snapshots.
59 */68 */
...@@ -168,6 +177,7 @@ export interface ProjectionSnapshotQueryShape {...@@ -168,6 +177,7 @@ export interface ProjectionSnapshotQueryShape {
168 */177 */
169 readonly getThreadDetailById: (178 readonly getThreadDetailById: (
170 threadId: ThreadId,179 threadId: ThreadId,
180 query?: ProjectionThreadDetailQuery,
171 ) => Effect.Effect<Option.Option<OrchestrationThread>, ProjectionRepositoryError>;181 ) => Effect.Effect<Option.Option<OrchestrationThread>, ProjectionRepositoryError>;
172182
173 /**183 /**
apps/server/src/persistence/Layers/ProjectionThreadActivities.ts+59-13
...@@ -23,6 +23,21 @@ const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(...@@ -23,6 +23,21 @@ const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(
23 }),23 }),
24);24);
2525
26const mapActivityRows = (
27 rows: ReadonlyArray<Schema.Schema.Type<typeof ProjectionThreadActivityDbRowSchema>>,
28): ReadonlyArray<ProjectionThreadActivity> =>
29 rows.map((row) => ({
30 activityId: row.activityId,
31 threadId: row.threadId,
32 turnId: row.turnId,
33 tone: row.tone,
34 kind: row.kind,
35 summary: row.summary,
36 payload: row.payload,
37 ...(row.sequence !== null ? { sequence: row.sequence } : {}),
38 createdAt: row.createdAt,
39 }));
40
26function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {41function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
27 return (cause: unknown) =>42 return (cause: unknown) =>
28 Schema.isSchemaError(cause)43 Schema.isSchemaError(cause)
...@@ -97,6 +112,36 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () {...@@ -97,6 +112,36 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () {
97 `,112 `,
98 });113 });
99114
115 const listUserInputLifecycleActivityRows = SqlSchema.findAll({
116 Request: ListProjectionThreadActivitiesInput,
117 Result: ProjectionThreadActivityDbRowSchema,
118 execute: ({ threadId }) =>
119 sql`
120 SELECT
121 activity_id AS "activityId",
122 thread_id AS "threadId",
123 turn_id AS "turnId",
124 tone,
125 kind,
126 summary,
127 payload_json AS "payload",
128 sequence,
129 created_at AS "createdAt"
130 FROM projection_thread_activities
131 WHERE thread_id = ${threadId}
132 AND kind IN (
133 'user-input.requested',
134 'user-input.resolved',
135 'provider.user-input.respond.failed'
136 )
137 ORDER BY
138 CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC,
139 sequence ASC,
140 created_at ASC,
141 activity_id ASC
142 `,
143 });
144
100 const deleteProjectionThreadActivityRows = SqlSchema.void({145 const deleteProjectionThreadActivityRows = SqlSchema.void({
101 Request: DeleteProjectionThreadActivitiesInput,146 Request: DeleteProjectionThreadActivitiesInput,
102 execute: ({ threadId }) =>147 execute: ({ threadId }) =>
...@@ -124,21 +169,21 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () {...@@ -124,21 +169,21 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () {
124 "ProjectionThreadActivityRepository.listByThreadId:decodeRows",169 "ProjectionThreadActivityRepository.listByThreadId:decodeRows",
125 ),170 ),
126 ),171 ),
127 Effect.map((rows) =>172 Effect.map(mapActivityRows),
128 rows.map((row) => ({
129 activityId: row.activityId,
130 threadId: row.threadId,
131 turnId: row.turnId,
132 tone: row.tone,
133 kind: row.kind,
134 summary: row.summary,
135 payload: row.payload,
136 ...(row.sequence !== null ? { sequence: row.sequence } : {}),
137 createdAt: row.createdAt,
138 })),
139 ),
140 );173 );
141174
175 const listUserInputLifecycleByThreadId: ProjectionThreadActivityRepositoryShape["listUserInputLifecycleByThreadId"] =
176 (input) =>
177 listUserInputLifecycleActivityRows(input).pipe(
178 Effect.mapError(
179 toPersistenceSqlOrDecodeError(
180 "ProjectionThreadActivityRepository.listUserInputLifecycleByThreadId:query",
181 "ProjectionThreadActivityRepository.listUserInputLifecycleByThreadId:decodeRows",
182 ),
183 ),
184 Effect.map(mapActivityRows),
185 );
186
142 const deleteByThreadId: ProjectionThreadActivityRepositoryShape["deleteByThreadId"] = (input) =>187 const deleteByThreadId: ProjectionThreadActivityRepositoryShape["deleteByThreadId"] = (input) =>
143 deleteProjectionThreadActivityRows(input).pipe(188 deleteProjectionThreadActivityRows(input).pipe(
144 Effect.mapError(189 Effect.mapError(
...@@ -149,6 +194,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () {...@@ -149,6 +194,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () {
149 return {194 return {
150 upsert,195 upsert,
151 listByThreadId,196 listByThreadId,
197 listUserInputLifecycleByThreadId,
152 deleteByThreadId,198 deleteByThreadId,
153 } satisfies ProjectionThreadActivityRepositoryShape;199 } satisfies ProjectionThreadActivityRepositoryShape;
154});200});
apps/server/src/persistence/Services/ProjectionThreadActivities.ts+9
...@@ -67,6 +67,15 @@ export interface ProjectionThreadActivityRepositoryShape {...@@ -67,6 +67,15 @@ export interface ProjectionThreadActivityRepositoryShape {
67 input: ListProjectionThreadActivitiesInput,67 input: ListProjectionThreadActivitiesInput,
68 ) => Effect.Effect<ReadonlyArray<ProjectionThreadActivity>, ProjectionRepositoryError>;68 ) => Effect.Effect<ReadonlyArray<ProjectionThreadActivity>, ProjectionRepositoryError>;
6969
70 /**
71 * List activity rows used to derive pending user-input state.
72 *
73 * Filters in SQLite so unrelated payloads do not enter server memory.
74 */
75 readonly listUserInputLifecycleByThreadId: (
76 input: ListProjectionThreadActivitiesInput,
77 ) => Effect.Effect<ReadonlyArray<ProjectionThreadActivity>, ProjectionRepositoryError>;
78
70 /**79 /**
71 * Delete projected thread activity rows by thread.80 * Delete projected thread activity rows by thread.
72 */81 */