Introduces the schema, storage, and migration for richer subagent tracking. Nothing produces the new data yet — adapters populate neutral defaults so every subagent row remains valid — which keeps this change reviewable as a pure data-model step. Contracts gain a reusable subagent identity: per-activation records (SubagentActivationId, OrchestrationV2SubagentActivation), cumulative usage, a role, recent activity, and workflow grouping. A subagent may now also rest at "idle" between activations. Because a subagent status is not a turn item status, the two are bridged by an explicit mapping rather than a copy. The Record type makes that total: adding a subagent status without giving it a timeline meaning is a compile error. Without it, a producer can emit a turn item whose own schema cannot decode it, which is unrecoverable — the projection rebuild then fails on every subsequent startup and the server cannot boot. Migration 043 backfills existing rows so old events stay readable. Its riskiest path is a partially-migrated row, where already-present JSON is round-tripped through COALESCE; a test pins that structured values keep their JSON subtype rather than being rewritten as quoted strings. Also narrows event-envelope attribution in ProviderEventIngestor: a payload carrying an explicit null run or node id now keeps that null instead of inheriting the ambient run. Activations legitimately have no run id, and the envelope is persistence metadata only — routing reads payload fields — but this does change stored metadata for existing event types that already pass explicit nulls.
Unified · Split
| @@ -2085,6 +2085,14 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 2084 unmodified lines | ||
| 2085 | 2085 | prompt: update.prompt, |
| 2086 | 2086 | title: update.title, |
| 2087 | 2087 | model: update.model, |
| 2088 | + kind: "subagent", | |
| 2089 | + role: { name: "general-purpose", source: "app_default" }, | |
| 2090 | + usage: null, | |
| 2091 | + currentActivationId: null, | |
| 2092 | + activationCount: 1, | |
| 2093 | + workflow: null, | |
| 2094 | + workflowMembership: null, | |
| 2095 | + recentActivity: [], | |
| 2088 | 2096 | result: null, |
| 2089 | 2097 | startedAt: now, |
| 2090 | 2098 | }), |
| @@ -2343,6 +2343,14 @@ export function makeClaudeAdapterV2( · 2342 unmodified lines | ||
| 2343 | 2343 | prompt: input.prompt ?? "", |
| 2344 | 2344 | title: input.title ?? null, |
| 2345 | 2345 | model: input.context.input.modelSelection.model, |
| 2346 | + kind: "subagent" as const, | |
| 2347 | + role: { name: "general-purpose", source: "app_default" as const }, | |
| 2348 | + usage: null, | |
| 2349 | + currentActivationId: null, | |
| 2350 | + activationCount: 1, | |
| 2351 | + workflow: null, | |
| 2352 | + workflowMembership: null, | |
| 2353 | + recentActivity: [], | |
| 2346 | 2354 | result: null, |
| 2347 | 2355 | startedAt: now, |
| 2348 | 2356 | }), |
| @@ -1,4 +1,9 @@ | ||
| 1 | −import { CodexSettings, defaultInstanceIdForDriver, ProviderDriverKind } from "@t3tools/contracts"; | |
| 1 | +import { | |
| 2 | + CodexSettings, | |
| 3 | + defaultInstanceIdForDriver, | |
| 4 | + orchestrationV2SubagentStatusAsTurnItemStatus, | |
| 5 | + ProviderDriverKind, | |
| 6 | +} from "@t3tools/contracts"; | |
| 2 | 7 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; |
| 3 | 8 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; |
| 4 | 9 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| @@ -100,6 +105,7 @@ import { · 95 unmodified lines | ||
| 100 | 105 | makeSubagentConversationArtifacts, |
| 101 | 106 | subagentThreadTitle, |
| 102 | 107 | } from "../SubagentProjection.ts"; |
| 108 | +import { defaultSubagentRole } from "../SubagentObservability.ts"; | |
| 103 | 109 | |
| 104 | 110 | const CODEX_PROVIDER = ProviderDriverKind.make("codex"); |
| 105 | 111 | export const CODEX_DRIVER_KIND = CODEX_PROVIDER; |
| @@ -1774,7 +1780,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi · 1668 unmodified lines | ||
| 1774 | 1780 | nativeItemRef: task.nativeTaskRef, |
| 1775 | 1781 | parentItemId: null, |
| 1776 | 1782 | ordinal: input.subagent.turnItemOrdinal, |
| 1777 | − status: task.status, | |
| 1783 | + status: orchestrationV2SubagentStatusAsTurnItemStatus[task.status], | |
| 1778 | 1784 | title: task.title, |
| 1779 | 1785 | startedAt: task.startedAt, |
| 1780 | 1786 | completedAt: task.completedAt, |
| @@ -2021,8 +2027,16 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi · 240 unmodified lines | ||
| 2021 | 2027 | prompt: input.prompt, |
| 2022 | 2028 | title: input.title, |
| 2023 | 2029 | model: input.model, |
| 2030 | + kind: "subagent", | |
| 2031 | + role: defaultSubagentRole(), | |
| 2024 | 2032 | status: "running", |
| 2025 | 2033 | result: null, |
| 2034 | + usage: null, | |
| 2035 | + currentActivationId: null, | |
| 2036 | + activationCount: 1, | |
| 2037 | + workflow: null, | |
| 2038 | + workflowMembership: null, | |
| 2039 | + recentActivity: [], | |
| 2026 | 2040 | startedAt: now, |
| 2027 | 2041 | completedAt: null, |
| 2028 | 2042 | updatedAt: now, |
| @@ -1494,6 +1494,14 @@ export function makeCursorAdapterV2( · 1493 unmodified lines | ||
| 1494 | 1494 | prompt: args.prompt, |
| 1495 | 1495 | title: args.description, |
| 1496 | 1496 | model: args.model ?? input.context.input.modelSelection.model, |
| 1497 | + kind: "subagent", | |
| 1498 | + role: { name: "general-purpose", source: "app_default" }, | |
| 1499 | + usage: null, | |
| 1500 | + currentActivationId: null, | |
| 1501 | + activationCount: 1, | |
| 1502 | + workflow: null, | |
| 1503 | + workflowMembership: null, | |
| 1504 | + recentActivity: [], | |
| 1497 | 1505 | result: null, |
| 1498 | 1506 | startedAt: now, |
| 1499 | 1507 | }), |
| @@ -1215,6 +1215,14 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid · 1214 unmodified lines | ||
| 1215 | 1215 | prompt, |
| 1216 | 1216 | title, |
| 1217 | 1217 | model: context.model, |
| 1218 | + kind: "subagent", | |
| 1219 | + role: { name: "general-purpose", source: "app_default" }, | |
| 1220 | + usage: null, | |
| 1221 | + currentActivationId: null, | |
| 1222 | + activationCount: 1, | |
| 1223 | + workflow: null, | |
| 1224 | + workflowMembership: null, | |
| 1225 | + recentActivity: [], | |
| 1218 | 1226 | status: subagentStatus, |
| 1219 | 1227 | result: context.result, |
| 1220 | 1228 | startedAt: context.startedAt, |
| @@ -19,6 +19,7 @@ import { · 18 unmodified lines | ||
| 19 | 19 | ProviderThreadId, |
| 20 | 20 | RunAttemptId, |
| 21 | 21 | RunId, |
| 22 | + SubagentActivationId, | |
| 22 | 23 | ThreadId, |
| 23 | 24 | TurnItemId, |
| 24 | 25 | } from "@t3tools/contracts"; |
| @@ -409,6 +410,7 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { · 384 unmodified lines | ||
| 409 | 410 | const eventSink = yield* EventSinkV2; |
| 410 | 411 | const projectionStore = yield* ProjectionStoreV2; |
| 411 | 412 | const maintenance = yield* ProjectionMaintenanceV2; |
| 413 | + const sql = yield* SqlClient.SqlClient; | |
| 412 | 414 | const now = yield* DateTime.now; |
| 413 | 415 | const parentThreadId = ThreadId.make("thread:foundation-cross-thread:parent"); |
| 414 | 416 | const childThreadId = ThreadId.make("thread:foundation-cross-thread:child"); |
| @@ -416,6 +418,9 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { · 1 unmodified line | ||
| 416 | 418 | "provider-thread:foundation-cross-thread:child", |
| 417 | 419 | ); |
| 418 | 420 | const subagentId = NodeId.make("subagent:foundation-cross-thread"); |
| 421 | + const activationId = SubagentActivationId.make( | |
| 422 | + "subagent:foundation-cross-thread:activation:1", | |
| 423 | + ); | |
| 419 | 424 | const spawnTransferId = ContextTransferId.make("transfer:foundation-cross-thread:spawn"); |
| 420 | 425 | const resultTransferId = ContextTransferId.make("transfer:foundation-cross-thread:result"); |
| 421 | 426 | const parentThread = makeThread(parentThreadId, now); |
| @@ -464,8 +469,37 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { · 42 unmodified lines | ||
| 464 | 469 | prompt: "Inspect the child flow", |
| 465 | 470 | title: "Cross-thread child", |
| 466 | 471 | model: modelSelection.model, |
| 472 | + kind: "subagent", | |
| 473 | + role: { name: "general-purpose", source: "app_default" }, | |
| 467 | 474 | status: "completed", |
| 468 | 475 | result: "done", |
| 476 | + usage: null, | |
| 477 | + currentActivationId: null, | |
| 478 | + activationCount: 1, | |
| 479 | + workflow: null, | |
| 480 | + workflowMembership: null, | |
| 481 | + recentActivity: [], | |
| 482 | + startedAt: now, | |
| 483 | + completedAt: now, | |
| 484 | + updatedAt: now, | |
| 485 | + }, | |
| 486 | + }, | |
| 487 | + { | |
| 488 | + id: EventId.make("event:foundation-cross-thread:subagent-activation"), | |
| 489 | + type: "subagent-activation.updated", | |
| 490 | + threadId: parentThreadId, | |
| 491 | + nodeId: subagentId, | |
| 492 | + providerInstanceId, | |
| 493 | + occurredAt: now, | |
| 494 | + payload: { | |
| 495 | + id: activationId, | |
| 496 | + threadId: parentThreadId, | |
| 497 | + subagentId, | |
| 498 | + runId: null, | |
| 499 | + providerTurnId: null, | |
| 500 | + ordinal: 1, | |
| 501 | + status: "completed", | |
| 502 | + usage: { totalTokens: 240, toolUses: 2 }, | |
| 469 | 503 | startedAt: now, |
| 470 | 504 | completedAt: now, |
| 471 | 505 | updatedAt: now, |
| @@ -566,11 +600,57 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { · 94 unmodified lines | ||
| 566 | 600 | [childProviderThreadId], |
| 567 | 601 | ); |
| 568 | 602 | assert.equal(child.thread.activeProviderThreadId, childProviderThreadId); |
| 603 | + assert.deepEqual( | |
| 604 | + parent.subagentActivations.map((activation) => ({ | |
| 605 | + id: activation.id, | |
| 606 | + totalTokens: activation.usage?.totalTokens, | |
| 607 | + })), | |
| 608 | + [ | |
| 609 | + { | |
| 610 | + id: activationId, | |
| 611 | + totalTokens: 240, | |
| 612 | + }, | |
| 613 | + ], | |
| 614 | + ); | |
| 569 | 615 | }); |
| 570 | 616 | |
| 571 | 617 | yield* assertCrossThreadProjection; |
| 572 | 618 | assert.isTrue((yield* maintenance.verify).valid); |
| 619 | + yield* sql` | |
| 620 | + INSERT INTO orchestration_v2_projection_subagent_activations ( | |
| 621 | + activation_id, | |
| 622 | + thread_id, | |
| 623 | + subagent_id, | |
| 624 | + run_id, | |
| 625 | + provider_turn_id, | |
| 626 | + ordinal, | |
| 627 | + status, | |
| 628 | + started_at, | |
| 629 | + completed_at, | |
| 630 | + updated_at, | |
| 631 | + payload_json | |
| 632 | + ) | |
| 633 | + VALUES ( | |
| 634 | + 'activation:foundation-cross-thread:stale', | |
| 635 | + ${parentThreadId}, | |
| 636 | + ${subagentId}, | |
| 637 | + NULL, | |
| 638 | + NULL, | |
| 639 | + 99, | |
| 640 | + 'completed', | |
| 641 | + NULL, | |
| 642 | + NULL, | |
| 643 | + '2026-07-26T00:00:00.000Z', | |
| 644 | + '{}' | |
| 645 | + ) | |
| 646 | + `; | |
| 573 | 647 | assert.isTrue((yield* maintenance.rebuild).valid); |
| 648 | + const staleActivationRows = yield* sql<{ readonly count: number }>` | |
| 649 | + SELECT COUNT(*) AS count | |
| 650 | + FROM orchestration_v2_projection_subagent_activations | |
| 651 | + WHERE activation_id = 'activation:foundation-cross-thread:stale' | |
| 652 | + `; | |
| 653 | + assert.equal(staleActivationRows[0]?.count, 0); | |
| 574 | 654 | yield* assertCrossThreadProjection; |
| 575 | 655 | }), |
| 576 | 656 | ); |
| @@ -19,6 +19,7 @@ import { · 18 unmodified lines | ||
| 19 | 19 | type OrchestrationV2ThreadShellSnapshot, |
| 20 | 20 | type OrchestrationV2StoredEvent, |
| 21 | 21 | type OrchestrationV2Subagent, |
| 22 | + type OrchestrationV2SubagentActivation, | |
| 22 | 23 | type OrchestrationV2ThreadProjection, |
| 23 | 24 | type OrchestrationV2TurnItem, |
| 24 | 25 | ProviderInstanceId, |
| @@ -63,6 +64,7 @@ import { · 38 unmodified lines | ||
| 63 | 64 | subagentResultForRun, |
| 64 | 65 | subagentThreadTitle, |
| 65 | 66 | } from "./SubagentProjection.ts"; |
| 67 | +import { defaultSubagentRole, subagentActivationId } from "./SubagentObservability.ts"; | |
| 66 | 68 | import { ThreadForkServiceV2 } from "./ThreadForkService.ts"; |
| 67 | 69 | |
| 68 | 70 | export class OrchestratorDispatchError extends Schema.TaggedErrorClass<OrchestratorDispatchError>()( |
| @@ -294,7 +296,10 @@ function isTerminalDelegatedTaskStatus(status: OrchestrationV2Subagent["status"] · 225 unmodified lines | ||
| 294 | 296 | |
| 295 | 297 | function delegatedTaskTerminalStatus( |
| 296 | 298 | status: OrchestrationV2Run["status"], |
| 297 | −): OrchestrationV2Subagent["status"] | null { | |
| 299 | +): Extract< | |
| 300 | + OrchestrationV2Subagent["status"], | |
| 301 | + "completed" | "failed" | "cancelled" | "interrupted" | |
| 302 | +> | null { | |
| 298 | 303 | switch (status) { |
| 299 | 304 | case "completed": |
| 300 | 305 | case "failed": |
| @@ -312,6 +317,12 @@ function delegatedTaskTerminalStatus( · 11 unmodified lines | ||
| 312 | 317 | } |
| 313 | 318 | } |
| 314 | 319 | |
| 320 | +const isTerminalSubagentActivation = (activation: OrchestrationV2SubagentActivation) => | |
| 321 | + activation.status === "completed" || | |
| 322 | + activation.status === "failed" || | |
| 323 | + activation.status === "cancelled" || | |
| 324 | + activation.status === "interrupted"; | |
| 325 | + | |
| 315 | 326 | function nextQueuedRun( |
| 316 | 327 | projection: OrchestrationV2ThreadProjection, |
| 317 | 328 | ): OrchestrationV2Run | undefined { |
| @@ -3941,6 +3952,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 3623 unmodified lines | ||
| 3941 | 3952 | runtimeMode: command.runtimeMode, |
| 3942 | 3953 | interactionMode: command.interactionMode, |
| 3943 | 3954 | }; |
| 3955 | + const activationId = subagentActivationId(taskNodeId, 1); | |
| 3944 | 3956 | const task: OrchestrationV2Subagent = { |
| 3945 | 3957 | id: taskNodeId, |
| 3946 | 3958 | threadId: command.parentThreadId, |
| @@ -3957,8 +3969,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 10 unmodified lines | ||
| 3957 | 3969 | title: command.title ?? null, |
| 3958 | 3970 | model: command.modelSelection.model, |
| 3959 | 3971 | ...(command.completionWake === undefined ? {} : { completionWake: command.completionWake }), |
| 3972 | + kind: "subagent", | |
| 3973 | + role: defaultSubagentRole("delegated-worker"), | |
| 3960 | 3974 | status: "running", |
| 3961 | 3975 | result: null, |
| 3976 | + usage: null, | |
| 3977 | + currentActivationId: activationId, | |
| 3978 | + activationCount: 1, | |
| 3979 | + workflow: null, | |
| 3980 | + workflowMembership: null, | |
| 3981 | + recentActivity: [], | |
| 3962 | 3982 | startedAt: now, |
| 3963 | 3983 | completedAt: null, |
| 3964 | 3984 | updatedAt: now, |
| @@ -3981,6 +4001,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 16 unmodified lines | ||
| 3981 | 4001 | completedAt: null, |
| 3982 | 4002 | }; |
| 3983 | 4003 | const parentProviderTurn = providerTurnForRun(parentProjection, parentRun); |
| 4004 | + const activation = { | |
| 4005 | + id: activationId, | |
| 4006 | + threadId: command.parentThreadId, | |
| 4007 | + subagentId: taskNodeId, | |
| 4008 | + runId: parentRun.id, | |
| 4009 | + providerTurnId: parentProviderTurn?.id ?? null, | |
| 4010 | + ordinal: 1, | |
| 4011 | + status: "running", | |
| 4012 | + usage: null, | |
| 4013 | + startedAt: now, | |
| 4014 | + completedAt: null, | |
| 4015 | + updatedAt: now, | |
| 4016 | + } satisfies OrchestrationV2SubagentActivation; | |
| 3984 | 4017 | const taskTurnItem: OrchestrationV2TurnItem = { |
| 3985 | 4018 | id: taskTurnItemId, |
| 3986 | 4019 | threadId: command.parentThreadId, |
| @@ -4035,6 +4068,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 48 unmodified lines | ||
| 4035 | 4068 | occurredAt: now, |
| 4036 | 4069 | payload: task, |
| 4037 | 4070 | }); |
| 4071 | + yield* emitEvent({ | |
| 4072 | + type: "subagent-activation.updated", | |
| 4073 | + threadId: command.parentThreadId, | |
| 4074 | + runId: parentRun.id, | |
| 4075 | + nodeId: taskNodeId, | |
| 4076 | + driver: targetAdapter.driver, | |
| 4077 | + providerInstanceId: command.modelSelection.instanceId, | |
| 4078 | + occurredAt: now, | |
| 4079 | + payload: activation, | |
| 4080 | + }); | |
| 4038 | 4081 | yield* emitEvent({ |
| 4039 | 4082 | type: "turn-item.updated", |
| 4040 | 4083 | threadId: command.parentThreadId, |
| @@ -5475,9 +5518,41 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 1434 unmodified lines | ||
| 5475 | 5518 | providerThreadId: childRun.providerThreadId, |
| 5476 | 5519 | status: terminalStatus, |
| 5477 | 5520 | result: result.text, |
| 5521 | + currentActivationId: null, | |
| 5478 | 5522 | completedAt: now, |
| 5479 | 5523 | updatedAt: now, |
| 5480 | 5524 | }; |
| 5525 | + const currentActivation = | |
| 5526 | + task.currentActivationId === null | |
| 5527 | + ? undefined | |
| 5528 | + : parentProjection.subagentActivations.find( | |
| 5529 | + (activation) => activation.id === task.currentActivationId, | |
| 5530 | + ); | |
| 5531 | + const terminalActivation = | |
| 5532 | + task.currentActivationId === null || currentActivation?.status === terminalStatus | |
| 5533 | + ? null | |
| 5534 | + : currentActivation === undefined | |
| 5535 | + ? ({ | |
| 5536 | + id: task.currentActivationId, | |
| 5537 | + threadId: task.threadId, | |
| 5538 | + subagentId: task.id, | |
| 5539 | + runId: task.runId, | |
| 5540 | + providerTurnId: null, | |
| 5541 | + ordinal: Math.max(1, task.activationCount), | |
| 5542 | + status: terminalStatus, | |
| 5543 | + usage: task.usage, | |
| 5544 | + startedAt: task.startedAt, | |
| 5545 | + completedAt: now, | |
| 5546 | + updatedAt: now, | |
| 5547 | + } satisfies OrchestrationV2SubagentActivation) | |
| 5548 | + : isTerminalSubagentActivation(currentActivation) | |
| 5549 | + ? null | |
| 5550 | + : ({ | |
| 5551 | + ...currentActivation, | |
| 5552 | + status: terminalStatus, | |
| 5553 | + completedAt: now, | |
| 5554 | + updatedAt: now, | |
| 5555 | + } satisfies OrchestrationV2SubagentActivation); | |
| 5481 | 5556 | const resultTransferId = yield* idAllocator.allocate.contextTransfer({ |
| 5482 | 5557 | sourceThreadId: childThreadId, |
| 5483 | 5558 | targetThreadId: parentThreadId, |
| @@ -5562,6 +5637,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 78 unmodified lines | ||
| 5562 | 5637 | occurredAt: now, |
| 5563 | 5638 | payload: updatedTask, |
| 5564 | 5639 | }, |
| 5640 | + ...(terminalActivation === null | |
| 5641 | + ? [] | |
| 5642 | + : [ | |
| 5643 | + { | |
| 5644 | + type: "subagent-activation.updated" as const, | |
| 5645 | + threadId: parentThreadId, | |
| 5646 | + ...(task.runId === null ? {} : { runId: task.runId }), | |
| 5647 | + nodeId: task.id, | |
| 5648 | + driver: task.driver, | |
| 5649 | + occurredAt: now, | |
| 5650 | + payload: terminalActivation, | |
| 5651 | + }, | |
| 5652 | + ]), | |
| 5565 | 5653 | ...(parentNode === undefined |
| 5566 | 5654 | ? [] |
| 5567 | 5655 | : [ |
| @@ -160,6 +160,7 @@ export const layer: Layer.Layer< · 159 unmodified lines | ||
| 160 | 160 | yield* sql`DELETE FROM orchestration_v2_projection_provider_threads`; |
| 161 | 161 | yield* sql`DELETE FROM orchestration_v2_projection_provider_session_bindings`; |
| 162 | 162 | yield* sql`DELETE FROM orchestration_v2_projection_provider_sessions`; |
| 163 | + yield* sql`DELETE FROM orchestration_v2_projection_subagent_activations`; | |
| 163 | 164 | yield* sql`DELETE FROM orchestration_v2_projection_subagents`; |
| 164 | 165 | yield* sql`DELETE FROM orchestration_v2_projection_nodes`; |
| 165 | 166 | yield* sql`DELETE FROM orchestration_v2_projection_run_attempts`; |
| @@ -25,6 +25,7 @@ import { · 24 unmodified lines | ||
| 25 | 25 | OrchestrationV2RunJson as OrchestrationV2RunJsonSchema, |
| 26 | 26 | OrchestrationV2RuntimeRequestJson as OrchestrationV2RuntimeRequestJsonSchema, |
| 27 | 27 | OrchestrationV2SubagentJson as OrchestrationV2SubagentJsonSchema, |
| 28 | + OrchestrationV2SubagentActivationJson as OrchestrationV2SubagentActivationJsonSchema, | |
| 28 | 29 | OrchestrationV2TurnItemJson as OrchestrationV2TurnItemJsonSchema, |
| 29 | 30 | RunId, |
| 30 | 31 | ThreadId, |
| @@ -124,7 +125,7 @@ export class ProjectionStoreV2 extends Context.Service<ProjectionStoreV2, Projec · 93 unmodified lines | ||
| 124 | 125 | "t3/orchestration-v2/ProjectionStore/ProjectionStoreV2", |
| 125 | 126 | ) {} |
| 126 | 127 | |
| 127 | −export const ORCHESTRATION_V2_PROJECTION_SCHEMA_VERSION = 2; | |
| 128 | +export const ORCHESTRATION_V2_PROJECTION_SCHEMA_VERSION = 3; | |
| 128 | 129 | |
| 129 | 130 | function upsertById<T extends { readonly id: string }>(items: ReadonlyArray<T>, next: T): Array<T> { |
| 130 | 131 | const index = items.findIndex((item) => item.id === next.id); |
| @@ -146,6 +147,7 @@ export function emptyProjection( · 15 unmodified lines | ||
| 146 | 147 | attempts: [], |
| 147 | 148 | nodes: [], |
| 148 | 149 | subagents: [], |
| 150 | + subagentActivations: [], | |
| 149 | 151 | providerSessions: [], |
| 150 | 152 | providerThreads: [], |
| 151 | 153 | providerTurns: [], |
| @@ -223,6 +225,11 @@ export function applyToProjection( · 71 unmodified lines | ||
| 223 | 225 | ...base, |
| 224 | 226 | subagents: upsertById(base.subagents, event.payload), |
| 225 | 227 | }; |
| 228 | + case "subagent-activation.updated": | |
| 229 | + return { | |
| 230 | + ...base, | |
| 231 | + subagentActivations: upsertById(base.subagentActivations, event.payload), | |
| 232 | + }; | |
| 226 | 233 | case "provider-session.attached": |
| 227 | 234 | case "provider-session.updated": |
| 228 | 235 | return { |
| @@ -439,6 +446,9 @@ const encodeNodePayload = Schema.encodeEffect( · 210 unmodified lines | ||
| 439 | 446 | const encodeSubagentPayload = Schema.encodeEffect( |
| 440 | 447 | Schema.fromJsonString(OrchestrationV2SubagentJsonSchema), |
| 441 | 448 | ); |
| 449 | +const encodeSubagentActivationPayload = Schema.encodeEffect( | |
| 450 | + Schema.fromJsonString(OrchestrationV2SubagentActivationJsonSchema), | |
| 451 | +); | |
| 442 | 452 | const encodeProviderSessionPayload = Schema.encodeEffect( |
| 443 | 453 | Schema.fromJsonString(OrchestrationV2ProviderSessionJsonSchema), |
| 444 | 454 | ); |
| @@ -483,6 +493,10 @@ const decodeNodePayload = (json: string) => · 38 unmodified lines | ||
| 483 | 493 | Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2ExecutionNodeJsonSchema))(json); |
| 484 | 494 | const decodeSubagentPayload = (json: string) => |
| 485 | 495 | Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2SubagentJsonSchema))(json); |
| 496 | +const decodeSubagentActivationPayload = (json: string) => | |
| 497 | + Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2SubagentActivationJsonSchema))( | |
| 498 | + json, | |
| 499 | + ); | |
| 486 | 500 | const decodeProviderSessionPayload = (json: string) => |
| 487 | 501 | Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2ProviderSessionJsonSchema))(json); |
| 488 | 502 | const decodeProviderThreadPayload = (json: string) => |
| @@ -1293,6 +1307,51 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 804 unmodified lines | ||
| 1293 | 1307 | `; |
| 1294 | 1308 | break; |
| 1295 | 1309 | } |
| 1310 | + case "subagent-activation.updated": { | |
| 1311 | + const payloadJson = yield* encodeSubagentActivationPayload(event.payload); | |
| 1312 | + const payload = parseEncodedPayload(payloadJson); | |
| 1313 | + yield* sql` | |
| 1314 | + INSERT INTO orchestration_v2_projection_subagent_activations ( | |
| 1315 | + activation_id, | |
| 1316 | + thread_id, | |
| 1317 | + subagent_id, | |
| 1318 | + run_id, | |
| 1319 | + provider_turn_id, | |
| 1320 | + ordinal, | |
| 1321 | + status, | |
| 1322 | + started_at, | |
| 1323 | + completed_at, | |
| 1324 | + updated_at, | |
| 1325 | + payload_json | |
| 1326 | + ) | |
| 1327 | + VALUES ( | |
| 1328 | + ${event.payload.id}, | |
| 1329 | + ${event.payload.threadId}, | |
| 1330 | + ${event.payload.subagentId}, | |
| 1331 | + ${event.payload.runId}, | |
| 1332 | + ${event.payload.providerTurnId}, | |
| 1333 | + ${event.payload.ordinal}, | |
| 1334 | + ${event.payload.status}, | |
| 1335 | + ${nullableStringField(payload, "startedAt")}, | |
| 1336 | + ${nullableStringField(payload, "completedAt")}, | |
| 1337 | + ${stringField(payload, "updatedAt")}, | |
| 1338 | + ${payloadJson} | |
| 1339 | + ) | |
| 1340 | + ON CONFLICT(activation_id) | |
| 1341 | + DO UPDATE SET | |
| 1342 | + thread_id = excluded.thread_id, | |
| 1343 | + subagent_id = excluded.subagent_id, | |
| 1344 | + run_id = excluded.run_id, | |
| 1345 | + provider_turn_id = excluded.provider_turn_id, | |
| 1346 | + ordinal = excluded.ordinal, | |
| 1347 | + status = excluded.status, | |
| 1348 | + started_at = excluded.started_at, | |
| 1349 | + completed_at = excluded.completed_at, | |
| 1350 | + updated_at = excluded.updated_at, | |
| 1351 | + payload_json = excluded.payload_json | |
| 1352 | + `; | |
| 1353 | + break; | |
| 1354 | + } | |
| 1296 | 1355 | case "provider-session.attached": |
| 1297 | 1356 | case "provider-session.updated": { |
| 1298 | 1357 | const payloadJson = yield* encodeProviderSessionPayload(event.payload); |
| @@ -1869,6 +1928,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 570 unmodified lines | ||
| 1869 | 1928 | attemptRows, |
| 1870 | 1929 | nodeRows, |
| 1871 | 1930 | subagentRows, |
| 1931 | + subagentActivationRows, | |
| 1872 | 1932 | providerSessionRows, |
| 1873 | 1933 | providerThreadRows, |
| 1874 | 1934 | providerTurnRows, |
| @@ -1906,6 +1966,12 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 31 unmodified lines | ||
| 1906 | 1966 | WHERE thread_id = ${threadId} |
| 1907 | 1967 | ORDER BY COALESCE(started_at, ''), subagent_id ASC |
| 1908 | 1968 | `, |
| 1969 | + sql<PayloadRow>` | |
| 1970 | + SELECT payload_json | |
| 1971 | + FROM orchestration_v2_projection_subagent_activations | |
| 1972 | + WHERE thread_id = ${threadId} | |
| 1973 | + ORDER BY subagent_id ASC, ordinal ASC | |
| 1974 | + `, | |
| 1909 | 1975 | sql<PayloadRow>` |
| 1910 | 1976 | SELECT sessions.payload_json |
| 1911 | 1977 | FROM orchestration_v2_projection_provider_sessions AS sessions |
| @@ -1992,6 +2058,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 80 unmodified lines | ||
| 1992 | 2058 | attempts, |
| 1993 | 2059 | nodes, |
| 1994 | 2060 | subagents, |
| 2061 | + subagentActivations, | |
| 1995 | 2062 | providerSessions, |
| 1996 | 2063 | providerThreads, |
| 1997 | 2064 | providerTurns, |
| @@ -2008,6 +2075,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 10 unmodified lines | ||
| 2008 | 2075 | decodeRows(decodeRunAttemptPayload, threadId)(attemptRows), |
| 2009 | 2076 | decodeRows(decodeNodePayload, threadId)(nodeRows), |
| 2010 | 2077 | decodeRows(decodeSubagentPayload, threadId)(subagentRows), |
| 2078 | + decodeRows(decodeSubagentActivationPayload, threadId)(subagentActivationRows), | |
| 2011 | 2079 | decodeRows(decodeProviderSessionPayload, threadId)(providerSessionRows), |
| 2012 | 2080 | decodeRows(decodeProviderThreadPayload, threadId)(providerThreadRows), |
| 2013 | 2081 | decodeRows(decodeProviderTurnPayload, threadId)(providerTurnRows), |
| @@ -2027,6 +2095,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 13 unmodified lines | ||
| 2027 | 2095 | attempts, |
| 2028 | 2096 | nodes, |
| 2029 | 2097 | subagents, |
| 2098 | + subagentActivations, | |
| 2030 | 2099 | providerSessions, |
| 2031 | 2100 | providerThreads, |
| 2032 | 2101 | providerTurns, |
| @@ -16,6 +16,7 @@ import { · 15 unmodified lines | ||
| 16 | 16 | OrchestrationV2ProviderTurn, |
| 17 | 17 | OrchestrationV2RuntimeRequest, |
| 18 | 18 | OrchestrationV2Subagent, |
| 19 | + OrchestrationV2SubagentActivation, | |
| 19 | 20 | OrchestrationV2TurnItem, |
| 20 | 21 | ProviderApprovalDecision, |
| 21 | 22 | ProviderInteractionMode, |
| @@ -31,6 +32,7 @@ import { · 9 unmodified lines | ||
| 31 | 32 | RunAttemptId, |
| 32 | 33 | RunId, |
| 33 | 34 | ThreadId, |
| 35 | + TurnItemId, | |
| 34 | 36 | } from "@t3tools/contracts"; |
| 35 | 37 | import * as Context from "effect/Context"; |
| 36 | 38 | import * as Schema from "effect/Schema"; |
| @@ -104,6 +106,11 @@ export const ProviderAdapterV2Event = Schema.Union([ · 67 unmodified lines | ||
| 104 | 106 | driver: ProviderDriverKind, |
| 105 | 107 | subagent: OrchestrationV2Subagent, |
| 106 | 108 | }), |
| 109 | + Schema.Struct({ | |
| 110 | + type: Schema.Literal("subagent_activation.updated"), | |
| 111 | + driver: ProviderDriverKind, | |
| 112 | + activation: OrchestrationV2SubagentActivation, | |
| 113 | + }), | |
| 107 | 114 | Schema.Struct({ |
| 108 | 115 | type: Schema.Literal("message.updated"), |
| 109 | 116 | driver: ProviderDriverKind, |
| @@ -8,6 +8,8 @@ import { · 7 unmodified lines | ||
| 8 | 8 | type OrchestrationV2ProviderThread, |
| 9 | 9 | ProviderDriverKind, |
| 10 | 10 | ProviderInstanceId, |
| 11 | + RunId, | |
| 12 | + SubagentActivationId, | |
| 11 | 13 | } from "@t3tools/contracts"; |
| 12 | 14 | import * as DateTime from "effect/DateTime"; |
| 13 | 15 | import * as Effect from "effect/Effect"; |
| @@ -383,4 +385,83 @@ layer("ProviderEventIngestorV2", (it) => { · 369 unmodified lines | ||
| 383 | 385 | assert.equal(messageEvents[0]?.threadId, childThreadId); |
| 384 | 386 | }), |
| 385 | 387 | ); |
| 388 | + | |
| 389 | + it.effect("does not replace explicit null run and node overrides with ambient ids", () => | |
| 390 | + Effect.gen(function* () { | |
| 391 | + const now = yield* DateTime.now; | |
| 392 | + const ingestor = yield* ProviderEventIngestorV2; | |
| 393 | + const idAllocator = yield* IdAllocatorV2; | |
| 394 | + const projectId = yield* idAllocator.allocate.project({ | |
| 395 | + fixtureName: "provider-event-null-overrides", | |
| 396 | + }); | |
| 397 | + const threadId = yield* idAllocator.allocate.thread({ | |
| 398 | + fixtureName: "provider-event-null-overrides", | |
| 399 | + projectId, | |
| 400 | + }); | |
| 401 | + const providerSessionId = yield* idAllocator.allocate.providerSession({ | |
| 402 | + providerInstanceId: modelSelection.instanceId, | |
| 403 | + threadId, | |
| 404 | + }); | |
| 405 | + const ambientRunId = RunId.make("run:provider-event-null-overrides"); | |
| 406 | + const ambientNodeId = NodeId.make("node:provider-event-null-overrides"); | |
| 407 | + const subagentId = NodeId.make("node:provider-event-null-overrides:subagent"); | |
| 408 | + | |
| 409 | + const activationEvents = yield* ingestor.normalize({ | |
| 410 | + providerSessionId, | |
| 411 | + providerInstanceId: modelSelection.instanceId, | |
| 412 | + threadId, | |
| 413 | + runId: ambientRunId, | |
| 414 | + nodeId: ambientNodeId, | |
| 415 | + event: { | |
| 416 | + type: "subagent_activation.updated", | |
| 417 | + driver: CODEX_DRIVER, | |
| 418 | + activation: { | |
| 419 | + id: SubagentActivationId.make( | |
| 420 | + "node:provider-event-null-overrides:subagent:activation:1", | |
| 421 | + ), | |
| 422 | + threadId, | |
| 423 | + subagentId, | |
| 424 | + runId: null, | |
| 425 | + providerTurnId: null, | |
| 426 | + ordinal: 1, | |
| 427 | + status: "completed", | |
| 428 | + usage: null, | |
| 429 | + startedAt: now, | |
| 430 | + completedAt: now, | |
| 431 | + updatedAt: now, | |
| 432 | + }, | |
| 433 | + }, | |
| 434 | + }); | |
| 435 | + const messageEvents = yield* ingestor.normalize({ | |
| 436 | + providerSessionId, | |
| 437 | + providerInstanceId: modelSelection.instanceId, | |
| 438 | + threadId, | |
| 439 | + runId: ambientRunId, | |
| 440 | + nodeId: ambientNodeId, | |
| 441 | + event: { | |
| 442 | + type: "message.updated", | |
| 443 | + driver: CODEX_DRIVER, | |
| 444 | + message: { | |
| 445 | + createdBy: "agent", | |
| 446 | + creationSource: "provider", | |
| 447 | + id: MessageId.make("message:provider-event-null-overrides"), | |
| 448 | + threadId, | |
| 449 | + runId: null, | |
| 450 | + nodeId: null, | |
| 451 | + role: "assistant", | |
| 452 | + text: "Detached provider message", | |
| 453 | + attachments: [], | |
| 454 | + streaming: false, | |
| 455 | + createdAt: now, | |
| 456 | + updatedAt: now, | |
| 457 | + }, | |
| 458 | + }, | |
| 459 | + }); | |
| 460 | + | |
| 461 | + assert.isUndefined(activationEvents[0]?.runId); | |
| 462 | + assert.equal(activationEvents[0]?.nodeId, subagentId); | |
| 463 | + assert.isUndefined(messageEvents[0]?.runId); | |
| 464 | + assert.isUndefined(messageEvents[0]?.nodeId); | |
| 465 | + }), | |
| 466 | + ); | |
| 386 | 467 | }); |
| @@ -125,8 +125,8 @@ export const layer: Layer.Layer<ProviderEventIngestorV2, never, EventSinkV2 | Id · 124 unmodified lines | ||
| 125 | 125 | id: eventId, |
| 126 | 126 | type: payloadInput.type, |
| 127 | 127 | threadId, |
| 128 | − runId: payloadInput.runId ?? input.runId, | |
| 129 | − nodeId: payloadInput.nodeId ?? input.nodeId, | |
| 128 | + runId: "runId" in payloadInput ? (payloadInput.runId ?? undefined) : input.runId, | |
| 129 | + nodeId: "nodeId" in payloadInput ? (payloadInput.nodeId ?? undefined) : input.nodeId, | |
| 130 | 130 | driver: input.event.driver, |
| 131 | 131 | providerInstanceId: input.providerInstanceId, |
| 132 | 132 | rawEventId: input.rawEventId, |
| @@ -191,6 +191,16 @@ export const layer: Layer.Layer<ProviderEventIngestorV2, never, EventSinkV2 | Id · 58 unmodified lines | ||
| 191 | 191 | nodeId: input.event.subagent.id, |
| 192 | 192 | }), |
| 193 | 193 | ]; |
| 194 | + case "subagent_activation.updated": | |
| 195 | + return [ | |
| 196 | + yield* makeDomainEvent(input, { | |
| 197 | + type: "subagent-activation.updated", | |
| 198 | + threadId: input.event.activation.threadId, | |
| 199 | + payload: input.event.activation, | |
| 200 | + runId: input.event.activation.runId, | |
| 201 | + nodeId: input.event.activation.subagentId, | |
| 202 | + }), | |
| 203 | + ]; | |
| 194 | 204 | case "message.updated": |
| 195 | 205 | return [ |
| 196 | 206 | yield* makeDomainEvent(input, { |
| @@ -93,6 +93,7 @@ function makeProjection(input: { · 92 unmodified lines | ||
| 93 | 93 | ], |
| 94 | 94 | nodes: [], |
| 95 | 95 | subagents: [], |
| 96 | + subagentActivations: [], | |
| 96 | 97 | providerSessions: [], |
| 97 | 98 | providerThreads: [input.providerThread], |
| 98 | 99 | providerTurns: [ |
| @@ -1311,6 +1311,14 @@ it.effect("cascade helper is provider-neutral for Claude and Codex-shaped child · 1310 unmodified lines | ||
| 1311 | 1311 | prompt: "hold", |
| 1312 | 1312 | title: "hold", |
| 1313 | 1313 | model: null, |
| 1314 | + kind: "subagent", | |
| 1315 | + role: { name: "general-purpose", source: "app_default" }, | |
| 1316 | + usage: null, | |
| 1317 | + currentActivationId: null, | |
| 1318 | + activationCount: 1, | |
| 1319 | + workflow: null, | |
| 1320 | + workflowMembership: null, | |
| 1321 | + recentActivity: [], | |
| 1314 | 1322 | status: "running", |
| 1315 | 1323 | progress: "partial progress", |
| 1316 | 1324 | result: "partial result", |
| @@ -1865,6 +1873,14 @@ function makeRunOwnedSubagentFixture(input: { · 548 unmodified lines | ||
| 1865 | 1873 | prompt: "hold", |
| 1866 | 1874 | title: "Live-test subagent hold", |
| 1867 | 1875 | model: null, |
| 1876 | + kind: "subagent", | |
| 1877 | + role: { name: "general-purpose", source: "app_default" }, | |
| 1878 | + usage: null, | |
| 1879 | + currentActivationId: null, | |
| 1880 | + activationCount: 1, | |
| 1881 | + workflow: null, | |
| 1882 | + workflowMembership: null, | |
| 1883 | + recentActivity: [], | |
| 1868 | 1884 | status: input.status, |
| 1869 | 1885 | result: null, |
| 1870 | 1886 | startedAt: now, |
| @@ -361,6 +361,8 @@ export function routeProviderEvent( · 360 unmodified lines | ||
| 361 | 361 | } |
| 362 | 362 | case "subagent.updated": |
| 363 | 363 | return [ownsRun(event.subagent.runId) || ownsChildThread(event.subagent.threadId), state]; |
| 364 | + case "subagent_activation.updated": | |
| 365 | + return [ownsRun(event.activation.runId) || ownsChildThread(event.activation.threadId), state]; | |
| 364 | 366 | case "message.updated": |
| 365 | 367 | return [ownsRun(event.message.runId) || ownsChildThread(event.message.threadId), state]; |
| 366 | 368 | case "turn_item.updated": |
| @@ -0,0 +1,23 @@ | ||
| 1 | +import { describe, expect, it } from "vite-plus/test"; | |
| 2 | +import { NodeId } from "@t3tools/contracts"; | |
| 3 | + | |
| 4 | +import { defaultSubagentRole, subagentActivationId } from "./SubagentObservability.ts"; | |
| 5 | + | |
| 6 | +describe("subagent observability", () => { | |
| 7 | + it("marks a fallback role as app-provided so provider roles stay distinguishable", () => { | |
| 8 | + expect(defaultSubagentRole()).toEqual({ name: "general-purpose", source: "app_default" }); | |
| 9 | + expect(defaultSubagentRole("workflow-worker")).toEqual({ | |
| 10 | + name: "workflow-worker", | |
| 11 | + source: "app_default", | |
| 12 | + }); | |
| 13 | + }); | |
| 14 | + | |
| 15 | + it("derives activation ids that are stable and unique per ordinal", () => { | |
| 16 | + const subagentId = NodeId.make("node:provider:codex:native-item:call_abc"); | |
| 17 | + expect(subagentActivationId(subagentId, 1)).toBe(`${subagentId}:activation:1`); | |
| 18 | + // Stable derivation means a replayed activation updates its own row rather | |
| 19 | + // than inserting a duplicate. | |
| 20 | + expect(subagentActivationId(subagentId, 2)).toBe(subagentActivationId(subagentId, 2)); | |
| 21 | + expect(subagentActivationId(subagentId, 1)).not.toBe(subagentActivationId(subagentId, 2)); | |
| 22 | + }); | |
| 23 | +}); | |
| @@ -0,0 +1,13 @@ | ||
| 1 | +import { | |
| 2 | + SubagentActivationId, | |
| 3 | + type NodeId, | |
| 4 | + type OrchestrationV2SubagentRole, | |
| 5 | +} from "@t3tools/contracts"; | |
| 6 | + | |
| 7 | +export const defaultSubagentRole = (name = "general-purpose"): OrchestrationV2SubagentRole => ({ | |
| 8 | + name, | |
| 9 | + source: "app_default", | |
| 10 | +}); | |
| 11 | + | |
| 12 | +export const subagentActivationId = (subagentId: NodeId, ordinal: number) => | |
| 13 | + SubagentActivationId.make(`${subagentId}:activation:${ordinal}`); | |
| @@ -93,6 +93,7 @@ it.effect("keeps a fork awake when its source thread is snoozed", () => · 92 unmodified lines | ||
| 93 | 93 | attempts: [], |
| 94 | 94 | nodes: [], |
| 95 | 95 | subagents: [], |
| 96 | + subagentActivations: [], | |
| 96 | 97 | providerSessions: [], |
| 97 | 98 | providerThreads: [], |
| 98 | 99 | providerTurns: [], |
| @@ -57,6 +57,7 @@ import Migration0041 from "./Migrations/041_ApplicationEventSource.ts"; · 56 unmodified lines | ||
| 57 | 57 | import Migration0042 from "./Migrations/042_OrchestrationV2EffectCancellation.ts"; |
| 58 | 58 | import Migration0043 from "./Migrations/043_ScheduledTasks.ts"; |
| 59 | 59 | import Migration0044 from "./Migrations/044_LegacyV1ImportState.ts"; |
| 60 | +import Migration0045 from "./Migrations/045_OrchestrationV2SubagentObservability.ts"; | |
| 60 | 61 | |
| 61 | 62 | /** |
| 62 | 63 | * Migration loader with all migrations defined inline. |
| @@ -113,6 +114,7 @@ export const migrationEntries = [ · 50 unmodified lines | ||
| 113 | 114 | [42, "OrchestrationV2EffectCancellation", Migration0042], |
| 114 | 115 | [43, "ScheduledTasks", Migration0043], |
| 115 | 116 | [44, "LegacyV1ImportState", Migration0044], |
| 117 | + [45, "OrchestrationV2SubagentObservability", Migration0045], | |
| 116 | 118 | ] as const; |
| 117 | 119 | |
| 118 | 120 | export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); |
| @@ -190,7 +190,7 @@ it.effect("upgrades a database already at released main migration 034", () => · 189 unmodified lines | ||
| 190 | 190 | }>` |
| 191 | 191 | SELECT migration_id, name |
| 192 | 192 | FROM effect_sql_migrations |
| 193 | − WHERE migration_id BETWEEN 34 AND 44 | |
| 193 | + WHERE migration_id BETWEEN 34 AND 45 | |
| 194 | 194 | ORDER BY migration_id |
| 195 | 195 | `; |
| 196 | 196 | assert.deepStrictEqual( |
| @@ -207,6 +207,7 @@ it.effect("upgrades a database already at released main migration 034", () => · 10 unmodified lines | ||
| 207 | 207 | [42, "OrchestrationV2EffectCancellation"], |
| 208 | 208 | [43, "ScheduledTasks"], |
| 209 | 209 | [44, "LegacyV1ImportState"], |
| 210 | + [45, "OrchestrationV2SubagentObservability"], | |
| 210 | 211 | ], |
| 211 | 212 | ); |
| 212 | 213 | |
| @@ -0,0 +1,222 @@ | ||
| 1 | +import { assert, it } from "@effect/vitest"; | |
| 2 | +import * as Effect from "effect/Effect"; | |
| 3 | +import * as Layer from "effect/Layer"; | |
| 4 | +import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 5 | + | |
| 6 | +import { runMigrations } from "../Migrations.ts"; | |
| 7 | +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; | |
| 8 | + | |
| 9 | +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); | |
| 10 | + | |
| 11 | +layer("044_OrchestrationV2SubagentObservability", (it) => { | |
| 12 | + it.effect("backfills old subagents and installs activation persistence", () => | |
| 13 | + Effect.gen(function* () { | |
| 14 | + const sql = yield* SqlClient.SqlClient; | |
| 15 | + yield* runMigrations({ toMigrationInclusive: 43 }); | |
| 16 | + yield* sql` | |
| 17 | + INSERT INTO orchestration_v2_projection_subagents ( | |
| 18 | + subagent_id, | |
| 19 | + thread_id, | |
| 20 | + run_id, | |
| 21 | + parent_node_id, | |
| 22 | + provider, | |
| 23 | + provider_thread_id, | |
| 24 | + child_thread_id, | |
| 25 | + origin, | |
| 26 | + status, | |
| 27 | + started_at, | |
| 28 | + completed_at, | |
| 29 | + updated_at, | |
| 30 | + payload_json | |
| 31 | + ) VALUES ( | |
| 32 | + 'node:legacy-subagent', | |
| 33 | + 'thread:legacy-subagent', | |
| 34 | + 'run:legacy-subagent', | |
| 35 | + 'node:legacy-root', | |
| 36 | + 'codex', | |
| 37 | + NULL, | |
| 38 | + NULL, | |
| 39 | + 'provider_native', | |
| 40 | + 'completed', | |
| 41 | + '2026-07-26T00:00:00.000Z', | |
| 42 | + '2026-07-26T00:01:00.000Z', | |
| 43 | + '2026-07-26T00:01:00.000Z', | |
| 44 | + '{"id":"node:legacy-subagent","status":"completed"}' | |
| 45 | + ) | |
| 46 | + `; | |
| 47 | + yield* sql` | |
| 48 | + INSERT INTO orchestration_v2_projection_subagents ( | |
| 49 | + subagent_id, | |
| 50 | + thread_id, | |
| 51 | + run_id, | |
| 52 | + parent_node_id, | |
| 53 | + provider, | |
| 54 | + provider_thread_id, | |
| 55 | + child_thread_id, | |
| 56 | + origin, | |
| 57 | + status, | |
| 58 | + started_at, | |
| 59 | + completed_at, | |
| 60 | + updated_at, | |
| 61 | + payload_json | |
| 62 | + ) VALUES ( | |
| 63 | + 'node:current-subagent', | |
| 64 | + 'thread:current-subagent', | |
| 65 | + 'run:current-subagent', | |
| 66 | + 'node:current-root', | |
| 67 | + 'claudeAgent', | |
| 68 | + NULL, | |
| 69 | + NULL, | |
| 70 | + 'provider_native', | |
| 71 | + 'running', | |
| 72 | + '2026-07-26T00:00:00.000Z', | |
| 73 | + NULL, | |
| 74 | + '2026-07-26T00:01:00.000Z', | |
| 75 | + '{"id":"node:current-subagent","status":"running","kind":"workflow_agent","role":{"name":"researcher","source":"provider"},"usage":{"totalTokens":12},"currentActivationId":"activation:current","activationCount":2,"workflow":null,"workflowMembership":null,"recentActivity":[{"at":"2026-07-26T00:00:30.000Z","summary":"kept"}]}' | |
| 76 | + ) | |
| 77 | + `; | |
| 78 | + | |
| 79 | + // Half-migrated: `role` and `recentActivity` already exist as structured | |
| 80 | + // JSON, while `kind`, `usage`, and `activationCount` are still missing. | |
| 81 | + yield* sql` | |
| 82 | + INSERT INTO orchestration_v2_projection_subagents ( | |
| 83 | + subagent_id, | |
| 84 | + thread_id, | |
| 85 | + run_id, | |
| 86 | + parent_node_id, | |
| 87 | + provider, | |
| 88 | + provider_thread_id, | |
| 89 | + child_thread_id, | |
| 90 | + origin, | |
| 91 | + status, | |
| 92 | + started_at, | |
| 93 | + completed_at, | |
| 94 | + updated_at, | |
| 95 | + payload_json | |
| 96 | + ) VALUES ( | |
| 97 | + 'node:mixed-subagent', | |
| 98 | + 'thread:mixed-subagent', | |
| 99 | + 'run:mixed-subagent', | |
| 100 | + 'node:mixed-root', | |
| 101 | + 'codex', | |
| 102 | + NULL, | |
| 103 | + NULL, | |
| 104 | + 'provider_native', | |
| 105 | + 'running', | |
| 106 | + '2026-07-26T00:00:00.000Z', | |
| 107 | + NULL, | |
| 108 | + '2026-07-26T00:01:00.000Z', | |
| 109 | + '{"id":"node:mixed-subagent","status":"running","role":{"name":"reviewer","source":"provider"},"recentActivity":[{"at":"2026-07-26T00:00:30.000Z","summary":"kept"}]}' | |
| 110 | + ) | |
| 111 | + `; | |
| 112 | + | |
| 113 | + yield* runMigrations({ toMigrationInclusive: 44 }); | |
| 114 | + | |
| 115 | + const rows = yield* sql<{ | |
| 116 | + readonly kind: string; | |
| 117 | + readonly role_name: string; | |
| 118 | + readonly role_source: string; | |
| 119 | + readonly activation_count: number; | |
| 120 | + readonly usage_type: string; | |
| 121 | + readonly activity_count: number; | |
| 122 | + }>` | |
| 123 | + SELECT | |
| 124 | + json_extract(payload_json, '$.kind') AS kind, | |
| 125 | + json_extract(payload_json, '$.role.name') AS role_name, | |
| 126 | + json_extract(payload_json, '$.role.source') AS role_source, | |
| 127 | + json_extract(payload_json, '$.activationCount') AS activation_count, | |
| 128 | + json_type(payload_json, '$.usage') AS usage_type, | |
| 129 | + json_array_length(json_extract(payload_json, '$.recentActivity')) AS activity_count | |
| 130 | + FROM orchestration_v2_projection_subagents | |
| 131 | + WHERE subagent_id = 'node:legacy-subagent' | |
| 132 | + `; | |
| 133 | + assert.deepStrictEqual(rows, [ | |
| 134 | + { | |
| 135 | + kind: "subagent", | |
| 136 | + role_name: "general-purpose", | |
| 137 | + role_source: "app_default", | |
| 138 | + activation_count: 1, | |
| 139 | + usage_type: "null", | |
| 140 | + activity_count: 0, | |
| 141 | + }, | |
| 142 | + ]); | |
| 143 | + | |
| 144 | + const currentRows = yield* sql<{ | |
| 145 | + readonly kind: string; | |
| 146 | + readonly role_name: string; | |
| 147 | + readonly role_source: string; | |
| 148 | + readonly total_tokens: number; | |
| 149 | + readonly current_activation_id: string; | |
| 150 | + readonly activation_count: number; | |
| 151 | + readonly activity_summary: string; | |
| 152 | + }>` | |
| 153 | + SELECT | |
| 154 | + json_extract(payload_json, '$.kind') AS kind, | |
| 155 | + json_extract(payload_json, '$.role.name') AS role_name, | |
| 156 | + json_extract(payload_json, '$.role.source') AS role_source, | |
| 157 | + json_extract(payload_json, '$.usage.totalTokens') AS total_tokens, | |
| 158 | + json_extract(payload_json, '$.currentActivationId') AS current_activation_id, | |
| 159 | + json_extract(payload_json, '$.activationCount') AS activation_count, | |
| 160 | + json_extract(payload_json, '$.recentActivity[0].summary') AS activity_summary | |
| 161 | + FROM orchestration_v2_projection_subagents | |
| 162 | + WHERE subagent_id = 'node:current-subagent' | |
| 163 | + `; | |
| 164 | + assert.deepStrictEqual(currentRows, [ | |
| 165 | + { | |
| 166 | + kind: "workflow_agent", | |
| 167 | + role_name: "researcher", | |
| 168 | + role_source: "provider", | |
| 169 | + total_tokens: 12, | |
| 170 | + current_activation_id: "activation:current", | |
| 171 | + activation_count: 2, | |
| 172 | + activity_summary: "kept", | |
| 173 | + }, | |
| 174 | + ]); | |
| 175 | + | |
| 176 | + // A partially-migrated row is the only one that both matches the WHERE | |
| 177 | + // clause and round-trips already-present JSON through COALESCE. If the | |
| 178 | + // JSON subtype were lost there, structured values would be rewritten as | |
| 179 | + // quoted strings and the payload would stop decoding — the same | |
| 180 | + // unreadable-event failure this migration exists to avoid. | |
| 181 | + const mixedRows = yield* sql<{ | |
| 182 | + readonly role_type: string; | |
| 183 | + readonly role_name: string; | |
| 184 | + readonly activity_type: string; | |
| 185 | + readonly activity_summary: string; | |
| 186 | + readonly usage_type: string; | |
| 187 | + readonly kind: string; | |
| 188 | + readonly activation_count: number; | |
| 189 | + }>` | |
| 190 | + SELECT | |
| 191 | + json_type(payload_json, '$.role') AS role_type, | |
| 192 | + json_extract(payload_json, '$.role.name') AS role_name, | |
| 193 | + json_type(payload_json, '$.recentActivity') AS activity_type, | |
| 194 | + json_extract(payload_json, '$.recentActivity[0].summary') AS activity_summary, | |
| 195 | + json_type(payload_json, '$.usage') AS usage_type, | |
| 196 | + json_extract(payload_json, '$.kind') AS kind, | |
| 197 | + json_extract(payload_json, '$.activationCount') AS activation_count | |
| 198 | + FROM orchestration_v2_projection_subagents | |
| 199 | + WHERE subagent_id = 'node:mixed-subagent' | |
| 200 | + `; | |
| 201 | + assert.deepStrictEqual(mixedRows, [ | |
| 202 | + { | |
| 203 | + role_type: "object", | |
| 204 | + role_name: "reviewer", | |
| 205 | + activity_type: "array", | |
| 206 | + activity_summary: "kept", | |
| 207 | + usage_type: "null", | |
| 208 | + kind: "subagent", | |
| 209 | + activation_count: 1, | |
| 210 | + }, | |
| 211 | + ]); | |
| 212 | + | |
| 213 | + const activationTable = yield* sql<{ readonly name: string }>` | |
| 214 | + SELECT name | |
| 215 | + FROM sqlite_master | |
| 216 | + WHERE type = 'table' | |
| 217 | + AND name = 'orchestration_v2_projection_subagent_activations' | |
| 218 | + `; | |
| 219 | + assert.lengthOf(activationTable, 1); | |
| 220 | + }), | |
| 221 | + ); | |
| 222 | +}); | |
| @@ -0,0 +1,50 @@ | ||
| 1 | +import * as Effect from "effect/Effect"; | |
| 2 | +import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 3 | + | |
| 4 | +export default Effect.gen(function* () { | |
| 5 | + const sql = yield* SqlClient.SqlClient; | |
| 6 | + | |
| 7 | + yield* sql` | |
| 8 | + CREATE TABLE orchestration_v2_projection_subagent_activations ( | |
| 9 | + activation_id TEXT PRIMARY KEY, | |
| 10 | + thread_id TEXT NOT NULL, | |
| 11 | + subagent_id TEXT NOT NULL, | |
| 12 | + run_id TEXT, | |
| 13 | + provider_turn_id TEXT, | |
| 14 | + ordinal INTEGER NOT NULL, | |
| 15 | + status TEXT NOT NULL, | |
| 16 | + started_at TEXT, | |
| 17 | + completed_at TEXT, | |
| 18 | + updated_at TEXT NOT NULL, | |
| 19 | + payload_json TEXT NOT NULL | |
| 20 | + ) | |
| 21 | + `; | |
| 22 | + yield* sql`CREATE INDEX orchestration_v2_projection_subagent_activations_thread_idx ON orchestration_v2_projection_subagent_activations(thread_id, subagent_id, ordinal)`; | |
| 23 | + yield* sql`CREATE INDEX orchestration_v2_projection_subagent_activations_run_idx ON orchestration_v2_projection_subagent_activations(run_id, status)`; | |
| 24 | + | |
| 25 | + yield* sql` | |
| 26 | + UPDATE orchestration_v2_projection_subagents | |
| 27 | + SET payload_json = json_set( | |
| 28 | + payload_json, | |
| 29 | + '$.kind', COALESCE(json_extract(payload_json, '$.kind'), 'subagent'), | |
| 30 | + '$.role', COALESCE( | |
| 31 | + json_extract(payload_json, '$.role'), | |
| 32 | + json_object('name', 'general-purpose', 'source', 'app_default') | |
| 33 | + ), | |
| 34 | + '$.usage', json_extract(payload_json, '$.usage'), | |
| 35 | + '$.currentActivationId', json_extract(payload_json, '$.currentActivationId'), | |
| 36 | + '$.activationCount', COALESCE(json_extract(payload_json, '$.activationCount'), 1), | |
| 37 | + '$.workflow', json_extract(payload_json, '$.workflow'), | |
| 38 | + '$.workflowMembership', json_extract(payload_json, '$.workflowMembership'), | |
| 39 | + '$.recentActivity', COALESCE(json_extract(payload_json, '$.recentActivity'), json_array()) | |
| 40 | + ) | |
| 41 | + WHERE json_type(payload_json, '$.kind') IS NULL | |
| 42 | + OR json_type(payload_json, '$.role') IS NULL | |
| 43 | + OR json_type(payload_json, '$.usage') IS NULL | |
| 44 | + OR json_type(payload_json, '$.currentActivationId') IS NULL | |
| 45 | + OR json_type(payload_json, '$.activationCount') IS NULL | |
| 46 | + OR json_type(payload_json, '$.workflow') IS NULL | |
| 47 | + OR json_type(payload_json, '$.workflowMembership') IS NULL | |
| 48 | + OR json_type(payload_json, '$.recentActivity') IS NULL | |
| 49 | + `; | |
| 50 | +}); | |
| @@ -51,6 +51,7 @@ export function makeThreadProjectionFixture(): OrchestrationV2ThreadProjection { · 50 unmodified lines | ||
| 51 | 51 | attempts: [], |
| 52 | 52 | nodes: [], |
| 53 | 53 | subagents: [], |
| 54 | + subagentActivations: [], | |
| 54 | 55 | providerSessions: [], |
| 55 | 56 | providerThreads: [], |
| 56 | 57 | providerTurns: [], |
| @@ -86,6 +86,7 @@ const emptyProjection = { · 85 unmodified lines | ||
| 86 | 86 | attempts: [], |
| 87 | 87 | nodes: [], |
| 88 | 88 | subagents: [], |
| 89 | + subagentActivations: [], | |
| 89 | 90 | providerSessions: [], |
| 90 | 91 | providerThreads: [], |
| 91 | 92 | providerTurns: [], |
| @@ -136,6 +136,11 @@ export function applyOrchestrationV2ProjectionEvent( · 135 unmodified lines | ||
| 136 | 136 | return { ...base, nodes: upsertEntity(base.nodes, event.payload) }; |
| 137 | 137 | case "subagent.updated": |
| 138 | 138 | return { ...base, subagents: upsertEntity(base.subagents, event.payload) }; |
| 139 | + case "subagent-activation.updated": | |
| 140 | + return { | |
| 141 | + ...base, | |
| 142 | + subagentActivations: upsertEntity(base.subagentActivations, event.payload), | |
| 143 | + }; | |
| 139 | 144 | case "provider-session.attached": |
| 140 | 145 | case "provider-session.updated": |
| 141 | 146 | return { |
| @@ -86,6 +86,7 @@ export const v2Projection: OrchestrationV2ThreadProjection = { · 85 unmodified lines | ||
| 86 | 86 | attempts: [], |
| 87 | 87 | nodes: [], |
| 88 | 88 | subagents: [], |
| 89 | + subagentActivations: [], | |
| 89 | 90 | providerSessions: [], |
| 90 | 91 | providerThreads: [], |
| 91 | 92 | providerTurns: [], |
| @@ -72,6 +72,8 @@ export const RunAttemptId = makeEntityId("RunAttemptId"); · 71 unmodified lines | ||
| 72 | 72 | export type RunAttemptId = typeof RunAttemptId.Type; |
| 73 | 73 | export const NodeId = makeEntityId("NodeId"); |
| 74 | 74 | export type NodeId = typeof NodeId.Type; |
| 75 | +export const SubagentActivationId = makeEntityId("SubagentActivationId"); | |
| 76 | +export type SubagentActivationId = typeof SubagentActivationId.Type; | |
| 75 | 77 | export const AuthSessionId = makeEntityId("AuthSessionId"); |
| 76 | 78 | export type AuthSessionId = typeof AuthSessionId.Type; |
| 77 | 79 | export const RpcClientId = NonNegativeInt.pipe(Schema.brand("RpcClientId")); |
| @@ -24,10 +24,15 @@ import { · 23 unmodified lines | ||
| 24 | 24 | OrchestrationV2CheckpointScope, |
| 25 | 25 | OrchestrationV2Command, |
| 26 | 26 | OrchestrationV2DomainEvent, |
| 27 | + OrchestrationV2DomainEventJson, | |
| 27 | 28 | OrchestrationV2ShellSnapshot, |
| 28 | 29 | OrchestrationV2Subagent, |
| 30 | + OrchestrationV2SubagentActivation, | |
| 29 | 31 | OrchestrationV2ThreadProjection, |
| 30 | 32 | OrchestrationV2TurnItem, |
| 33 | + OrchestrationV2TurnItemJson, | |
| 34 | + OrchestrationV2TurnItemStatus, | |
| 35 | + orchestrationV2SubagentStatusAsTurnItemStatus, | |
| 31 | 36 | } from "./orchestrationV2.ts"; |
| 32 | 37 | |
| 33 | 38 | const now = DateTime.makeUnsafe("2026-04-20T00:00:00.000Z"); |
| @@ -410,13 +415,89 @@ describe("orchestration V2 contracts", () => { · 376 unmodified lines | ||
| 410 | 415 | prompt: "Inspect package.json", |
| 411 | 416 | title: "Package audit", |
| 412 | 417 | model: "gpt-5.4", |
| 413 | − status: "completed", | |
| 418 | + kind: "subagent", | |
| 419 | + role: { name: "reviewer", source: "provider" }, | |
| 420 | + status: "idle", | |
| 414 | 421 | progress: "Inspecting package metadata", |
| 415 | 422 | result: "Package is private.", |
| 423 | + usage: { | |
| 424 | + totalTokens: 1200, | |
| 425 | + inputTokens: 900, | |
| 426 | + outputTokens: 300, | |
| 427 | + toolUses: 2, | |
| 428 | + }, | |
| 429 | + currentActivationId: null, | |
| 430 | + activationCount: 2, | |
| 431 | + workflow: null, | |
| 432 | + workflowMembership: null, | |
| 433 | + recentActivity: [{ at: now, summary: "Inspected package metadata" }], | |
| 434 | + startedAt: now, | |
| 435 | + completedAt: now, | |
| 436 | + updatedAt: now, | |
| 437 | + }); | |
| 438 | + const activation = Schema.decodeUnknownSync(OrchestrationV2SubagentActivation)({ | |
| 439 | + id: "node-subagent-1:activation:2", | |
| 440 | + threadId: "thread-1", | |
| 441 | + subagentId: subagent.id, | |
| 442 | + runId: "run-1", | |
| 443 | + providerTurnId: "provider-turn-1", | |
| 444 | + ordinal: 2, | |
| 445 | + status: "completed", | |
| 446 | + usage: { totalTokens: 400 }, | |
| 416 | 447 | startedAt: now, |
| 417 | 448 | completedAt: now, |
| 418 | 449 | updatedAt: now, |
| 419 | 450 | }); |
| 451 | + const { | |
| 452 | + kind: _kind, | |
| 453 | + role: _role, | |
| 454 | + usage: _usage, | |
| 455 | + currentActivationId: _currentActivationId, | |
| 456 | + activationCount: _activationCount, | |
| 457 | + workflow: _workflow, | |
| 458 | + workflowMembership: _workflowMembership, | |
| 459 | + recentActivity: _recentActivity, | |
| 460 | + ...legacySubagent | |
| 461 | + } = subagent; | |
| 462 | + const decodedLegacySubagent = Schema.decodeUnknownSync(OrchestrationV2Subagent)(legacySubagent); | |
| 463 | + const decodedLegacyEvent = Schema.decodeUnknownSync(OrchestrationV2DomainEventJson)({ | |
| 464 | + id: "event-legacy-subagent", | |
| 465 | + type: "subagent.updated", | |
| 466 | + threadId: "thread-1", | |
| 467 | + runId: "run-1", | |
| 468 | + nodeId: "node-subagent-1", | |
| 469 | + driver: "codex", | |
| 470 | + providerInstanceId: "codex", | |
| 471 | + occurredAt: DateTime.formatIso(now), | |
| 472 | + payload: { | |
| 473 | + ...legacySubagent, | |
| 474 | + startedAt: DateTime.formatIso(now), | |
| 475 | + completedAt: DateTime.formatIso(now), | |
| 476 | + updatedAt: DateTime.formatIso(now), | |
| 477 | + }, | |
| 478 | + }); | |
| 479 | + const decodedActivityEvent = Schema.decodeUnknownSync(OrchestrationV2DomainEventJson)({ | |
| 480 | + id: "event-subagent-activity", | |
| 481 | + type: "subagent.updated", | |
| 482 | + threadId: "thread-1", | |
| 483 | + runId: "run-1", | |
| 484 | + nodeId: "node-subagent-1", | |
| 485 | + driver: "codex", | |
| 486 | + providerInstanceId: "codex", | |
| 487 | + occurredAt: DateTime.formatIso(now), | |
| 488 | + payload: { | |
| 489 | + ...subagent, | |
| 490 | + recentActivity: [ | |
| 491 | + { | |
| 492 | + at: DateTime.formatIso(now), | |
| 493 | + summary: "Inspected package metadata", | |
| 494 | + }, | |
| 495 | + ], | |
| 496 | + startedAt: DateTime.formatIso(now), | |
| 497 | + completedAt: DateTime.formatIso(now), | |
| 498 | + updatedAt: DateTime.formatIso(now), | |
| 499 | + }, | |
| 500 | + }); | |
| 420 | 501 | const turnItem = Schema.decodeUnknownSync(OrchestrationV2TurnItem)({ |
| 421 | 502 | id: "turn-item-subagent-1", |
| 422 | 503 | type: "subagent", |
| @@ -446,6 +527,24 @@ describe("orchestration V2 contracts", () => { · 23 unmodified lines | ||
| 446 | 527 | expect(subagent.origin).toBe("provider_native"); |
| 447 | 528 | expect(subagent.progress).toBe("Inspecting package metadata"); |
| 448 | 529 | expect(subagent.childThreadId).toBeNull(); |
| 530 | + expect(subagent.role).toEqual({ name: "reviewer", source: "provider" }); | |
| 531 | + expect(subagent.status).toBe("idle"); | |
| 532 | + expect(activation.ordinal).toBe(2); | |
| 533 | + expect(decodedLegacySubagent.role).toEqual({ | |
| 534 | + name: "general-purpose", | |
| 535 | + source: "app_default", | |
| 536 | + }); | |
| 537 | + expect(decodedLegacySubagent.activationCount).toBe(1); | |
| 538 | + if (decodedLegacyEvent.type !== "subagent.updated") { | |
| 539 | + throw new Error("expected legacy subagent event"); | |
| 540 | + } | |
| 541 | + expect(decodedLegacyEvent.payload.recentActivity).toEqual([]); | |
| 542 | + if (decodedActivityEvent.type !== "subagent.updated") { | |
| 543 | + throw new Error("expected subagent activity event"); | |
| 544 | + } | |
| 545 | + expect(DateTime.formatIso(decodedActivityEvent.payload.recentActivity[0]!.at)).toBe( | |
| 546 | + DateTime.formatIso(now), | |
| 547 | + ); | |
| 449 | 548 | expect(turnItem.type).toBe("subagent"); |
| 450 | 549 | if (turnItem.type !== "subagent") throw new Error("expected subagent item"); |
| 451 | 550 | expect(turnItem.progress).toBe("Inspecting package metadata"); |
| @@ -514,6 +613,7 @@ describe("orchestration V2 contracts", () => { · 62 unmodified lines | ||
| 514 | 613 | attempts: [], |
| 515 | 614 | nodes: [], |
| 516 | 615 | subagents: [], |
| 616 | + subagentActivations: [], | |
| 517 | 617 | providerSessions: [], |
| 518 | 618 | providerThreads: [], |
| 519 | 619 | providerTurns: [], |
| @@ -654,6 +754,61 @@ describe("orchestration V2 contracts", () => { · 134 unmodified lines | ||
| 654 | 754 | expect(fork.type).toBe("fork"); |
| 655 | 755 | }); |
| 656 | 756 | |
| 757 | + it("projects every subagent status onto a decodable turn item status", () => { | |
| 758 | + // Regression guard for a bricked-server class of bug: a producer copied a | |
| 759 | + // raw subagent status onto a timeline row, emitting a "turn-item.updated" | |
| 760 | + // event that the domain schema could not decode. Nothing failed until the | |
| 761 | + // next startup, where the projection rebuild died on the stored event and | |
| 762 | + // the server could no longer boot at all. | |
| 763 | + const subagentStatuses = OrchestrationV2Subagent.fields.status.literals; | |
| 764 | + const turnItemStatuses = new Set<string>(OrchestrationV2TurnItemStatus.literals); | |
| 765 | + | |
| 766 | + for (const status of subagentStatuses) { | |
| 767 | + const projected = orchestrationV2SubagentStatusAsTurnItemStatus[status]; | |
| 768 | + expect( | |
| 769 | + turnItemStatuses.has(projected), | |
| 770 | + `subagent status "${status}" projects to "${projected}", which a turn item cannot hold`, | |
| 771 | + ).toBe(true); | |
| 772 | + } | |
| 773 | + | |
| 774 | + // "idle" is the reusable-identity resting state and the one with no direct | |
| 775 | + // timeline equivalent; it must collapse onto the finished activation. | |
| 776 | + expect(subagentStatuses).toContain("idle"); | |
| 777 | + expect(turnItemStatuses.has("idle")).toBe(false); | |
| 778 | + expect(orchestrationV2SubagentStatusAsTurnItemStatus.idle).toBe("completed"); | |
| 779 | + | |
| 780 | + // Prove it end to end: the projected status decodes as a real event, and | |
| 781 | + // the raw one is rejected — exactly the event that could not be read back. | |
| 782 | + const base = { | |
| 783 | + id: "turn-item:subagent-status-projection", | |
| 784 | + threadId: "thread-1", | |
| 785 | + runId: null, | |
| 786 | + nodeId: null, | |
| 787 | + providerThreadId: null, | |
| 788 | + providerTurnId: null, | |
| 789 | + nativeItemRef: null, | |
| 790 | + parentItemId: null, | |
| 791 | + ordinal: 1, | |
| 792 | + title: null, | |
| 793 | + startedAt: null, | |
| 794 | + completedAt: null, | |
| 795 | + updatedAt: "2026-07-27T00:00:00.000Z", | |
| 796 | + type: "subagent", | |
| 797 | + subagentId: "node-1", | |
| 798 | + origin: "provider_native", | |
| 799 | + driver: "codex", | |
| 800 | + providerInstanceId: "codex", | |
| 801 | + childThreadId: null, | |
| 802 | + prompt: "", | |
| 803 | + result: null, | |
| 804 | + }; | |
| 805 | + const decode = Schema.decodeUnknownSync(OrchestrationV2TurnItemJson); | |
| 806 | + expect(() => | |
| 807 | + decode({ ...base, status: orchestrationV2SubagentStatusAsTurnItemStatus.idle }), | |
| 808 | + ).not.toThrow(); | |
| 809 | + expect(() => decode({ ...base, status: "idle" })).toThrow(); | |
| 810 | + }); | |
| 811 | + | |
| 657 | 812 | it("exports the V2 branded ids through the public contracts entrypoint", () => { |
| 658 | 813 | expect(ThreadId.make("thread-1")).toBe("thread-1"); |
| 659 | 814 | expect(ProjectId.make("project-1")).toBe("project-1"); |
| @@ -23,6 +23,7 @@ import { · 22 unmodified lines | ||
| 23 | 23 | RunAttemptId, |
| 24 | 24 | RunId, |
| 25 | 25 | RuntimeRequestId, |
| 26 | + SubagentActivationId, | |
| 26 | 27 | ThreadId, |
| 27 | 28 | TrimmedNonEmptyString, |
| 28 | 29 | TurnItemId, |
| @@ -443,6 +444,35 @@ export const OrchestrationV2ExecutionNode = Schema.Struct({ · 414 unmodified lines | ||
| 443 | 444 | }); |
| 444 | 445 | export type OrchestrationV2ExecutionNode = typeof OrchestrationV2ExecutionNode.Type; |
| 445 | 446 | |
| 447 | +export const OrchestrationV2SubagentUsage = Schema.Struct({ | |
| 448 | + totalTokens: NonNegativeInt, | |
| 449 | + inputTokens: Schema.optional(NonNegativeInt), | |
| 450 | + cachedInputTokens: Schema.optional(NonNegativeInt), | |
| 451 | + outputTokens: Schema.optional(NonNegativeInt), | |
| 452 | + reasoningOutputTokens: Schema.optional(NonNegativeInt), | |
| 453 | + toolUses: Schema.optional(NonNegativeInt), | |
| 454 | + durationMs: Schema.optional(NonNegativeInt), | |
| 455 | +}); | |
| 456 | +export type OrchestrationV2SubagentUsage = typeof OrchestrationV2SubagentUsage.Type; | |
| 457 | + | |
| 458 | +export const OrchestrationV2SubagentRole = Schema.Struct({ | |
| 459 | + name: TrimmedNonEmptyString, | |
| 460 | + source: Schema.Literals(["provider", "app_default"]), | |
| 461 | +}); | |
| 462 | +export type OrchestrationV2SubagentRole = typeof OrchestrationV2SubagentRole.Type; | |
| 463 | + | |
| 464 | +export const OrchestrationV2SubagentActivity = Schema.Struct({ | |
| 465 | + at: Schema.DateTimeUtc, | |
| 466 | + summary: TrimmedNonEmptyString, | |
| 467 | +}); | |
| 468 | +export type OrchestrationV2SubagentActivity = typeof OrchestrationV2SubagentActivity.Type; | |
| 469 | + | |
| 470 | +export const OrchestrationV2WorkflowPhase = Schema.Struct({ | |
| 471 | + index: NonNegativeInt, | |
| 472 | + title: TrimmedNonEmptyString, | |
| 473 | +}); | |
| 474 | +export type OrchestrationV2WorkflowPhase = typeof OrchestrationV2WorkflowPhase.Type; | |
| 475 | + | |
| 446 | 476 | export const OrchestrationV2Subagent = Schema.Struct({ |
| 447 | 477 | id: NodeId, |
| 448 | 478 | threadId: ThreadId, |
| @@ -464,10 +494,19 @@ export const OrchestrationV2Subagent = Schema.Struct({ · 15 unmodified lines | ||
| 464 | 494 | // live run (wait-mode delegations, whose result returns through the |
| 465 | 495 | // blocking tool call). Absent on legacy records; treated as settled_only. |
| 466 | 496 | completionWake: Schema.optional(Schema.Literals(["always", "settled_only"])), |
| 497 | + kind: Schema.Literals(["subagent", "workflow", "workflow_agent"]).pipe( | |
| 498 | + Schema.withDecodingDefault(Effect.succeed("subagent" as const)), | |
| 499 | + ), | |
| 500 | + role: OrchestrationV2SubagentRole.pipe( | |
| 501 | + Schema.withDecodingDefault( | |
| 502 | + Effect.succeed({ name: "general-purpose", source: "app_default" as const }), | |
| 503 | + ), | |
| 504 | + ), | |
| 467 | 505 | status: Schema.Literals([ |
| 468 | 506 | "pending", |
| 469 | 507 | "running", |
| 470 | 508 | "waiting", |
| 509 | + "idle", | |
| 471 | 510 | "completed", |
| 472 | 511 | "failed", |
| 473 | 512 | "cancelled", |
| @@ -475,12 +514,58 @@ export const OrchestrationV2Subagent = Schema.Struct({ · 1 unmodified line | ||
| 475 | 514 | ]), |
| 476 | 515 | progress: Schema.optional(Schema.String), |
| 477 | 516 | result: Schema.NullOr(Schema.String), |
| 517 | + usage: Schema.NullOr(OrchestrationV2SubagentUsage).pipe( | |
| 518 | + Schema.withDecodingDefault(Effect.succeed(null)), | |
| 519 | + ), | |
| 520 | + currentActivationId: Schema.NullOr(SubagentActivationId).pipe( | |
| 521 | + Schema.withDecodingDefault(Effect.succeed(null)), | |
| 522 | + ), | |
| 523 | + activationCount: NonNegativeInt.pipe(Schema.withDecodingDefault(Effect.succeed(1))), | |
| 524 | + workflow: Schema.NullOr( | |
| 525 | + Schema.Struct({ | |
| 526 | + phases: Schema.Array(OrchestrationV2WorkflowPhase), | |
| 527 | + }), | |
| 528 | + ).pipe(Schema.withDecodingDefault(Effect.succeed(null))), | |
| 529 | + workflowMembership: Schema.NullOr( | |
| 530 | + Schema.Struct({ | |
| 531 | + workflowSubagentId: NodeId, | |
| 532 | + agentIndex: NonNegativeInt, | |
| 533 | + phaseIndex: Schema.NullOr(NonNegativeInt), | |
| 534 | + attempt: PositiveInt, | |
| 535 | + }), | |
| 536 | + ).pipe(Schema.withDecodingDefault(Effect.succeed(null))), | |
| 537 | + recentActivity: Schema.Array(OrchestrationV2SubagentActivity).pipe( | |
| 538 | + Schema.withDecodingDefault(Effect.succeed([])), | |
| 539 | + ), | |
| 478 | 540 | startedAt: Schema.NullOr(Schema.DateTimeUtc), |
| 479 | 541 | completedAt: Schema.NullOr(Schema.DateTimeUtc), |
| 480 | 542 | updatedAt: Schema.DateTimeUtc, |
| 481 | 543 | }); |
| 482 | 544 | export type OrchestrationV2Subagent = typeof OrchestrationV2Subagent.Type; |
| 483 | 545 | |
| 546 | +export const OrchestrationV2SubagentActivation = Schema.Struct({ | |
| 547 | + id: SubagentActivationId, | |
| 548 | + threadId: ThreadId, | |
| 549 | + subagentId: NodeId, | |
| 550 | + runId: Schema.NullOr(RunId), | |
| 551 | + providerTurnId: Schema.NullOr(ProviderTurnId), | |
| 552 | + ordinal: PositiveInt, | |
| 553 | + status: Schema.Literals([ | |
| 554 | + "pending", | |
| 555 | + "running", | |
| 556 | + "waiting", | |
| 557 | + "completed", | |
| 558 | + "failed", | |
| 559 | + "cancelled", | |
| 560 | + "interrupted", | |
| 561 | + ]), | |
| 562 | + usage: Schema.NullOr(OrchestrationV2SubagentUsage), | |
| 563 | + startedAt: Schema.NullOr(Schema.DateTimeUtc), | |
| 564 | + completedAt: Schema.NullOr(Schema.DateTimeUtc), | |
| 565 | + updatedAt: Schema.DateTimeUtc, | |
| 566 | +}); | |
| 567 | +export type OrchestrationV2SubagentActivation = typeof OrchestrationV2SubagentActivation.Type; | |
| 568 | + | |
| 484 | 569 | export const OrchestrationV2CheckpointScope = Schema.Struct({ |
| 485 | 570 | id: CheckpointScopeId, |
| 486 | 571 | threadId: ThreadId, |
| @@ -722,6 +807,33 @@ export const OrchestrationV2TurnItemStatus = Schema.Literals([ · 235 unmodified lines | ||
| 722 | 807 | ]); |
| 723 | 808 | export type OrchestrationV2TurnItemStatus = typeof OrchestrationV2TurnItemStatus.Type; |
| 724 | 809 | |
| 810 | +/** | |
| 811 | + * How a subagent's status projects onto its timeline row. | |
| 812 | + * | |
| 813 | + * A subagent identity is reusable and rests at "idle" between activations, but | |
| 814 | + * a turn item records one completed activation and has no such state. Producers | |
| 815 | + * must translate rather than copy: writing a raw subagent status onto a turn | |
| 816 | + * item can emit an event that its own schema cannot decode, which is | |
| 817 | + * unrecoverable because the projection rebuild fails on every later startup. | |
| 818 | + * | |
| 819 | + * The `Record` type is the safeguard — adding a subagent status without giving | |
| 820 | + * it a timeline meaning here is a compile error, not a runtime brick. | |
| 821 | + */ | |
| 822 | +export const orchestrationV2SubagentStatusAsTurnItemStatus: Record< | |
| 823 | + typeof OrchestrationV2Subagent.Type.status, | |
| 824 | + OrchestrationV2TurnItemStatus | |
| 825 | +> = { | |
| 826 | + pending: "pending", | |
| 827 | + running: "running", | |
| 828 | + waiting: "waiting", | |
| 829 | + // Resting between activations: the row for the activation that just ended. | |
| 830 | + idle: "completed", | |
| 831 | + completed: "completed", | |
| 832 | + failed: "failed", | |
| 833 | + cancelled: "cancelled", | |
| 834 | + interrupted: "interrupted", | |
| 835 | +}; | |
| 836 | + | |
| 725 | 837 | export const OrchestrationV2ProviderFailureClass = Schema.Literals([ |
| 726 | 838 | "provider_error", |
| 727 | 839 | "transport_error", |
| @@ -1074,6 +1186,11 @@ export const OrchestrationV2DomainEvent = Schema.Union([ · 346 unmodified lines | ||
| 1074 | 1186 | type: Schema.Literal("subagent.updated"), |
| 1075 | 1187 | payload: OrchestrationV2Subagent, |
| 1076 | 1188 | }), |
| 1189 | + Schema.Struct({ | |
| 1190 | + ...OrchestrationV2EventBase.fields, | |
| 1191 | + type: Schema.Literal("subagent-activation.updated"), | |
| 1192 | + payload: OrchestrationV2SubagentActivation, | |
| 1193 | + }), | |
| 1077 | 1194 | Schema.Struct({ |
| 1078 | 1195 | ...OrchestrationV2EventBase.fields, |
| 1079 | 1196 | type: Schema.Literals(["provider-session.attached", "provider-session.updated"]), |
| @@ -1153,6 +1270,7 @@ export const OrchestrationV2ThreadProjection = Schema.Struct({ · 73 unmodified lines | ||
| 1153 | 1270 | attempts: Schema.Array(OrchestrationV2RunAttempt), |
| 1154 | 1271 | nodes: Schema.Array(OrchestrationV2ExecutionNode), |
| 1155 | 1272 | subagents: Schema.Array(OrchestrationV2Subagent), |
| 1273 | + subagentActivations: Schema.Array(OrchestrationV2SubagentActivation), | |
| 1156 | 1274 | providerSessions: Schema.Array(OrchestrationV2ProviderSession), |
| 1157 | 1275 | providerThreads: Schema.Array(OrchestrationV2ProviderThread), |
| 1158 | 1276 | providerTurns: Schema.Array(OrchestrationV2ProviderTurn), |
| @@ -1356,12 +1474,29 @@ export type OrchestrationV2ExecutionNodeJson = typeof OrchestrationV2ExecutionNo · 197 unmodified lines | ||
| 1356 | 1474 | |
| 1357 | 1475 | export const OrchestrationV2SubagentJson = OrchestrationV2Subagent.mapFields((fields) => ({ |
| 1358 | 1476 | ...fields, |
| 1477 | + recentActivity: Schema.Array( | |
| 1478 | + OrchestrationV2SubagentActivity.mapFields((activityFields) => ({ | |
| 1479 | + ...activityFields, | |
| 1480 | + at: Schema.DateTimeUtcFromString, | |
| 1481 | + })), | |
| 1482 | + ).pipe(Schema.withDecodingDefault(Effect.succeed([]))), | |
| 1359 | 1483 | startedAt: Schema.NullOr(Schema.DateTimeUtcFromString), |
| 1360 | 1484 | completedAt: Schema.NullOr(Schema.DateTimeUtcFromString), |
| 1361 | 1485 | updatedAt: Schema.DateTimeUtcFromString, |
| 1362 | 1486 | })); |
| 1363 | 1487 | export type OrchestrationV2SubagentJson = typeof OrchestrationV2SubagentJson.Type; |
| 1364 | 1488 | |
| 1489 | +export const OrchestrationV2SubagentActivationJson = OrchestrationV2SubagentActivation.mapFields( | |
| 1490 | + (fields) => ({ | |
| 1491 | + ...fields, | |
| 1492 | + startedAt: Schema.NullOr(Schema.DateTimeUtcFromString), | |
| 1493 | + completedAt: Schema.NullOr(Schema.DateTimeUtcFromString), | |
| 1494 | + updatedAt: Schema.DateTimeUtcFromString, | |
| 1495 | + }), | |
| 1496 | +); | |
| 1497 | +export type OrchestrationV2SubagentActivationJson = | |
| 1498 | + typeof OrchestrationV2SubagentActivationJson.Type; | |
| 1499 | + | |
| 1365 | 1500 | export const OrchestrationV2CheckpointScopeJson = OrchestrationV2CheckpointScope.mapFields( |
| 1366 | 1501 | (fields) => ({ |
| 1367 | 1502 | ...fields, |
| @@ -1650,6 +1785,9 @@ export const OrchestrationV2ThreadProjectionJson = OrchestrationV2ThreadProjecti · 282 unmodified lines | ||
| 1650 | 1785 | attempts: Schema.Array(OrchestrationV2RunAttemptJson), |
| 1651 | 1786 | nodes: Schema.Array(OrchestrationV2ExecutionNodeJson), |
| 1652 | 1787 | subagents: Schema.Array(OrchestrationV2SubagentJson), |
| 1788 | + subagentActivations: Schema.Array(OrchestrationV2SubagentActivationJson).pipe( | |
| 1789 | + Schema.withDecodingDefault(Effect.succeed([])), | |
| 1790 | + ), | |
| 1653 | 1791 | providerSessions: Schema.Array(OrchestrationV2ProviderSessionJson), |
| 1654 | 1792 | providerThreads: Schema.Array(OrchestrationV2ProviderThreadJson), |
| 1655 | 1793 | providerTurns: Schema.Array(OrchestrationV2ProviderTurnJson), |
| @@ -1780,6 +1918,11 @@ export const OrchestrationV2DomainEventJson = Schema.Union([ · 124 unmodified lines | ||
| 1780 | 1918 | type: Schema.Literal("subagent.updated"), |
| 1781 | 1919 | payload: OrchestrationV2SubagentJson, |
| 1782 | 1920 | }), |
| 1921 | + Schema.Struct({ | |
| 1922 | + ...OrchestrationV2JsonEventBaseFields, | |
| 1923 | + type: Schema.Literal("subagent-activation.updated"), | |
| 1924 | + payload: OrchestrationV2SubagentActivationJson, | |
| 1925 | + }), | |
| 1783 | 1926 | Schema.Struct({ |
| 1784 | 1927 | ...OrchestrationV2JsonEventBaseFields, |
| 1785 | 1928 | type: Schema.Literals(["provider-session.attached", "provider-session.updated"]), |