| author | |
| committer | |
| log | b17cc3d1bf0f4a5deee6dd6a470e36970b864a9b |
| tree | e5bcbcae83c0a536d8a0aea5a89efe217196c34b |
| parent | 42a8fd5103b4af57b16be721a9864ca3a5c18c5b |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 164 | |
| 165 | 165 | const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* (threadId: ThreadId) { |
| 166 | 166 | return yield* projectionSnapshotQuery |
| 167 | .getThreadDetailById(threadId) | |
| 167 | .getThreadDetailById(threadId, { activityKinds: [] }) | |
| 168 | 168 | .pipe(Effect.map(Option.getOrUndefined)); |
| 169 | 169 | }); |
| 170 | 170 |
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+114-56| ... | ... | @@ -2235,7 +2235,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 2235 | 2235 | }), |
| 2236 | 2236 | ); |
| 2237 | 2237 | |
| 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 | 2239 | Effect.gen(function* () { |
| 2240 | 2240 | const projectionPipeline = yield* OrchestrationProjectionPipeline; |
| 2241 | 2241 | const eventStore = yield* OrchestrationEventStore; |
| ... | ... | @@ -2293,70 +2293,128 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 2293 | 2293 | }, |
| 2294 | 2294 | }); |
| 2295 | 2295 | |
| 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 | 2399 | yield* appendAndProject({ |
| 2297 | type: "thread.activity-appended", | |
| 2400 | type: "thread.message-sent", | |
| 2298 | 2401 | eventId: EventId.make("evt-stale-user-input-3"), |
| 2299 | 2402 | aggregateKind: "thread", |
| 2300 | 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 | 2405 | commandId: CommandId.make("cmd-stale-user-input-3"), |
| 2303 | 2406 | causationEventId: null, |
| 2304 | 2407 | correlationId: CorrelationId.make("cmd-stale-user-input-3"), |
| 2305 | 2408 | metadata: {}, |
| 2306 | 2409 | payload: { |
| 2307 | 2410 | threadId: ThreadId.make("thread-stale-user-input"), |
| 2308 | activity: { | |
| 2309 | id: EventId.make("activity-stale-user-input-requested"), | |
| 2310 | tone: "info", | |
| 2311 | kind: "user-input.requested", | |
| 2312 | summary: "User input requested", | |
| 2313 | payload: { | |
| 2314 | requestId: "user-input-request-stale-1", | |
| 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 | }, | |
| 2411 | messageId: MessageId.make("message-stale-user-input"), | |
| 2412 | role: "user", | |
| 2413 | text: "Continue", | |
| 2414 | turnId: null, | |
| 2415 | streaming: false, | |
| 2416 | createdAt: "2026-02-26T12:35:09.000Z", | |
| 2417 | updatedAt: "2026-02-26T12:35:09.000Z", | |
| 2360 | 2418 | }, |
| 2361 | 2419 | }); |
| 2362 | 2420 | |
| ... | ... | @@ -2367,7 +2425,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 2367 | 2425 | FROM projection_threads |
| 2368 | 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 | ); |
| 2373 | 2431 |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+2-2| ... | ... | @@ -130,7 +130,7 @@ function isStalePendingApprovalFailureDetail(detail: string | null): boolean { |
| 130 | 130 | ); |
| 131 | 131 | } |
| 132 | 132 | |
| 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. | |
| 134 | 134 | function shouldRefreshThreadShellSummary(event: OrchestrationEvent): boolean { |
| 135 | 135 | if (event.type === "thread.message-sent") { |
| 136 | 136 | return event.payload.role === "user"; |
| ... | ... | @@ -588,7 +588,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 588 | 588 | const [messages, proposedPlans, activities, pendingApprovals] = yield* Effect.all([ |
| 589 | 589 | projectionThreadMessageRepository.listByThreadId({ threadId }), |
| 590 | 590 | projectionThreadProposedPlanRepository.listByThreadId({ threadId }), |
| 591 | projectionThreadActivityRepository.listByThreadId({ threadId }), | |
| 591 | projectionThreadActivityRepository.listUserInputLifecycleByThreadId({ threadId }), | |
| 592 | 592 | projectionPendingApprovalRepository.listByThreadId({ threadId }), |
| 593 | 593 | ]); |
| 594 | 594 |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+71| ... | ... | @@ -484,6 +484,77 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 484 | 484 | if (threadDetail._tag === "Some") { |
| 485 | 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 | ); |
| 489 | 560 |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+89-22| ... | ... | @@ -64,6 +64,7 @@ import { |
| 64 | 64 | type ProjectionFullThreadDiffContext, |
| 65 | 65 | type ProjectionSnapshotCounts, |
| 66 | 66 | type ProjectionThreadCheckpointContext, |
| 67 | type ProjectionThreadDetailQuery, | |
| 67 | 68 | type ProjectionSnapshotQueryShape, |
| 68 | 69 | } from "../Services/ProjectionSnapshotQuery.ts"; |
| 69 | 70 | |
| ... | ... | @@ -141,6 +142,10 @@ const ProjectIdLookupInput = Schema.Struct({ |
| 141 | 142 | const ThreadIdLookupInput = Schema.Struct({ |
| 142 | 143 | threadId: ThreadId, |
| 143 | 144 | }); |
| 145 | const ThreadActivityKindsLookupInput = Schema.Struct({ | |
| 146 | threadId: ThreadId, | |
| 147 | activityKinds: Schema.Array(Schema.String), | |
| 148 | }); | |
| 144 | 149 | // Windowed reads order turns by the stable keyset (anchor, turn key), where |
| 145 | 150 | // anchor is requested_at and turn key is |
| 146 | 151 | // COALESCE(turn_id, ''). Both are event-derived, so cursors survive the |
| ... | ... | @@ -1055,6 +1060,48 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1055 | 1060 | `, |
| 1056 | 1061 | }); |
| 1057 | 1062 | |
| 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 | 1105 | const getThreadSessionRowByThread = SqlSchema.findOneOption({ |
| 1059 | 1106 | Request: ThreadIdLookupInput, |
| 1060 | 1107 | Result: ProjectionThreadSessionDbRowSchema, |
| ... | ... | @@ -2524,8 +2571,43 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 2524 | 2571 | readonly beforeTurnKey: string; |
| 2525 | 2572 | } |
| 2526 | 2573 | |
| 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 | 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 | 2611 | const [ |
| 2530 | 2612 | threadRow, |
| 2531 | 2613 | messageRows, |
| ... | ... | @@ -2563,25 +2645,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 2563 | 2645 | ), |
| 2564 | 2646 | ), |
| 2565 | 2647 | ), |
| 2566 | (bounds === undefined | |
| 2567 | ? listThreadActivityRowsByThread({ threadId }) | |
| 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 | ), | |
| 2648 | activityRowsEffect, | |
| 2649 | pinnedActivityRowsEffect, | |
| 2585 | 2650 | listCheckpointRowsByThread({ threadId }).pipe( |
| 2586 | 2651 | Effect.mapError( |
| 2587 | 2652 | toPersistenceSqlOrDecodeError( |
| ... | ... | @@ -2700,8 +2765,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 2700 | 2765 | ); |
| 2701 | 2766 | }); |
| 2702 | 2767 | |
| 2703 | const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = (threadId) => | |
| 2704 | getThreadDetailByIdBounded(threadId, undefined); | |
| 2768 | const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = ( | |
| 2769 | threadId, | |
| 2770 | query, | |
| 2771 | ) => getThreadDetailByIdBounded(threadId, undefined, query); | |
| 2705 | 2772 | |
| 2706 | 2773 | // Bounds pathological fan-out: one user turn that spawned hundreds of |
| 2707 | 2774 | // 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 | 481 | |
| 482 | 482 | const resolveThread = Effect.fnUntraced(function* (threadId: ThreadId) { |
| 483 | 483 | return yield* projectionSnapshotQuery |
| 484 | .getThreadDetailById(threadId) | |
| 484 | .getThreadDetailById(threadId, { activityKinds: [] }) | |
| 485 | 485 | .pipe(Effect.map(Option.getOrUndefined)); |
| 486 | 486 | }); |
| 487 | 487 |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+7-3| ... | ... | @@ -48,6 +48,7 @@ import { canReplaceThreadTitle } from "../threadTitles.ts"; |
| 48 | 48 | |
| 49 | 49 | const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; |
| 50 | 50 | const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${taskId}`; |
| 51 | const TASK_TITLE_ACTIVITY_KINDS = ["task.started", "task.progress"] as const; | |
| 51 | 52 | |
| 52 | 53 | // Fallback when the in-memory description cache no longer has the task name |
| 53 | 54 | // (server restart, session-exit sweep, TTL/capacity eviction): earlier |
| ... | ... | @@ -949,9 +950,12 @@ const make = Effect.gen(function* () { |
| 949 | 950 | ), |
| 950 | 951 | ); |
| 951 | 952 | |
| 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 | 957 | return yield* projectionSnapshotQuery |
| 954 | .getThreadDetailById(threadId) | |
| 958 | .getThreadDetailById(threadId, { activityKinds }) | |
| 955 | 959 | .pipe(Effect.map(Option.getOrUndefined)); |
| 956 | 960 | }); |
| 957 | 961 | |
| ... | ... | @@ -2022,7 +2026,7 @@ const make = Effect.gen(function* () { |
| 2022 | 2026 | if (event.type === "task.completed") { |
| 2023 | 2027 | taskTitle = yield* lookupTaskDescription(thread.id, event.payload.taskId); |
| 2024 | 2028 | if (!taskTitle) { |
| 2025 | const threadDetail = yield* getLoadedThreadDetail(); | |
| 2029 | const threadDetail = yield* resolveThreadDetail(thread.id, TASK_TITLE_ACTIVITY_KINDS); | |
| 2026 | 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 | 54 | readonly toCheckpointRef: CheckpointRef | null; |
| 55 | 55 | } |
| 56 | 56 | |
| 57 | export 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 | 67 | * ProjectionSnapshotQueryShape - Service API for read-model snapshots. |
| 59 | 68 | */ |
| ... | ... | @@ -168,6 +177,7 @@ export interface ProjectionSnapshotQueryShape { |
| 168 | 177 | */ |
| 169 | 178 | readonly getThreadDetailById: ( |
| 170 | 179 | threadId: ThreadId, |
| 180 | query?: ProjectionThreadDetailQuery, | |
| 171 | 181 | ) => Effect.Effect<Option.Option<OrchestrationThread>, ProjectionRepositoryError>; |
| 172 | 182 | |
| 173 | 183 | /** |
apps/server/src/persistence/Layers/ProjectionThreadActivities.ts+59-13| ... | ... | @@ -23,6 +23,21 @@ const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( |
| 23 | 23 | }), |
| 24 | 24 | ); |
| 25 | 25 | |
| 26 | const 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 | ||
| 26 | 41 | function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { |
| 27 | 42 | return (cause: unknown) => |
| 28 | 43 | Schema.isSchemaError(cause) |
| ... | ... | @@ -97,6 +112,36 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { |
| 97 | 112 | `, |
| 98 | 113 | }); |
| 99 | 114 | |
| 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 | 145 | const deleteProjectionThreadActivityRows = SqlSchema.void({ |
| 101 | 146 | Request: DeleteProjectionThreadActivitiesInput, |
| 102 | 147 | execute: ({ threadId }) => |
| ... | ... | @@ -124,21 +169,21 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { |
| 124 | 169 | "ProjectionThreadActivityRepository.listByThreadId:decodeRows", |
| 125 | 170 | ), |
| 126 | 171 | ), |
| 127 | Effect.map((rows) => | |
| 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 | ), | |
| 172 | Effect.map(mapActivityRows), | |
| 140 | 173 | ); |
| 141 | 174 | |
| 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 | 187 | const deleteByThreadId: ProjectionThreadActivityRepositoryShape["deleteByThreadId"] = (input) => |
| 143 | 188 | deleteProjectionThreadActivityRows(input).pipe( |
| 144 | 189 | Effect.mapError( |
| ... | ... | @@ -149,6 +194,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { |
| 149 | 194 | return { |
| 150 | 195 | upsert, |
| 151 | 196 | listByThreadId, |
| 197 | listUserInputLifecycleByThreadId, | |
| 152 | 198 | deleteByThreadId, |
| 153 | 199 | } satisfies ProjectionThreadActivityRepositoryShape; |
| 154 | 200 | }); |
apps/server/src/persistence/Services/ProjectionThreadActivities.ts+9| ... | ... | @@ -67,6 +67,15 @@ export interface ProjectionThreadActivityRepositoryShape { |
| 67 | 67 | input: ListProjectionThreadActivitiesInput, |
| 68 | 68 | ) => Effect.Effect<ReadonlyArray<ProjectionThreadActivity>, ProjectionRepositoryError>; |
| 69 | 69 | |
| 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 | 80 | * Delete projected thread activity rows by thread. |
| 72 | 81 | */ |