History

feat(orchestration-v2): add subagent observability data model

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.

Shivam Sharma committed 3 months ago

Commit c1929c21 parent 378615b29 files · +1029 −8Browse files

Unified · Split

29 files
  1. apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +8 −0
  2. apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +8 −0
  3. apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +16 −2
  4. apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts +8 −0
  5. apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts +8 −0
  6. apps/server/src/orchestration-v2/FoundationPersistence.test.ts +80 −0
  7. apps/server/src/orchestration-v2/Orchestrator.ts +89 −1
  8. apps/server/src/orchestration-v2/ProjectionMaintenance.ts +1 −0
  9. apps/server/src/orchestration-v2/ProjectionStore.ts +70 −1
  10. apps/server/src/orchestration-v2/ProviderAdapter.ts +7 −0
  11. apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +81 −0
  12. apps/server/src/orchestration-v2/ProviderEventIngestor.ts +12 −2
  13. apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +1 −0
  14. apps/server/src/orchestration-v2/RunExecutionService.test.ts +16 −0
  15. apps/server/src/orchestration-v2/RunExecutionService.ts +2 −0
  16. apps/server/src/orchestration-v2/SubagentObservability.test.ts +23 −0
  17. apps/server/src/orchestration-v2/SubagentObservability.ts +13 −0
  18. apps/server/src/orchestration-v2/ThreadForkService.test.ts +1 −0
  19. apps/server/src/persistence/Migrations.ts +2 −0
  20. apps/server/src/persistence/Migrations/036_037_OrchestrationV2.test.ts +2 −1
  21. apps/server/src/persistence/Migrations/045_OrchestrationV2SubagentObservability.test.ts +222 −0
  22. apps/server/src/persistence/Migrations/045_OrchestrationV2SubagentObservability.ts +50 −0
  23. apps/web/src/test-fixtures.ts +1 −0
  24. packages/client-runtime/src/state/orchestrationV2Projection.test.ts +1 −0
  25. packages/client-runtime/src/state/orchestrationV2Projection.ts +5 −0
  26. packages/client-runtime/src/state/orchestrationV2TestFixtures.ts +1 −0
  27. packages/contracts/src/baseSchemas.ts +2 −0
  28. packages/contracts/src/orchestrationV2.test.ts +156 −1
  29. packages/contracts/src/orchestrationV2.ts +143 −0
apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts+8−0
@@ -2085,6 +2085,14 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 2084 unmodified lines
2085 prompt: update.prompt,2085 prompt: update.prompt,
2086 title: update.title,2086 title: update.title,
2087 model: update.model,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 result: null,2096 result: null,
2089 startedAt: now,2097 startedAt: now,
2090 }),2098 }),
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+8−0
@@ -2343,6 +2343,14 @@ export function makeClaudeAdapterV2( · 2342 unmodified lines
2343 prompt: input.prompt ?? "",2343 prompt: input.prompt ?? "",
2344 title: input.title ?? null,2344 title: input.title ?? null,
2345 model: input.context.input.modelSelection.model,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 result: null,2354 result: null,
2347 startedAt: now,2355 startedAt: now,
2348 }),2356 }),
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+16−2
@@ -1,4 +1,9 @@
1import { CodexSettings, defaultInstanceIdForDriver, ProviderDriverKind } from "@t3tools/contracts";1import {
2 CodexSettings,
3 defaultInstanceIdForDriver,
4 orchestrationV2SubagentStatusAsTurnItemStatus,
5 ProviderDriverKind,
6} from "@t3tools/contracts";
2import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";7import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
3import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";8import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
4import { resolveSpawnCommand } from "@t3tools/shared/shell";9import { resolveSpawnCommand } from "@t3tools/shared/shell";
@@ -100,6 +105,7 @@ import { · 95 unmodified lines
100 makeSubagentConversationArtifacts,105 makeSubagentConversationArtifacts,
101 subagentThreadTitle,106 subagentThreadTitle,
102} from "../SubagentProjection.ts";107} from "../SubagentProjection.ts";
108import { defaultSubagentRole } from "../SubagentObservability.ts";
103 109
104const CODEX_PROVIDER = ProviderDriverKind.make("codex");110const CODEX_PROVIDER = ProviderDriverKind.make("codex");
105export const CODEX_DRIVER_KIND = CODEX_PROVIDER;111export const CODEX_DRIVER_KIND = CODEX_PROVIDER;
@@ -1774,7 +1780,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi · 1668 unmodified lines
1774 nativeItemRef: task.nativeTaskRef,1780 nativeItemRef: task.nativeTaskRef,
1775 parentItemId: null,1781 parentItemId: null,
1776 ordinal: input.subagent.turnItemOrdinal,1782 ordinal: input.subagent.turnItemOrdinal,
1777 status: task.status,1783 status: orchestrationV2SubagentStatusAsTurnItemStatus[task.status],
1778 title: task.title,1784 title: task.title,
1779 startedAt: task.startedAt,1785 startedAt: task.startedAt,
1780 completedAt: task.completedAt,1786 completedAt: task.completedAt,
@@ -2021,8 +2027,16 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi · 240 unmodified lines
2021 prompt: input.prompt,2027 prompt: input.prompt,
2022 title: input.title,2028 title: input.title,
2023 model: input.model,2029 model: input.model,
2030 kind: "subagent",
2031 role: defaultSubagentRole(),
2024 status: "running",2032 status: "running",
2025 result: null,2033 result: null,
2034 usage: null,
2035 currentActivationId: null,
2036 activationCount: 1,
2037 workflow: null,
2038 workflowMembership: null,
2039 recentActivity: [],
2026 startedAt: now,2040 startedAt: now,
2027 completedAt: null,2041 completedAt: null,
2028 updatedAt: now,2042 updatedAt: now,
apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts+8−0
@@ -1494,6 +1494,14 @@ export function makeCursorAdapterV2( · 1493 unmodified lines
1494 prompt: args.prompt,1494 prompt: args.prompt,
1495 title: args.description,1495 title: args.description,
1496 model: args.model ?? input.context.input.modelSelection.model,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 result: null,1505 result: null,
1498 startedAt: now,1506 startedAt: now,
1499 }),1507 }),
apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts+8−0
@@ -1215,6 +1215,14 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid · 1214 unmodified lines
1215 prompt,1215 prompt,
1216 title,1216 title,
1217 model: context.model,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 status: subagentStatus,1226 status: subagentStatus,
1219 result: context.result,1227 result: context.result,
1220 startedAt: context.startedAt,1228 startedAt: context.startedAt,
apps/server/src/orchestration-v2/FoundationPersistence.test.ts+80−0
@@ -19,6 +19,7 @@ import { · 18 unmodified lines
19 ProviderThreadId,19 ProviderThreadId,
20 RunAttemptId,20 RunAttemptId,
21 RunId,21 RunId,
22 SubagentActivationId,
22 ThreadId,23 ThreadId,
23 TurnItemId,24 TurnItemId,
24} from "@t3tools/contracts";25} from "@t3tools/contracts";
@@ -409,6 +410,7 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { · 384 unmodified lines
409 const eventSink = yield* EventSinkV2;410 const eventSink = yield* EventSinkV2;
410 const projectionStore = yield* ProjectionStoreV2;411 const projectionStore = yield* ProjectionStoreV2;
411 const maintenance = yield* ProjectionMaintenanceV2;412 const maintenance = yield* ProjectionMaintenanceV2;
413 const sql = yield* SqlClient.SqlClient;
412 const now = yield* DateTime.now;414 const now = yield* DateTime.now;
413 const parentThreadId = ThreadId.make("thread:foundation-cross-thread:parent");415 const parentThreadId = ThreadId.make("thread:foundation-cross-thread:parent");
414 const childThreadId = ThreadId.make("thread:foundation-cross-thread:child");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 "provider-thread:foundation-cross-thread:child",418 "provider-thread:foundation-cross-thread:child",
417 );419 );
418 const subagentId = NodeId.make("subagent:foundation-cross-thread");420 const subagentId = NodeId.make("subagent:foundation-cross-thread");
421 const activationId = SubagentActivationId.make(
422 "subagent:foundation-cross-thread:activation:1",
423 );
419 const spawnTransferId = ContextTransferId.make("transfer:foundation-cross-thread:spawn");424 const spawnTransferId = ContextTransferId.make("transfer:foundation-cross-thread:spawn");
420 const resultTransferId = ContextTransferId.make("transfer:foundation-cross-thread:result");425 const resultTransferId = ContextTransferId.make("transfer:foundation-cross-thread:result");
421 const parentThread = makeThread(parentThreadId, now);426 const parentThread = makeThread(parentThreadId, now);
@@ -464,8 +469,37 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { · 42 unmodified lines
464 prompt: "Inspect the child flow",469 prompt: "Inspect the child flow",
465 title: "Cross-thread child",470 title: "Cross-thread child",
466 model: modelSelection.model,471 model: modelSelection.model,
472 kind: "subagent",
473 role: { name: "general-purpose", source: "app_default" },
467 status: "completed",474 status: "completed",
468 result: "done",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 startedAt: now,503 startedAt: now,
470 completedAt: now,504 completedAt: now,
471 updatedAt: now,505 updatedAt: now,
@@ -566,11 +600,57 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { · 94 unmodified lines
566 [childProviderThreadId],600 [childProviderThreadId],
567 );601 );
568 assert.equal(child.thread.activeProviderThreadId, childProviderThreadId);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 yield* assertCrossThreadProjection;617 yield* assertCrossThreadProjection;
572 assert.isTrue((yield* maintenance.verify).valid);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 assert.isTrue((yield* maintenance.rebuild).valid);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 yield* assertCrossThreadProjection;654 yield* assertCrossThreadProjection;
575 }),655 }),
576 );656 );
apps/server/src/orchestration-v2/Orchestrator.ts+89−1
@@ -19,6 +19,7 @@ import { · 18 unmodified lines
19 type OrchestrationV2ThreadShellSnapshot,19 type OrchestrationV2ThreadShellSnapshot,
20 type OrchestrationV2StoredEvent,20 type OrchestrationV2StoredEvent,
21 type OrchestrationV2Subagent,21 type OrchestrationV2Subagent,
22 type OrchestrationV2SubagentActivation,
22 type OrchestrationV2ThreadProjection,23 type OrchestrationV2ThreadProjection,
23 type OrchestrationV2TurnItem,24 type OrchestrationV2TurnItem,
24 ProviderInstanceId,25 ProviderInstanceId,
@@ -63,6 +64,7 @@ import { · 38 unmodified lines
63 subagentResultForRun,64 subagentResultForRun,
64 subagentThreadTitle,65 subagentThreadTitle,
65} from "./SubagentProjection.ts";66} from "./SubagentProjection.ts";
67import { defaultSubagentRole, subagentActivationId } from "./SubagentObservability.ts";
66import { ThreadForkServiceV2 } from "./ThreadForkService.ts";68import { ThreadForkServiceV2 } from "./ThreadForkService.ts";
67 69
68export class OrchestratorDispatchError extends Schema.TaggedErrorClass<OrchestratorDispatchError>()(70export class OrchestratorDispatchError extends Schema.TaggedErrorClass<OrchestratorDispatchError>()(
@@ -294,7 +296,10 @@ function isTerminalDelegatedTaskStatus(status: OrchestrationV2Subagent["status"] · 225 unmodified lines
294 296
295function delegatedTaskTerminalStatus(297function delegatedTaskTerminalStatus(
296 status: OrchestrationV2Run["status"],298 status: OrchestrationV2Run["status"],
297): OrchestrationV2Subagent["status"] | null {299): Extract<
300 OrchestrationV2Subagent["status"],
301 "completed" | "failed" | "cancelled" | "interrupted"
302> | null {
298 switch (status) {303 switch (status) {
299 case "completed":304 case "completed":
300 case "failed":305 case "failed":
@@ -312,6 +317,12 @@ function delegatedTaskTerminalStatus( · 11 unmodified lines
312 }317 }
313}318}
314 319
320const isTerminalSubagentActivation = (activation: OrchestrationV2SubagentActivation) =>
321 activation.status === "completed" ||
322 activation.status === "failed" ||
323 activation.status === "cancelled" ||
324 activation.status === "interrupted";
325
315function nextQueuedRun(326function nextQueuedRun(
316 projection: OrchestrationV2ThreadProjection,327 projection: OrchestrationV2ThreadProjection,
317): OrchestrationV2Run | undefined {328): OrchestrationV2Run | undefined {
@@ -3941,6 +3952,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 3623 unmodified lines
3941 runtimeMode: command.runtimeMode,3952 runtimeMode: command.runtimeMode,
3942 interactionMode: command.interactionMode,3953 interactionMode: command.interactionMode,
3943 };3954 };
3955 const activationId = subagentActivationId(taskNodeId, 1);
3944 const task: OrchestrationV2Subagent = {3956 const task: OrchestrationV2Subagent = {
3945 id: taskNodeId,3957 id: taskNodeId,
3946 threadId: command.parentThreadId,3958 threadId: command.parentThreadId,
@@ -3957,8 +3969,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 10 unmodified lines
3957 title: command.title ?? null,3969 title: command.title ?? null,
3958 model: command.modelSelection.model,3970 model: command.modelSelection.model,
3959 ...(command.completionWake === undefined ? {} : { completionWake: command.completionWake }),3971 ...(command.completionWake === undefined ? {} : { completionWake: command.completionWake }),
3972 kind: "subagent",
3973 role: defaultSubagentRole("delegated-worker"),
3960 status: "running",3974 status: "running",
3961 result: null,3975 result: null,
3976 usage: null,
3977 currentActivationId: activationId,
3978 activationCount: 1,
3979 workflow: null,
3980 workflowMembership: null,
3981 recentActivity: [],
3962 startedAt: now,3982 startedAt: now,
3963 completedAt: null,3983 completedAt: null,
3964 updatedAt: now,3984 updatedAt: now,
@@ -3981,6 +4001,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 16 unmodified lines
3981 completedAt: null,4001 completedAt: null,
3982 };4002 };
3983 const parentProviderTurn = providerTurnForRun(parentProjection, parentRun);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 const taskTurnItem: OrchestrationV2TurnItem = {4017 const taskTurnItem: OrchestrationV2TurnItem = {
3985 id: taskTurnItemId,4018 id: taskTurnItemId,
3986 threadId: command.parentThreadId,4019 threadId: command.parentThreadId,
@@ -4035,6 +4068,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 48 unmodified lines
4035 occurredAt: now,4068 occurredAt: now,
4036 payload: task,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 yield* emitEvent({4081 yield* emitEvent({
4039 type: "turn-item.updated",4082 type: "turn-item.updated",
4040 threadId: command.parentThreadId,4083 threadId: command.parentThreadId,
@@ -5475,9 +5518,41 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 1434 unmodified lines
5475 providerThreadId: childRun.providerThreadId,5518 providerThreadId: childRun.providerThreadId,
5476 status: terminalStatus,5519 status: terminalStatus,
5477 result: result.text,5520 result: result.text,
5521 currentActivationId: null,
5478 completedAt: now,5522 completedAt: now,
5479 updatedAt: now,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 const resultTransferId = yield* idAllocator.allocate.contextTransfer({5556 const resultTransferId = yield* idAllocator.allocate.contextTransfer({
5482 sourceThreadId: childThreadId,5557 sourceThreadId: childThreadId,
5483 targetThreadId: parentThreadId,5558 targetThreadId: parentThreadId,
@@ -5562,6 +5637,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 78 unmodified lines
5562 occurredAt: now,5637 occurredAt: now,
5563 payload: updatedTask,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 ...(parentNode === undefined5653 ...(parentNode === undefined
5566 ? []5654 ? []
5567 : [5655 : [
apps/server/src/orchestration-v2/ProjectionMaintenance.ts+1−0
@@ -160,6 +160,7 @@ export const layer: Layer.Layer< · 159 unmodified lines
160 yield* sql`DELETE FROM orchestration_v2_projection_provider_threads`;160 yield* sql`DELETE FROM orchestration_v2_projection_provider_threads`;
161 yield* sql`DELETE FROM orchestration_v2_projection_provider_session_bindings`;161 yield* sql`DELETE FROM orchestration_v2_projection_provider_session_bindings`;
162 yield* sql`DELETE FROM orchestration_v2_projection_provider_sessions`;162 yield* sql`DELETE FROM orchestration_v2_projection_provider_sessions`;
163 yield* sql`DELETE FROM orchestration_v2_projection_subagent_activations`;
163 yield* sql`DELETE FROM orchestration_v2_projection_subagents`;164 yield* sql`DELETE FROM orchestration_v2_projection_subagents`;
164 yield* sql`DELETE FROM orchestration_v2_projection_nodes`;165 yield* sql`DELETE FROM orchestration_v2_projection_nodes`;
165 yield* sql`DELETE FROM orchestration_v2_projection_run_attempts`;166 yield* sql`DELETE FROM orchestration_v2_projection_run_attempts`;
apps/server/src/orchestration-v2/ProjectionStore.ts+70−1
@@ -25,6 +25,7 @@ import { · 24 unmodified lines
25 OrchestrationV2RunJson as OrchestrationV2RunJsonSchema,25 OrchestrationV2RunJson as OrchestrationV2RunJsonSchema,
26 OrchestrationV2RuntimeRequestJson as OrchestrationV2RuntimeRequestJsonSchema,26 OrchestrationV2RuntimeRequestJson as OrchestrationV2RuntimeRequestJsonSchema,
27 OrchestrationV2SubagentJson as OrchestrationV2SubagentJsonSchema,27 OrchestrationV2SubagentJson as OrchestrationV2SubagentJsonSchema,
28 OrchestrationV2SubagentActivationJson as OrchestrationV2SubagentActivationJsonSchema,
28 OrchestrationV2TurnItemJson as OrchestrationV2TurnItemJsonSchema,29 OrchestrationV2TurnItemJson as OrchestrationV2TurnItemJsonSchema,
29 RunId,30 RunId,
30 ThreadId,31 ThreadId,
@@ -124,7 +125,7 @@ export class ProjectionStoreV2 extends Context.Service<ProjectionStoreV2, Projec · 93 unmodified lines
124 "t3/orchestration-v2/ProjectionStore/ProjectionStoreV2",125 "t3/orchestration-v2/ProjectionStore/ProjectionStoreV2",
125) {}126) {}
126 127
127export const ORCHESTRATION_V2_PROJECTION_SCHEMA_VERSION = 2;128export const ORCHESTRATION_V2_PROJECTION_SCHEMA_VERSION = 3;
128 129
129function upsertById<T extends { readonly id: string }>(items: ReadonlyArray<T>, next: T): Array<T> {130function upsertById<T extends { readonly id: string }>(items: ReadonlyArray<T>, next: T): Array<T> {
130 const index = items.findIndex((item) => item.id === next.id);131 const index = items.findIndex((item) => item.id === next.id);
@@ -146,6 +147,7 @@ export function emptyProjection( · 15 unmodified lines
146 attempts: [],147 attempts: [],
147 nodes: [],148 nodes: [],
148 subagents: [],149 subagents: [],
150 subagentActivations: [],
149 providerSessions: [],151 providerSessions: [],
150 providerThreads: [],152 providerThreads: [],
151 providerTurns: [],153 providerTurns: [],
@@ -223,6 +225,11 @@ export function applyToProjection( · 71 unmodified lines
223 ...base,225 ...base,
224 subagents: upsertById(base.subagents, event.payload),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 case "provider-session.attached":233 case "provider-session.attached":
227 case "provider-session.updated":234 case "provider-session.updated":
228 return {235 return {
@@ -439,6 +446,9 @@ const encodeNodePayload = Schema.encodeEffect( · 210 unmodified lines
439const encodeSubagentPayload = Schema.encodeEffect(446const encodeSubagentPayload = Schema.encodeEffect(
440 Schema.fromJsonString(OrchestrationV2SubagentJsonSchema),447 Schema.fromJsonString(OrchestrationV2SubagentJsonSchema),
441);448);
449const encodeSubagentActivationPayload = Schema.encodeEffect(
450 Schema.fromJsonString(OrchestrationV2SubagentActivationJsonSchema),
451);
442const encodeProviderSessionPayload = Schema.encodeEffect(452const encodeProviderSessionPayload = Schema.encodeEffect(
443 Schema.fromJsonString(OrchestrationV2ProviderSessionJsonSchema),453 Schema.fromJsonString(OrchestrationV2ProviderSessionJsonSchema),
444);454);
@@ -483,6 +493,10 @@ const decodeNodePayload = (json: string) => · 38 unmodified lines
483 Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2ExecutionNodeJsonSchema))(json);493 Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2ExecutionNodeJsonSchema))(json);
484const decodeSubagentPayload = (json: string) =>494const decodeSubagentPayload = (json: string) =>
485 Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2SubagentJsonSchema))(json);495 Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2SubagentJsonSchema))(json);
496const decodeSubagentActivationPayload = (json: string) =>
497 Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2SubagentActivationJsonSchema))(
498 json,
499 );
486const decodeProviderSessionPayload = (json: string) =>500const decodeProviderSessionPayload = (json: string) =>
487 Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2ProviderSessionJsonSchema))(json);501 Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2ProviderSessionJsonSchema))(json);
488const decodeProviderThreadPayload = (json: string) =>502const decodeProviderThreadPayload = (json: string) =>
@@ -1293,6 +1307,51 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 804 unmodified lines
1293 `;1307 `;
1294 break;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 case "provider-session.attached":1355 case "provider-session.attached":
1297 case "provider-session.updated": {1356 case "provider-session.updated": {
1298 const payloadJson = yield* encodeProviderSessionPayload(event.payload);1357 const payloadJson = yield* encodeProviderSessionPayload(event.payload);
@@ -1869,6 +1928,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 570 unmodified lines
1869 attemptRows,1928 attemptRows,
1870 nodeRows,1929 nodeRows,
1871 subagentRows,1930 subagentRows,
1931 subagentActivationRows,
1872 providerSessionRows,1932 providerSessionRows,
1873 providerThreadRows,1933 providerThreadRows,
1874 providerTurnRows,1934 providerTurnRows,
@@ -1906,6 +1966,12 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 31 unmodified lines
1906 WHERE thread_id = ${threadId}1966 WHERE thread_id = ${threadId}
1907 ORDER BY COALESCE(started_at, ''), subagent_id ASC1967 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 sql<PayloadRow>`1975 sql<PayloadRow>`
1910 SELECT sessions.payload_json1976 SELECT sessions.payload_json
1911 FROM orchestration_v2_projection_provider_sessions AS sessions1977 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 attempts,2058 attempts,
1993 nodes,2059 nodes,
1994 subagents,2060 subagents,
2061 subagentActivations,
1995 providerSessions,2062 providerSessions,
1996 providerThreads,2063 providerThreads,
1997 providerTurns,2064 providerTurns,
@@ -2008,6 +2075,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 10 unmodified lines
2008 decodeRows(decodeRunAttemptPayload, threadId)(attemptRows),2075 decodeRows(decodeRunAttemptPayload, threadId)(attemptRows),
2009 decodeRows(decodeNodePayload, threadId)(nodeRows),2076 decodeRows(decodeNodePayload, threadId)(nodeRows),
2010 decodeRows(decodeSubagentPayload, threadId)(subagentRows),2077 decodeRows(decodeSubagentPayload, threadId)(subagentRows),
2078 decodeRows(decodeSubagentActivationPayload, threadId)(subagentActivationRows),
2011 decodeRows(decodeProviderSessionPayload, threadId)(providerSessionRows),2079 decodeRows(decodeProviderSessionPayload, threadId)(providerSessionRows),
2012 decodeRows(decodeProviderThreadPayload, threadId)(providerThreadRows),2080 decodeRows(decodeProviderThreadPayload, threadId)(providerThreadRows),
2013 decodeRows(decodeProviderTurnPayload, threadId)(providerTurnRows),2081 decodeRows(decodeProviderTurnPayload, threadId)(providerTurnRows),
@@ -2027,6 +2095,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 13 unmodified lines
2027 attempts,2095 attempts,
2028 nodes,2096 nodes,
2029 subagents,2097 subagents,
2098 subagentActivations,
2030 providerSessions,2099 providerSessions,
2031 providerThreads,2100 providerThreads,
2032 providerTurns,2101 providerTurns,
apps/server/src/orchestration-v2/ProviderAdapter.ts+7−0
@@ -16,6 +16,7 @@ import { · 15 unmodified lines
16 OrchestrationV2ProviderTurn,16 OrchestrationV2ProviderTurn,
17 OrchestrationV2RuntimeRequest,17 OrchestrationV2RuntimeRequest,
18 OrchestrationV2Subagent,18 OrchestrationV2Subagent,
19 OrchestrationV2SubagentActivation,
19 OrchestrationV2TurnItem,20 OrchestrationV2TurnItem,
20 ProviderApprovalDecision,21 ProviderApprovalDecision,
21 ProviderInteractionMode,22 ProviderInteractionMode,
@@ -31,6 +32,7 @@ import { · 9 unmodified lines
31 RunAttemptId,32 RunAttemptId,
32 RunId,33 RunId,
33 ThreadId,34 ThreadId,
35 TurnItemId,
34} from "@t3tools/contracts";36} from "@t3tools/contracts";
35import * as Context from "effect/Context";37import * as Context from "effect/Context";
36import * as Schema from "effect/Schema";38import * as Schema from "effect/Schema";
@@ -104,6 +106,11 @@ export const ProviderAdapterV2Event = Schema.Union([ · 67 unmodified lines
104 driver: ProviderDriverKind,106 driver: ProviderDriverKind,
105 subagent: OrchestrationV2Subagent,107 subagent: OrchestrationV2Subagent,
106 }),108 }),
109 Schema.Struct({
110 type: Schema.Literal("subagent_activation.updated"),
111 driver: ProviderDriverKind,
112 activation: OrchestrationV2SubagentActivation,
113 }),
107 Schema.Struct({114 Schema.Struct({
108 type: Schema.Literal("message.updated"),115 type: Schema.Literal("message.updated"),
109 driver: ProviderDriverKind,116 driver: ProviderDriverKind,
apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts+81−0
@@ -8,6 +8,8 @@ import { · 7 unmodified lines
8 type OrchestrationV2ProviderThread,8 type OrchestrationV2ProviderThread,
9 ProviderDriverKind,9 ProviderDriverKind,
10 ProviderInstanceId,10 ProviderInstanceId,
11 RunId,
12 SubagentActivationId,
11} from "@t3tools/contracts";13} from "@t3tools/contracts";
12import * as DateTime from "effect/DateTime";14import * as DateTime from "effect/DateTime";
13import * as Effect from "effect/Effect";15import * as Effect from "effect/Effect";
@@ -383,4 +385,83 @@ layer("ProviderEventIngestorV2", (it) => { · 369 unmodified lines
383 assert.equal(messageEvents[0]?.threadId, childThreadId);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});
apps/server/src/orchestration-v2/ProviderEventIngestor.ts+12−2
@@ -125,8 +125,8 @@ export const layer: Layer.Layer<ProviderEventIngestorV2, never, EventSinkV2 | Id · 124 unmodified lines
125 id: eventId,125 id: eventId,
126 type: payloadInput.type,126 type: payloadInput.type,
127 threadId,127 threadId,
128 runId: payloadInput.runId ?? input.runId,128 runId: "runId" in payloadInput ? (payloadInput.runId ?? undefined) : input.runId,
129 nodeId: payloadInput.nodeId ?? input.nodeId,129 nodeId: "nodeId" in payloadInput ? (payloadInput.nodeId ?? undefined) : input.nodeId,
130 driver: input.event.driver,130 driver: input.event.driver,
131 providerInstanceId: input.providerInstanceId,131 providerInstanceId: input.providerInstanceId,
132 rawEventId: input.rawEventId,132 rawEventId: input.rawEventId,
@@ -191,6 +191,16 @@ export const layer: Layer.Layer<ProviderEventIngestorV2, never, EventSinkV2 | Id · 58 unmodified lines
191 nodeId: input.event.subagent.id,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 case "message.updated":204 case "message.updated":
195 return [205 return [
196 yield* makeDomainEvent(input, {206 yield* makeDomainEvent(input, {
apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts+1−0
@@ -93,6 +93,7 @@ function makeProjection(input: { · 92 unmodified lines
93 ],93 ],
94 nodes: [],94 nodes: [],
95 subagents: [],95 subagents: [],
96 subagentActivations: [],
96 providerSessions: [],97 providerSessions: [],
97 providerThreads: [input.providerThread],98 providerThreads: [input.providerThread],
98 providerTurns: [99 providerTurns: [
apps/server/src/orchestration-v2/RunExecutionService.test.ts+16−0
@@ -1311,6 +1311,14 @@ it.effect("cascade helper is provider-neutral for Claude and Codex-shaped child · 1310 unmodified lines
1311 prompt: "hold",1311 prompt: "hold",
1312 title: "hold",1312 title: "hold",
1313 model: null,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 status: "running",1322 status: "running",
1315 progress: "partial progress",1323 progress: "partial progress",
1316 result: "partial result",1324 result: "partial result",
@@ -1865,6 +1873,14 @@ function makeRunOwnedSubagentFixture(input: { · 548 unmodified lines
1865 prompt: "hold",1873 prompt: "hold",
1866 title: "Live-test subagent hold",1874 title: "Live-test subagent hold",
1867 model: null,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 status: input.status,1884 status: input.status,
1869 result: null,1885 result: null,
1870 startedAt: now,1886 startedAt: now,
apps/server/src/orchestration-v2/RunExecutionService.ts+2−0
@@ -361,6 +361,8 @@ export function routeProviderEvent( · 360 unmodified lines
361 }361 }
362 case "subagent.updated":362 case "subagent.updated":
363 return [ownsRun(event.subagent.runId) || ownsChildThread(event.subagent.threadId), state];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 case "message.updated":366 case "message.updated":
365 return [ownsRun(event.message.runId) || ownsChildThread(event.message.threadId), state];367 return [ownsRun(event.message.runId) || ownsChildThread(event.message.threadId), state];
366 case "turn_item.updated":368 case "turn_item.updated":
apps/server/src/orchestration-v2/SubagentObservability.test.ts+23−0
@@ -0,0 +1,23 @@
1import { describe, expect, it } from "vite-plus/test";
2import { NodeId } from "@t3tools/contracts";
3
4import { defaultSubagentRole, subagentActivationId } from "./SubagentObservability.ts";
5
6describe("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});
apps/server/src/orchestration-v2/SubagentObservability.ts+13−0
@@ -0,0 +1,13 @@
1import {
2 SubagentActivationId,
3 type NodeId,
4 type OrchestrationV2SubagentRole,
5} from "@t3tools/contracts";
6
7export const defaultSubagentRole = (name = "general-purpose"): OrchestrationV2SubagentRole => ({
8 name,
9 source: "app_default",
10});
11
12export const subagentActivationId = (subagentId: NodeId, ordinal: number) =>
13 SubagentActivationId.make(`${subagentId}:activation:${ordinal}`);
apps/server/src/orchestration-v2/ThreadForkService.test.ts+1−0
@@ -93,6 +93,7 @@ it.effect("keeps a fork awake when its source thread is snoozed", () => · 92 unmodified lines
93 attempts: [],93 attempts: [],
94 nodes: [],94 nodes: [],
95 subagents: [],95 subagents: [],
96 subagentActivations: [],
96 providerSessions: [],97 providerSessions: [],
97 providerThreads: [],98 providerThreads: [],
98 providerTurns: [],99 providerTurns: [],
apps/server/src/persistence/Migrations.ts+2−0
@@ -57,6 +57,7 @@ import Migration0041 from "./Migrations/041_ApplicationEventSource.ts"; · 56 unmodified lines
57import Migration0042 from "./Migrations/042_OrchestrationV2EffectCancellation.ts";57import Migration0042 from "./Migrations/042_OrchestrationV2EffectCancellation.ts";
58import Migration0043 from "./Migrations/043_ScheduledTasks.ts";58import Migration0043 from "./Migrations/043_ScheduledTasks.ts";
59import Migration0044 from "./Migrations/044_LegacyV1ImportState.ts";59import Migration0044 from "./Migrations/044_LegacyV1ImportState.ts";
60import Migration0045 from "./Migrations/045_OrchestrationV2SubagentObservability.ts";
60 61
61/**62/**
62 * Migration loader with all migrations defined inline.63 * Migration loader with all migrations defined inline.
@@ -113,6 +114,7 @@ export const migrationEntries = [ · 50 unmodified lines
113 [42, "OrchestrationV2EffectCancellation", Migration0042],114 [42, "OrchestrationV2EffectCancellation", Migration0042],
114 [43, "ScheduledTasks", Migration0043],115 [43, "ScheduledTasks", Migration0043],
115 [44, "LegacyV1ImportState", Migration0044],116 [44, "LegacyV1ImportState", Migration0044],
117 [45, "OrchestrationV2SubagentObservability", Migration0045],
116] as const;118] as const;
117 119
118export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);120export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
apps/server/src/persistence/Migrations/036_037_OrchestrationV2.test.ts+2−1
@@ -190,7 +190,7 @@ it.effect("upgrades a database already at released main migration 034", () => · 189 unmodified lines
190 }>`190 }>`
191 SELECT migration_id, name191 SELECT migration_id, name
192 FROM effect_sql_migrations192 FROM effect_sql_migrations
193 WHERE migration_id BETWEEN 34 AND 44193 WHERE migration_id BETWEEN 34 AND 45
194 ORDER BY migration_id194 ORDER BY migration_id
195 `;195 `;
196 assert.deepStrictEqual(196 assert.deepStrictEqual(
@@ -207,6 +207,7 @@ it.effect("upgrades a database already at released main migration 034", () => · 10 unmodified lines
207 [42, "OrchestrationV2EffectCancellation"],207 [42, "OrchestrationV2EffectCancellation"],
208 [43, "ScheduledTasks"],208 [43, "ScheduledTasks"],
209 [44, "LegacyV1ImportState"],209 [44, "LegacyV1ImportState"],
210 [45, "OrchestrationV2SubagentObservability"],
210 ],211 ],
211 );212 );
212 213
apps/server/src/persistence/Migrations/045_OrchestrationV2SubagentObservability.test.ts+222−0
@@ -0,0 +1,222 @@
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as SqlClient from "effect/unstable/sql/SqlClient";
5
6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "../NodeSqliteClient.ts";
8
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
10
11layer("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});
apps/server/src/persistence/Migrations/045_OrchestrationV2SubagentObservability.ts+50−0
@@ -0,0 +1,50 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export 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});
apps/web/src/test-fixtures.ts+1−0
@@ -51,6 +51,7 @@ export function makeThreadProjectionFixture(): OrchestrationV2ThreadProjection { · 50 unmodified lines
51 attempts: [],51 attempts: [],
52 nodes: [],52 nodes: [],
53 subagents: [],53 subagents: [],
54 subagentActivations: [],
54 providerSessions: [],55 providerSessions: [],
55 providerThreads: [],56 providerThreads: [],
56 providerTurns: [],57 providerTurns: [],
packages/client-runtime/src/state/orchestrationV2Projection.test.ts+1−0
@@ -86,6 +86,7 @@ const emptyProjection = { · 85 unmodified lines
86 attempts: [],86 attempts: [],
87 nodes: [],87 nodes: [],
88 subagents: [],88 subagents: [],
89 subagentActivations: [],
89 providerSessions: [],90 providerSessions: [],
90 providerThreads: [],91 providerThreads: [],
91 providerTurns: [],92 providerTurns: [],
packages/client-runtime/src/state/orchestrationV2Projection.ts+5−0
@@ -136,6 +136,11 @@ export function applyOrchestrationV2ProjectionEvent( · 135 unmodified lines
136 return { ...base, nodes: upsertEntity(base.nodes, event.payload) };136 return { ...base, nodes: upsertEntity(base.nodes, event.payload) };
137 case "subagent.updated":137 case "subagent.updated":
138 return { ...base, subagents: upsertEntity(base.subagents, event.payload) };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 case "provider-session.attached":144 case "provider-session.attached":
140 case "provider-session.updated":145 case "provider-session.updated":
141 return {146 return {
packages/client-runtime/src/state/orchestrationV2TestFixtures.ts+1−0
@@ -86,6 +86,7 @@ export const v2Projection: OrchestrationV2ThreadProjection = { · 85 unmodified lines
86 attempts: [],86 attempts: [],
87 nodes: [],87 nodes: [],
88 subagents: [],88 subagents: [],
89 subagentActivations: [],
89 providerSessions: [],90 providerSessions: [],
90 providerThreads: [],91 providerThreads: [],
91 providerTurns: [],92 providerTurns: [],
packages/contracts/src/baseSchemas.ts+2−0
@@ -72,6 +72,8 @@ export const RunAttemptId = makeEntityId("RunAttemptId"); · 71 unmodified lines
72export type RunAttemptId = typeof RunAttemptId.Type;72export type RunAttemptId = typeof RunAttemptId.Type;
73export const NodeId = makeEntityId("NodeId");73export const NodeId = makeEntityId("NodeId");
74export type NodeId = typeof NodeId.Type;74export type NodeId = typeof NodeId.Type;
75export const SubagentActivationId = makeEntityId("SubagentActivationId");
76export type SubagentActivationId = typeof SubagentActivationId.Type;
75export const AuthSessionId = makeEntityId("AuthSessionId");77export const AuthSessionId = makeEntityId("AuthSessionId");
76export type AuthSessionId = typeof AuthSessionId.Type;78export type AuthSessionId = typeof AuthSessionId.Type;
77export const RpcClientId = NonNegativeInt.pipe(Schema.brand("RpcClientId"));79export const RpcClientId = NonNegativeInt.pipe(Schema.brand("RpcClientId"));
packages/contracts/src/orchestrationV2.test.ts+156−1
@@ -24,10 +24,15 @@ import { · 23 unmodified lines
24 OrchestrationV2CheckpointScope,24 OrchestrationV2CheckpointScope,
25 OrchestrationV2Command,25 OrchestrationV2Command,
26 OrchestrationV2DomainEvent,26 OrchestrationV2DomainEvent,
27 OrchestrationV2DomainEventJson,
27 OrchestrationV2ShellSnapshot,28 OrchestrationV2ShellSnapshot,
28 OrchestrationV2Subagent,29 OrchestrationV2Subagent,
30 OrchestrationV2SubagentActivation,
29 OrchestrationV2ThreadProjection,31 OrchestrationV2ThreadProjection,
30 OrchestrationV2TurnItem,32 OrchestrationV2TurnItem,
33 OrchestrationV2TurnItemJson,
34 OrchestrationV2TurnItemStatus,
35 orchestrationV2SubagentStatusAsTurnItemStatus,
31} from "./orchestrationV2.ts";36} from "./orchestrationV2.ts";
32 37
33const now = DateTime.makeUnsafe("2026-04-20T00:00:00.000Z");38const now = DateTime.makeUnsafe("2026-04-20T00:00:00.000Z");
@@ -410,13 +415,89 @@ describe("orchestration V2 contracts", () => { · 376 unmodified lines
410 prompt: "Inspect package.json",415 prompt: "Inspect package.json",
411 title: "Package audit",416 title: "Package audit",
412 model: "gpt-5.4",417 model: "gpt-5.4",
413 status: "completed",418 kind: "subagent",
419 role: { name: "reviewer", source: "provider" },
420 status: "idle",
414 progress: "Inspecting package metadata",421 progress: "Inspecting package metadata",
415 result: "Package is private.",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 startedAt: now,447 startedAt: now,
417 completedAt: now,448 completedAt: now,
418 updatedAt: now,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 const turnItem = Schema.decodeUnknownSync(OrchestrationV2TurnItem)({501 const turnItem = Schema.decodeUnknownSync(OrchestrationV2TurnItem)({
421 id: "turn-item-subagent-1",502 id: "turn-item-subagent-1",
422 type: "subagent",503 type: "subagent",
@@ -446,6 +527,24 @@ describe("orchestration V2 contracts", () => { · 23 unmodified lines
446 expect(subagent.origin).toBe("provider_native");527 expect(subagent.origin).toBe("provider_native");
447 expect(subagent.progress).toBe("Inspecting package metadata");528 expect(subagent.progress).toBe("Inspecting package metadata");
448 expect(subagent.childThreadId).toBeNull();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 expect(turnItem.type).toBe("subagent");548 expect(turnItem.type).toBe("subagent");
450 if (turnItem.type !== "subagent") throw new Error("expected subagent item");549 if (turnItem.type !== "subagent") throw new Error("expected subagent item");
451 expect(turnItem.progress).toBe("Inspecting package metadata");550 expect(turnItem.progress).toBe("Inspecting package metadata");
@@ -514,6 +613,7 @@ describe("orchestration V2 contracts", () => { · 62 unmodified lines
514 attempts: [],613 attempts: [],
515 nodes: [],614 nodes: [],
516 subagents: [],615 subagents: [],
616 subagentActivations: [],
517 providerSessions: [],617 providerSessions: [],
518 providerThreads: [],618 providerThreads: [],
519 providerTurns: [],619 providerTurns: [],
@@ -654,6 +754,61 @@ describe("orchestration V2 contracts", () => { · 134 unmodified lines
654 expect(fork.type).toBe("fork");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 it("exports the V2 branded ids through the public contracts entrypoint", () => {812 it("exports the V2 branded ids through the public contracts entrypoint", () => {
658 expect(ThreadId.make("thread-1")).toBe("thread-1");813 expect(ThreadId.make("thread-1")).toBe("thread-1");
659 expect(ProjectId.make("project-1")).toBe("project-1");814 expect(ProjectId.make("project-1")).toBe("project-1");
packages/contracts/src/orchestrationV2.ts+143−0
@@ -23,6 +23,7 @@ import { · 22 unmodified lines
23 RunAttemptId,23 RunAttemptId,
24 RunId,24 RunId,
25 RuntimeRequestId,25 RuntimeRequestId,
26 SubagentActivationId,
26 ThreadId,27 ThreadId,
27 TrimmedNonEmptyString,28 TrimmedNonEmptyString,
28 TurnItemId,29 TurnItemId,
@@ -443,6 +444,35 @@ export const OrchestrationV2ExecutionNode = Schema.Struct({ · 414 unmodified lines
443});444});
444export type OrchestrationV2ExecutionNode = typeof OrchestrationV2ExecutionNode.Type;445export type OrchestrationV2ExecutionNode = typeof OrchestrationV2ExecutionNode.Type;
445 446
447export 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});
456export type OrchestrationV2SubagentUsage = typeof OrchestrationV2SubagentUsage.Type;
457
458export const OrchestrationV2SubagentRole = Schema.Struct({
459 name: TrimmedNonEmptyString,
460 source: Schema.Literals(["provider", "app_default"]),
461});
462export type OrchestrationV2SubagentRole = typeof OrchestrationV2SubagentRole.Type;
463
464export const OrchestrationV2SubagentActivity = Schema.Struct({
465 at: Schema.DateTimeUtc,
466 summary: TrimmedNonEmptyString,
467});
468export type OrchestrationV2SubagentActivity = typeof OrchestrationV2SubagentActivity.Type;
469
470export const OrchestrationV2WorkflowPhase = Schema.Struct({
471 index: NonNegativeInt,
472 title: TrimmedNonEmptyString,
473});
474export type OrchestrationV2WorkflowPhase = typeof OrchestrationV2WorkflowPhase.Type;
475
446export const OrchestrationV2Subagent = Schema.Struct({476export const OrchestrationV2Subagent = Schema.Struct({
447 id: NodeId,477 id: NodeId,
448 threadId: ThreadId,478 threadId: ThreadId,
@@ -464,10 +494,19 @@ export const OrchestrationV2Subagent = Schema.Struct({ · 15 unmodified lines
464 // live run (wait-mode delegations, whose result returns through the494 // live run (wait-mode delegations, whose result returns through the
465 // blocking tool call). Absent on legacy records; treated as settled_only.495 // blocking tool call). Absent on legacy records; treated as settled_only.
466 completionWake: Schema.optional(Schema.Literals(["always", "settled_only"])),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 status: Schema.Literals([505 status: Schema.Literals([
468 "pending",506 "pending",
469 "running",507 "running",
470 "waiting",508 "waiting",
509 "idle",
471 "completed",510 "completed",
472 "failed",511 "failed",
473 "cancelled",512 "cancelled",
@@ -475,12 +514,58 @@ export const OrchestrationV2Subagent = Schema.Struct({ · 1 unmodified line
475 ]),514 ]),
476 progress: Schema.optional(Schema.String),515 progress: Schema.optional(Schema.String),
477 result: Schema.NullOr(Schema.String),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 startedAt: Schema.NullOr(Schema.DateTimeUtc),540 startedAt: Schema.NullOr(Schema.DateTimeUtc),
479 completedAt: Schema.NullOr(Schema.DateTimeUtc),541 completedAt: Schema.NullOr(Schema.DateTimeUtc),
480 updatedAt: Schema.DateTimeUtc,542 updatedAt: Schema.DateTimeUtc,
481});543});
482export type OrchestrationV2Subagent = typeof OrchestrationV2Subagent.Type;544export type OrchestrationV2Subagent = typeof OrchestrationV2Subagent.Type;
483 545
546export 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});
567export type OrchestrationV2SubagentActivation = typeof OrchestrationV2SubagentActivation.Type;
568
484export const OrchestrationV2CheckpointScope = Schema.Struct({569export const OrchestrationV2CheckpointScope = Schema.Struct({
485 id: CheckpointScopeId,570 id: CheckpointScopeId,
486 threadId: ThreadId,571 threadId: ThreadId,
@@ -722,6 +807,33 @@ export const OrchestrationV2TurnItemStatus = Schema.Literals([ · 235 unmodified lines
722]);807]);
723export type OrchestrationV2TurnItemStatus = typeof OrchestrationV2TurnItemStatus.Type;808export 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 */
822export 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
725export const OrchestrationV2ProviderFailureClass = Schema.Literals([837export const OrchestrationV2ProviderFailureClass = Schema.Literals([
726 "provider_error",838 "provider_error",
727 "transport_error",839 "transport_error",
@@ -1074,6 +1186,11 @@ export const OrchestrationV2DomainEvent = Schema.Union([ · 346 unmodified lines
1074 type: Schema.Literal("subagent.updated"),1186 type: Schema.Literal("subagent.updated"),
1075 payload: OrchestrationV2Subagent,1187 payload: OrchestrationV2Subagent,
1076 }),1188 }),
1189 Schema.Struct({
1190 ...OrchestrationV2EventBase.fields,
1191 type: Schema.Literal("subagent-activation.updated"),
1192 payload: OrchestrationV2SubagentActivation,
1193 }),
1077 Schema.Struct({1194 Schema.Struct({
1078 ...OrchestrationV2EventBase.fields,1195 ...OrchestrationV2EventBase.fields,
1079 type: Schema.Literals(["provider-session.attached", "provider-session.updated"]),1196 type: Schema.Literals(["provider-session.attached", "provider-session.updated"]),
@@ -1153,6 +1270,7 @@ export const OrchestrationV2ThreadProjection = Schema.Struct({ · 73 unmodified lines
1153 attempts: Schema.Array(OrchestrationV2RunAttempt),1270 attempts: Schema.Array(OrchestrationV2RunAttempt),
1154 nodes: Schema.Array(OrchestrationV2ExecutionNode),1271 nodes: Schema.Array(OrchestrationV2ExecutionNode),
1155 subagents: Schema.Array(OrchestrationV2Subagent),1272 subagents: Schema.Array(OrchestrationV2Subagent),
1273 subagentActivations: Schema.Array(OrchestrationV2SubagentActivation),
1156 providerSessions: Schema.Array(OrchestrationV2ProviderSession),1274 providerSessions: Schema.Array(OrchestrationV2ProviderSession),
1157 providerThreads: Schema.Array(OrchestrationV2ProviderThread),1275 providerThreads: Schema.Array(OrchestrationV2ProviderThread),
1158 providerTurns: Schema.Array(OrchestrationV2ProviderTurn),1276 providerTurns: Schema.Array(OrchestrationV2ProviderTurn),
@@ -1356,12 +1474,29 @@ export type OrchestrationV2ExecutionNodeJson = typeof OrchestrationV2ExecutionNo · 197 unmodified lines
1356 1474
1357export const OrchestrationV2SubagentJson = OrchestrationV2Subagent.mapFields((fields) => ({1475export const OrchestrationV2SubagentJson = OrchestrationV2Subagent.mapFields((fields) => ({
1358 ...fields,1476 ...fields,
1477 recentActivity: Schema.Array(
1478 OrchestrationV2SubagentActivity.mapFields((activityFields) => ({
1479 ...activityFields,
1480 at: Schema.DateTimeUtcFromString,
1481 })),
1482 ).pipe(Schema.withDecodingDefault(Effect.succeed([]))),
1359 startedAt: Schema.NullOr(Schema.DateTimeUtcFromString),1483 startedAt: Schema.NullOr(Schema.DateTimeUtcFromString),
1360 completedAt: Schema.NullOr(Schema.DateTimeUtcFromString),1484 completedAt: Schema.NullOr(Schema.DateTimeUtcFromString),
1361 updatedAt: Schema.DateTimeUtcFromString,1485 updatedAt: Schema.DateTimeUtcFromString,
1362}));1486}));
1363export type OrchestrationV2SubagentJson = typeof OrchestrationV2SubagentJson.Type;1487export type OrchestrationV2SubagentJson = typeof OrchestrationV2SubagentJson.Type;
1364 1488
1489export 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);
1497export type OrchestrationV2SubagentActivationJson =
1498 typeof OrchestrationV2SubagentActivationJson.Type;
1499
1365export const OrchestrationV2CheckpointScopeJson = OrchestrationV2CheckpointScope.mapFields(1500export const OrchestrationV2CheckpointScopeJson = OrchestrationV2CheckpointScope.mapFields(
1366 (fields) => ({1501 (fields) => ({
1367 ...fields,1502 ...fields,
@@ -1650,6 +1785,9 @@ export const OrchestrationV2ThreadProjectionJson = OrchestrationV2ThreadProjecti · 282 unmodified lines
1650 attempts: Schema.Array(OrchestrationV2RunAttemptJson),1785 attempts: Schema.Array(OrchestrationV2RunAttemptJson),
1651 nodes: Schema.Array(OrchestrationV2ExecutionNodeJson),1786 nodes: Schema.Array(OrchestrationV2ExecutionNodeJson),
1652 subagents: Schema.Array(OrchestrationV2SubagentJson),1787 subagents: Schema.Array(OrchestrationV2SubagentJson),
1788 subagentActivations: Schema.Array(OrchestrationV2SubagentActivationJson).pipe(
1789 Schema.withDecodingDefault(Effect.succeed([])),
1790 ),
1653 providerSessions: Schema.Array(OrchestrationV2ProviderSessionJson),1791 providerSessions: Schema.Array(OrchestrationV2ProviderSessionJson),
1654 providerThreads: Schema.Array(OrchestrationV2ProviderThreadJson),1792 providerThreads: Schema.Array(OrchestrationV2ProviderThreadJson),
1655 providerTurns: Schema.Array(OrchestrationV2ProviderTurnJson),1793 providerTurns: Schema.Array(OrchestrationV2ProviderTurnJson),
@@ -1780,6 +1918,11 @@ export const OrchestrationV2DomainEventJson = Schema.Union([ · 124 unmodified lines
1780 type: Schema.Literal("subagent.updated"),1918 type: Schema.Literal("subagent.updated"),
1781 payload: OrchestrationV2SubagentJson,1919 payload: OrchestrationV2SubagentJson,
1782 }),1920 }),
1921 Schema.Struct({
1922 ...OrchestrationV2JsonEventBaseFields,
1923 type: Schema.Literal("subagent-activation.updated"),
1924 payload: OrchestrationV2SubagentActivationJson,
1925 }),
1783 Schema.Struct({1926 Schema.Struct({
1784 ...OrchestrationV2JsonEventBaseFields,1927 ...OrchestrationV2JsonEventBaseFields,
1785 type: Schema.Literals(["provider-session.attached", "provider-session.updated"]),1928 type: Schema.Literals(["provider-session.attached", "provider-session.updated"]),