| 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,7 +164,7 @@ const make = Effect.gen(function* () { |
| 164 | 164 | ||
| 165 | const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* (threadId: ThreadId) { | 165 | const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* (threadId: ThreadId) { |
| 166 | return yield* projectionSnapshotQuery | 166 | return yield* projectionSnapshotQuery |
| 167 | .getThreadDetailById(threadId) | 167 | .getThreadDetailById(threadId, { activityKinds: [] }) |
| 168 | .pipe(Effect.map(Option.getOrUndefined)); | 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,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 | 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 | }); |
| 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 | 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 | }); |
| 2362 | 2420 | ||
| ... | @@ -2367,7 +2425,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { | ... | @@ -2367,7 +2425,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 2367 | FROM projection_threads | 2425 | 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 | ); |
| 2373 | 2431 |
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 | } |
| 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 | function shouldRefreshThreadShellSummary(event: OrchestrationEvent): boolean { | 134 | function 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 | ]); |
| 594 | 594 |
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 | ); |
| 489 | 560 |
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"; |
| 69 | 70 | ||
| ... | @@ -141,6 +142,10 @@ const ProjectIdLookupInput = Schema.Struct({ | ... | @@ -141,6 +142,10 @@ const ProjectIdLookupInput = Schema.Struct({ |
| 141 | const ThreadIdLookupInput = Schema.Struct({ | 142 | const ThreadIdLookupInput = Schema.Struct({ |
| 142 | threadId: ThreadId, | 143 | threadId: ThreadId, |
| 143 | }); | 144 | }); |
| 145 | const 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), where | 149 | // Windowed reads order turns by the stable keyset (anchor, turn key), where |
| 145 | // anchor is requested_at and turn key is | 150 | // anchor is requested_at and turn key is |
| 146 | // COALESCE(turn_id, ''). Both are event-derived, so cursors survive the | 151 | // 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 | }); |
| 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 | 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 | } |
| 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 | 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 === undefined | 2648 | 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 | }); |
| 2702 | 2767 | ||
| 2703 | const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = (threadId) => | 2768 | const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = ( |
| 2704 | getThreadDetailByIdBounded(threadId, undefined); | 2769 | threadId, |
| 2770 | query, | ||
| 2771 | ) => getThreadDetailByIdBounded(threadId, undefined, query); | ||
| 2705 | 2772 | ||
| 2706 | // Bounds pathological fan-out: one user turn that spawned hundreds of | 2773 | // Bounds pathological fan-out: one user turn that spawned hundreds of |
| 2707 | // subagent turns still pages in bounded chunks, at the cost of splitting the | 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,7 +481,7 @@ const make = Effect.gen(function* () { |
| 481 | 481 | ||
| 482 | const resolveThread = Effect.fnUntraced(function* (threadId: ThreadId) { | 482 | const resolveThread = Effect.fnUntraced(function* (threadId: ThreadId) { |
| 483 | return yield* projectionSnapshotQuery | 483 | return yield* projectionSnapshotQuery |
| 484 | .getThreadDetailById(threadId) | 484 | .getThreadDetailById(threadId, { activityKinds: [] }) |
| 485 | .pipe(Effect.map(Option.getOrUndefined)); | 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,6 +48,7 @@ import { canReplaceThreadTitle } from "../threadTitles.ts"; |
| 48 | 48 | ||
| 49 | const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; | 49 | const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; |
| 50 | const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${taskId}`; | 50 | const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${taskId}`; |
| 51 | const TASK_TITLE_ACTIVITY_KINDS = ["task.started", "task.progress"] as const; | ||
| 51 | 52 | ||
| 52 | // Fallback when the in-memory description cache no longer has the task name | 53 | // Fallback when the in-memory description cache no longer has the task name |
| 53 | // (server restart, session-exit sweep, TTL/capacity eviction): earlier | 54 | // (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 | ); |
| 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 | return yield* projectionSnapshotQuery | 957 | return yield* projectionSnapshotQuery |
| 954 | .getThreadDetailById(threadId) | 958 | .getThreadDetailById(threadId, { activityKinds }) |
| 955 | .pipe(Effect.map(Option.getOrUndefined)); | 959 | .pipe(Effect.map(Option.getOrUndefined)); |
| 956 | }); | 960 | }); |
| 957 | 961 | ||
| ... | @@ -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 | } |
| 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 | * 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>; |
| 172 | 182 | ||
| 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 | ); |
| 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 | function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { | 41 | function 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 | }); |
| 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 | 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 | ); |
| 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 | 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>; |
| 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 | * Delete projected thread activity rows by thread. | 80 | * Delete projected thread activity rows by thread. |
| 72 | */ | 81 | */ |