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
20852085 prompt: update.prompt,
20862086 title: update.title,
20872087 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: [],
20882096 result: null,
20892097 startedAt: now,
20902098 }),
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+8−0
@@ -2343,6 +2343,14 @@ export function makeClaudeAdapterV2( · 2342 unmodified lines
23432343 prompt: input.prompt ?? "",
23442344 title: input.title ?? null,
23452345 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: [],
23462354 result: null,
23472355 startedAt: now,
23482356 }),
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+16−2
@@ -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";
27 import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
38 import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
49 import { resolveSpawnCommand } from "@t3tools/shared/shell";
@@ -100,6 +105,7 @@ import { · 95 unmodified lines
100105 makeSubagentConversationArtifacts,
101106 subagentThreadTitle,
102107 } from "../SubagentProjection.ts";
108+import { defaultSubagentRole } from "../SubagentObservability.ts";
103109
104110 const CODEX_PROVIDER = ProviderDriverKind.make("codex");
105111 export const CODEX_DRIVER_KIND = CODEX_PROVIDER;
@@ -1774,7 +1780,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi · 1668 unmodified lines
17741780 nativeItemRef: task.nativeTaskRef,
17751781 parentItemId: null,
17761782 ordinal: input.subagent.turnItemOrdinal,
1777− status: task.status,
1783+ status: orchestrationV2SubagentStatusAsTurnItemStatus[task.status],
17781784 title: task.title,
17791785 startedAt: task.startedAt,
17801786 completedAt: task.completedAt,
@@ -2021,8 +2027,16 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi · 240 unmodified lines
20212027 prompt: input.prompt,
20222028 title: input.title,
20232029 model: input.model,
2030+ kind: "subagent",
2031+ role: defaultSubagentRole(),
20242032 status: "running",
20252033 result: null,
2034+ usage: null,
2035+ currentActivationId: null,
2036+ activationCount: 1,
2037+ workflow: null,
2038+ workflowMembership: null,
2039+ recentActivity: [],
20262040 startedAt: now,
20272041 completedAt: null,
20282042 updatedAt: now,
apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts+8−0
@@ -1494,6 +1494,14 @@ export function makeCursorAdapterV2( · 1493 unmodified lines
14941494 prompt: args.prompt,
14951495 title: args.description,
14961496 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: [],
14971505 result: null,
14981506 startedAt: now,
14991507 }),
apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts+8−0
@@ -1215,6 +1215,14 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid · 1214 unmodified lines
12151215 prompt,
12161216 title,
12171217 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: [],
12181226 status: subagentStatus,
12191227 result: context.result,
12201228 startedAt: context.startedAt,
apps/server/src/orchestration-v2/FoundationPersistence.test.ts+80−0
@@ -19,6 +19,7 @@ import { · 18 unmodified lines
1919 ProviderThreadId,
2020 RunAttemptId,
2121 RunId,
22+ SubagentActivationId,
2223 ThreadId,
2324 TurnItemId,
2425 } from "@t3tools/contracts";
@@ -409,6 +410,7 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { · 384 unmodified lines
409410 const eventSink = yield* EventSinkV2;
410411 const projectionStore = yield* ProjectionStoreV2;
411412 const maintenance = yield* ProjectionMaintenanceV2;
413+ const sql = yield* SqlClient.SqlClient;
412414 const now = yield* DateTime.now;
413415 const parentThreadId = ThreadId.make("thread:foundation-cross-thread:parent");
414416 const childThreadId = ThreadId.make("thread:foundation-cross-thread:child");
@@ -416,6 +418,9 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { · 1 unmodified line
416418 "provider-thread:foundation-cross-thread:child",
417419 );
418420 const subagentId = NodeId.make("subagent:foundation-cross-thread");
421+ const activationId = SubagentActivationId.make(
422+ "subagent:foundation-cross-thread:activation:1",
423+ );
419424 const spawnTransferId = ContextTransferId.make("transfer:foundation-cross-thread:spawn");
420425 const resultTransferId = ContextTransferId.make("transfer:foundation-cross-thread:result");
421426 const parentThread = makeThread(parentThreadId, now);
@@ -464,8 +469,37 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { · 42 unmodified lines
464469 prompt: "Inspect the child flow",
465470 title: "Cross-thread child",
466471 model: modelSelection.model,
472+ kind: "subagent",
473+ role: { name: "general-purpose", source: "app_default" },
467474 status: "completed",
468475 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 },
469503 startedAt: now,
470504 completedAt: now,
471505 updatedAt: now,
@@ -566,11 +600,57 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { · 94 unmodified lines
566600 [childProviderThreadId],
567601 );
568602 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+ );
569615 });
570616
571617 yield* assertCrossThreadProjection;
572618 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+ `;
573647 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);
574654 yield* assertCrossThreadProjection;
575655 }),
576656 );
apps/server/src/orchestration-v2/Orchestrator.ts+89−1
@@ -19,6 +19,7 @@ import { · 18 unmodified lines
1919 type OrchestrationV2ThreadShellSnapshot,
2020 type OrchestrationV2StoredEvent,
2121 type OrchestrationV2Subagent,
22+ type OrchestrationV2SubagentActivation,
2223 type OrchestrationV2ThreadProjection,
2324 type OrchestrationV2TurnItem,
2425 ProviderInstanceId,
@@ -63,6 +64,7 @@ import { · 38 unmodified lines
6364 subagentResultForRun,
6465 subagentThreadTitle,
6566 } from "./SubagentProjection.ts";
67+import { defaultSubagentRole, subagentActivationId } from "./SubagentObservability.ts";
6668 import { ThreadForkServiceV2 } from "./ThreadForkService.ts";
6769
6870 export class OrchestratorDispatchError extends Schema.TaggedErrorClass<OrchestratorDispatchError>()(
@@ -294,7 +296,10 @@ function isTerminalDelegatedTaskStatus(status: OrchestrationV2Subagent["status"] · 225 unmodified lines
294296
295297 function delegatedTaskTerminalStatus(
296298 status: OrchestrationV2Run["status"],
297−): OrchestrationV2Subagent["status"] | null {
299+): Extract<
300+ OrchestrationV2Subagent["status"],
301+ "completed" | "failed" | "cancelled" | "interrupted"
302+> | null {
298303 switch (status) {
299304 case "completed":
300305 case "failed":
@@ -312,6 +317,12 @@ function delegatedTaskTerminalStatus( · 11 unmodified lines
312317 }
313318 }
314319
320+const isTerminalSubagentActivation = (activation: OrchestrationV2SubagentActivation) =>
321+ activation.status === "completed" ||
322+ activation.status === "failed" ||
323+ activation.status === "cancelled" ||
324+ activation.status === "interrupted";
325+
315326 function nextQueuedRun(
316327 projection: OrchestrationV2ThreadProjection,
317328 ): OrchestrationV2Run | undefined {
@@ -3941,6 +3952,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 3623 unmodified lines
39413952 runtimeMode: command.runtimeMode,
39423953 interactionMode: command.interactionMode,
39433954 };
3955+ const activationId = subagentActivationId(taskNodeId, 1);
39443956 const task: OrchestrationV2Subagent = {
39453957 id: taskNodeId,
39463958 threadId: command.parentThreadId,
@@ -3957,8 +3969,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 10 unmodified lines
39573969 title: command.title ?? null,
39583970 model: command.modelSelection.model,
39593971 ...(command.completionWake === undefined ? {} : { completionWake: command.completionWake }),
3972+ kind: "subagent",
3973+ role: defaultSubagentRole("delegated-worker"),
39603974 status: "running",
39613975 result: null,
3976+ usage: null,
3977+ currentActivationId: activationId,
3978+ activationCount: 1,
3979+ workflow: null,
3980+ workflowMembership: null,
3981+ recentActivity: [],
39623982 startedAt: now,
39633983 completedAt: null,
39643984 updatedAt: now,
@@ -3981,6 +4001,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 16 unmodified lines
39814001 completedAt: null,
39824002 };
39834003 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;
39844017 const taskTurnItem: OrchestrationV2TurnItem = {
39854018 id: taskTurnItemId,
39864019 threadId: command.parentThreadId,
@@ -4035,6 +4068,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 48 unmodified lines
40354068 occurredAt: now,
40364069 payload: task,
40374070 });
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+ });
40384081 yield* emitEvent({
40394082 type: "turn-item.updated",
40404083 threadId: command.parentThreadId,
@@ -5475,9 +5518,41 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 1434 unmodified lines
54755518 providerThreadId: childRun.providerThreadId,
54765519 status: terminalStatus,
54775520 result: result.text,
5521+ currentActivationId: null,
54785522 completedAt: now,
54795523 updatedAt: now,
54805524 };
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);
54815556 const resultTransferId = yield* idAllocator.allocate.contextTransfer({
54825557 sourceThreadId: childThreadId,
54835558 targetThreadId: parentThreadId,
@@ -5562,6 +5637,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 78 unmodified lines
55625637 occurredAt: now,
55635638 payload: updatedTask,
55645639 },
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+ ]),
55655653 ...(parentNode === undefined
55665654 ? []
55675655 : [
apps/server/src/orchestration-v2/ProjectionMaintenance.ts+1−0
@@ -160,6 +160,7 @@ export const layer: Layer.Layer< · 159 unmodified lines
160160 yield* sql`DELETE FROM orchestration_v2_projection_provider_threads`;
161161 yield* sql`DELETE FROM orchestration_v2_projection_provider_session_bindings`;
162162 yield* sql`DELETE FROM orchestration_v2_projection_provider_sessions`;
163+ yield* sql`DELETE FROM orchestration_v2_projection_subagent_activations`;
163164 yield* sql`DELETE FROM orchestration_v2_projection_subagents`;
164165 yield* sql`DELETE FROM orchestration_v2_projection_nodes`;
165166 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
2525 OrchestrationV2RunJson as OrchestrationV2RunJsonSchema,
2626 OrchestrationV2RuntimeRequestJson as OrchestrationV2RuntimeRequestJsonSchema,
2727 OrchestrationV2SubagentJson as OrchestrationV2SubagentJsonSchema,
28+ OrchestrationV2SubagentActivationJson as OrchestrationV2SubagentActivationJsonSchema,
2829 OrchestrationV2TurnItemJson as OrchestrationV2TurnItemJsonSchema,
2930 RunId,
3031 ThreadId,
@@ -124,7 +125,7 @@ export class ProjectionStoreV2 extends Context.Service<ProjectionStoreV2, Projec · 93 unmodified lines
124125 "t3/orchestration-v2/ProjectionStore/ProjectionStoreV2",
125126 ) {}
126127
127−export const ORCHESTRATION_V2_PROJECTION_SCHEMA_VERSION = 2;
128+export const ORCHESTRATION_V2_PROJECTION_SCHEMA_VERSION = 3;
128129
129130 function upsertById<T extends { readonly id: string }>(items: ReadonlyArray<T>, next: T): Array<T> {
130131 const index = items.findIndex((item) => item.id === next.id);
@@ -146,6 +147,7 @@ export function emptyProjection( · 15 unmodified lines
146147 attempts: [],
147148 nodes: [],
148149 subagents: [],
150+ subagentActivations: [],
149151 providerSessions: [],
150152 providerThreads: [],
151153 providerTurns: [],
@@ -223,6 +225,11 @@ export function applyToProjection( · 71 unmodified lines
223225 ...base,
224226 subagents: upsertById(base.subagents, event.payload),
225227 };
228+ case "subagent-activation.updated":
229+ return {
230+ ...base,
231+ subagentActivations: upsertById(base.subagentActivations, event.payload),
232+ };
226233 case "provider-session.attached":
227234 case "provider-session.updated":
228235 return {
@@ -439,6 +446,9 @@ const encodeNodePayload = Schema.encodeEffect( · 210 unmodified lines
439446 const encodeSubagentPayload = Schema.encodeEffect(
440447 Schema.fromJsonString(OrchestrationV2SubagentJsonSchema),
441448 );
449+const encodeSubagentActivationPayload = Schema.encodeEffect(
450+ Schema.fromJsonString(OrchestrationV2SubagentActivationJsonSchema),
451+);
442452 const encodeProviderSessionPayload = Schema.encodeEffect(
443453 Schema.fromJsonString(OrchestrationV2ProviderSessionJsonSchema),
444454 );
@@ -483,6 +493,10 @@ const decodeNodePayload = (json: string) => · 38 unmodified lines
483493 Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2ExecutionNodeJsonSchema))(json);
484494 const decodeSubagentPayload = (json: string) =>
485495 Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2SubagentJsonSchema))(json);
496+const decodeSubagentActivationPayload = (json: string) =>
497+ Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2SubagentActivationJsonSchema))(
498+ json,
499+ );
486500 const decodeProviderSessionPayload = (json: string) =>
487501 Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2ProviderSessionJsonSchema))(json);
488502 const decodeProviderThreadPayload = (json: string) =>
@@ -1293,6 +1307,51 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 804 unmodified lines
12931307 `;
12941308 break;
12951309 }
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+ }
12961355 case "provider-session.attached":
12971356 case "provider-session.updated": {
12981357 const payloadJson = yield* encodeProviderSessionPayload(event.payload);
@@ -1869,6 +1928,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 570 unmodified lines
18691928 attemptRows,
18701929 nodeRows,
18711930 subagentRows,
1931+ subagentActivationRows,
18721932 providerSessionRows,
18731933 providerThreadRows,
18741934 providerTurnRows,
@@ -1906,6 +1966,12 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 31 unmodified lines
19061966 WHERE thread_id = ${threadId}
19071967 ORDER BY COALESCE(started_at, ''), subagent_id ASC
19081968 `,
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+ `,
19091975 sql<PayloadRow>`
19101976 SELECT sessions.payload_json
19111977 FROM orchestration_v2_projection_provider_sessions AS sessions
@@ -1992,6 +2058,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 80 unmodified lines
19922058 attempts,
19932059 nodes,
19942060 subagents,
2061+ subagentActivations,
19952062 providerSessions,
19962063 providerThreads,
19972064 providerTurns,
@@ -2008,6 +2075,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 10 unmodified lines
20082075 decodeRows(decodeRunAttemptPayload, threadId)(attemptRows),
20092076 decodeRows(decodeNodePayload, threadId)(nodeRows),
20102077 decodeRows(decodeSubagentPayload, threadId)(subagentRows),
2078+ decodeRows(decodeSubagentActivationPayload, threadId)(subagentActivationRows),
20112079 decodeRows(decodeProviderSessionPayload, threadId)(providerSessionRows),
20122080 decodeRows(decodeProviderThreadPayload, threadId)(providerThreadRows),
20132081 decodeRows(decodeProviderTurnPayload, threadId)(providerTurnRows),
@@ -2027,6 +2095,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 13 unmodified lines
20272095 attempts,
20282096 nodes,
20292097 subagents,
2098+ subagentActivations,
20302099 providerSessions,
20312100 providerThreads,
20322101 providerTurns,
apps/server/src/orchestration-v2/ProviderAdapter.ts+7−0
@@ -16,6 +16,7 @@ import { · 15 unmodified lines
1616 OrchestrationV2ProviderTurn,
1717 OrchestrationV2RuntimeRequest,
1818 OrchestrationV2Subagent,
19+ OrchestrationV2SubagentActivation,
1920 OrchestrationV2TurnItem,
2021 ProviderApprovalDecision,
2122 ProviderInteractionMode,
@@ -31,6 +32,7 @@ import { · 9 unmodified lines
3132 RunAttemptId,
3233 RunId,
3334 ThreadId,
35+ TurnItemId,
3436 } from "@t3tools/contracts";
3537 import * as Context from "effect/Context";
3638 import * as Schema from "effect/Schema";
@@ -104,6 +106,11 @@ export const ProviderAdapterV2Event = Schema.Union([ · 67 unmodified lines
104106 driver: ProviderDriverKind,
105107 subagent: OrchestrationV2Subagent,
106108 }),
109+ Schema.Struct({
110+ type: Schema.Literal("subagent_activation.updated"),
111+ driver: ProviderDriverKind,
112+ activation: OrchestrationV2SubagentActivation,
113+ }),
107114 Schema.Struct({
108115 type: Schema.Literal("message.updated"),
109116 driver: ProviderDriverKind,
apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts+81−0
@@ -8,6 +8,8 @@ import { · 7 unmodified lines
88 type OrchestrationV2ProviderThread,
99 ProviderDriverKind,
1010 ProviderInstanceId,
11+ RunId,
12+ SubagentActivationId,
1113 } from "@t3tools/contracts";
1214 import * as DateTime from "effect/DateTime";
1315 import * as Effect from "effect/Effect";
@@ -383,4 +385,83 @@ layer("ProviderEventIngestorV2", (it) => { · 369 unmodified lines
383385 assert.equal(messageEvents[0]?.threadId, childThreadId);
384386 }),
385387 );
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+ );
386467 });
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
125125 id: eventId,
126126 type: payloadInput.type,
127127 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,
130130 driver: input.event.driver,
131131 providerInstanceId: input.providerInstanceId,
132132 rawEventId: input.rawEventId,
@@ -191,6 +191,16 @@ export const layer: Layer.Layer<ProviderEventIngestorV2, never, EventSinkV2 | Id · 58 unmodified lines
191191 nodeId: input.event.subagent.id,
192192 }),
193193 ];
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+ ];
194204 case "message.updated":
195205 return [
196206 yield* makeDomainEvent(input, {
apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts+1−0
@@ -93,6 +93,7 @@ function makeProjection(input: { · 92 unmodified lines
9393 ],
9494 nodes: [],
9595 subagents: [],
96+ subagentActivations: [],
9697 providerSessions: [],
9798 providerThreads: [input.providerThread],
9899 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
13111311 prompt: "hold",
13121312 title: "hold",
13131313 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: [],
13141322 status: "running",
13151323 progress: "partial progress",
13161324 result: "partial result",
@@ -1865,6 +1873,14 @@ function makeRunOwnedSubagentFixture(input: { · 548 unmodified lines
18651873 prompt: "hold",
18661874 title: "Live-test subagent hold",
18671875 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: [],
18681884 status: input.status,
18691885 result: null,
18701886 startedAt: now,
apps/server/src/orchestration-v2/RunExecutionService.ts+2−0
@@ -361,6 +361,8 @@ export function routeProviderEvent( · 360 unmodified lines
361361 }
362362 case "subagent.updated":
363363 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];
364366 case "message.updated":
365367 return [ownsRun(event.message.runId) || ownsChildThread(event.message.threadId), state];
366368 case "turn_item.updated":
apps/server/src/orchestration-v2/SubagentObservability.test.ts+23−0
@@ -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+});
apps/server/src/orchestration-v2/SubagentObservability.ts+13−0
@@ -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}`);
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
9393 attempts: [],
9494 nodes: [],
9595 subagents: [],
96+ subagentActivations: [],
9697 providerSessions: [],
9798 providerThreads: [],
9899 providerTurns: [],
apps/server/src/persistence/Migrations.ts+2−0
@@ -57,6 +57,7 @@ import Migration0041 from "./Migrations/041_ApplicationEventSource.ts"; · 56 unmodified lines
5757 import Migration0042 from "./Migrations/042_OrchestrationV2EffectCancellation.ts";
5858 import Migration0043 from "./Migrations/043_ScheduledTasks.ts";
5959 import Migration0044 from "./Migrations/044_LegacyV1ImportState.ts";
60+import Migration0045 from "./Migrations/045_OrchestrationV2SubagentObservability.ts";
6061
6162 /**
6263 * Migration loader with all migrations defined inline.
@@ -113,6 +114,7 @@ export const migrationEntries = [ · 50 unmodified lines
113114 [42, "OrchestrationV2EffectCancellation", Migration0042],
114115 [43, "ScheduledTasks", Migration0043],
115116 [44, "LegacyV1ImportState", Migration0044],
117+ [45, "OrchestrationV2SubagentObservability", Migration0045],
116118 ] as const;
117119
118120 export 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
190190 }>`
191191 SELECT migration_id, name
192192 FROM effect_sql_migrations
193− WHERE migration_id BETWEEN 34 AND 44
193+ WHERE migration_id BETWEEN 34 AND 45
194194 ORDER BY migration_id
195195 `;
196196 assert.deepStrictEqual(
@@ -207,6 +207,7 @@ it.effect("upgrades a database already at released main migration 034", () => · 10 unmodified lines
207207 [42, "OrchestrationV2EffectCancellation"],
208208 [43, "ScheduledTasks"],
209209 [44, "LegacyV1ImportState"],
210+ [45, "OrchestrationV2SubagentObservability"],
210211 ],
211212 );
212213
apps/server/src/persistence/Migrations/045_OrchestrationV2SubagentObservability.test.ts+222−0
@@ -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+});
apps/server/src/persistence/Migrations/045_OrchestrationV2SubagentObservability.ts+50−0
@@ -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+});
apps/web/src/test-fixtures.ts+1−0
@@ -51,6 +51,7 @@ export function makeThreadProjectionFixture(): OrchestrationV2ThreadProjection { · 50 unmodified lines
5151 attempts: [],
5252 nodes: [],
5353 subagents: [],
54+ subagentActivations: [],
5455 providerSessions: [],
5556 providerThreads: [],
5657 providerTurns: [],
packages/client-runtime/src/state/orchestrationV2Projection.test.ts+1−0
@@ -86,6 +86,7 @@ const emptyProjection = { · 85 unmodified lines
8686 attempts: [],
8787 nodes: [],
8888 subagents: [],
89+ subagentActivations: [],
8990 providerSessions: [],
9091 providerThreads: [],
9192 providerTurns: [],
packages/client-runtime/src/state/orchestrationV2Projection.ts+5−0
@@ -136,6 +136,11 @@ export function applyOrchestrationV2ProjectionEvent( · 135 unmodified lines
136136 return { ...base, nodes: upsertEntity(base.nodes, event.payload) };
137137 case "subagent.updated":
138138 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+ };
139144 case "provider-session.attached":
140145 case "provider-session.updated":
141146 return {
packages/client-runtime/src/state/orchestrationV2TestFixtures.ts+1−0
@@ -86,6 +86,7 @@ export const v2Projection: OrchestrationV2ThreadProjection = { · 85 unmodified lines
8686 attempts: [],
8787 nodes: [],
8888 subagents: [],
89+ subagentActivations: [],
8990 providerSessions: [],
9091 providerThreads: [],
9192 providerTurns: [],
packages/contracts/src/baseSchemas.ts+2−0
@@ -72,6 +72,8 @@ export const RunAttemptId = makeEntityId("RunAttemptId"); · 71 unmodified lines
7272 export type RunAttemptId = typeof RunAttemptId.Type;
7373 export const NodeId = makeEntityId("NodeId");
7474 export type NodeId = typeof NodeId.Type;
75+export const SubagentActivationId = makeEntityId("SubagentActivationId");
76+export type SubagentActivationId = typeof SubagentActivationId.Type;
7577 export const AuthSessionId = makeEntityId("AuthSessionId");
7678 export type AuthSessionId = typeof AuthSessionId.Type;
7779 export const RpcClientId = NonNegativeInt.pipe(Schema.brand("RpcClientId"));
packages/contracts/src/orchestrationV2.test.ts+156−1
@@ -24,10 +24,15 @@ import { · 23 unmodified lines
2424 OrchestrationV2CheckpointScope,
2525 OrchestrationV2Command,
2626 OrchestrationV2DomainEvent,
27+ OrchestrationV2DomainEventJson,
2728 OrchestrationV2ShellSnapshot,
2829 OrchestrationV2Subagent,
30+ OrchestrationV2SubagentActivation,
2931 OrchestrationV2ThreadProjection,
3032 OrchestrationV2TurnItem,
33+ OrchestrationV2TurnItemJson,
34+ OrchestrationV2TurnItemStatus,
35+ orchestrationV2SubagentStatusAsTurnItemStatus,
3136 } from "./orchestrationV2.ts";
3237
3338 const now = DateTime.makeUnsafe("2026-04-20T00:00:00.000Z");
@@ -410,13 +415,89 @@ describe("orchestration V2 contracts", () => { · 376 unmodified lines
410415 prompt: "Inspect package.json",
411416 title: "Package audit",
412417 model: "gpt-5.4",
413− status: "completed",
418+ kind: "subagent",
419+ role: { name: "reviewer", source: "provider" },
420+ status: "idle",
414421 progress: "Inspecting package metadata",
415422 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 },
416447 startedAt: now,
417448 completedAt: now,
418449 updatedAt: now,
419450 });
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+ });
420501 const turnItem = Schema.decodeUnknownSync(OrchestrationV2TurnItem)({
421502 id: "turn-item-subagent-1",
422503 type: "subagent",
@@ -446,6 +527,24 @@ describe("orchestration V2 contracts", () => { · 23 unmodified lines
446527 expect(subagent.origin).toBe("provider_native");
447528 expect(subagent.progress).toBe("Inspecting package metadata");
448529 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+ );
449548 expect(turnItem.type).toBe("subagent");
450549 if (turnItem.type !== "subagent") throw new Error("expected subagent item");
451550 expect(turnItem.progress).toBe("Inspecting package metadata");
@@ -514,6 +613,7 @@ describe("orchestration V2 contracts", () => { · 62 unmodified lines
514613 attempts: [],
515614 nodes: [],
516615 subagents: [],
616+ subagentActivations: [],
517617 providerSessions: [],
518618 providerThreads: [],
519619 providerTurns: [],
@@ -654,6 +754,61 @@ describe("orchestration V2 contracts", () => { · 134 unmodified lines
654754 expect(fork.type).toBe("fork");
655755 });
656756
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+
657812 it("exports the V2 branded ids through the public contracts entrypoint", () => {
658813 expect(ThreadId.make("thread-1")).toBe("thread-1");
659814 expect(ProjectId.make("project-1")).toBe("project-1");
packages/contracts/src/orchestrationV2.ts+143−0
@@ -23,6 +23,7 @@ import { · 22 unmodified lines
2323 RunAttemptId,
2424 RunId,
2525 RuntimeRequestId,
26+ SubagentActivationId,
2627 ThreadId,
2728 TrimmedNonEmptyString,
2829 TurnItemId,
@@ -443,6 +444,35 @@ export const OrchestrationV2ExecutionNode = Schema.Struct({ · 414 unmodified lines
443444 });
444445 export type OrchestrationV2ExecutionNode = typeof OrchestrationV2ExecutionNode.Type;
445446
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+
446476 export const OrchestrationV2Subagent = Schema.Struct({
447477 id: NodeId,
448478 threadId: ThreadId,
@@ -464,10 +494,19 @@ export const OrchestrationV2Subagent = Schema.Struct({ · 15 unmodified lines
464494 // live run (wait-mode delegations, whose result returns through the
465495 // blocking tool call). Absent on legacy records; treated as settled_only.
466496 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+ ),
467505 status: Schema.Literals([
468506 "pending",
469507 "running",
470508 "waiting",
509+ "idle",
471510 "completed",
472511 "failed",
473512 "cancelled",
@@ -475,12 +514,58 @@ export const OrchestrationV2Subagent = Schema.Struct({ · 1 unmodified line
475514 ]),
476515 progress: Schema.optional(Schema.String),
477516 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+ ),
478540 startedAt: Schema.NullOr(Schema.DateTimeUtc),
479541 completedAt: Schema.NullOr(Schema.DateTimeUtc),
480542 updatedAt: Schema.DateTimeUtc,
481543 });
482544 export type OrchestrationV2Subagent = typeof OrchestrationV2Subagent.Type;
483545
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+
484569 export const OrchestrationV2CheckpointScope = Schema.Struct({
485570 id: CheckpointScopeId,
486571 threadId: ThreadId,
@@ -722,6 +807,33 @@ export const OrchestrationV2TurnItemStatus = Schema.Literals([ · 235 unmodified lines
722807 ]);
723808 export type OrchestrationV2TurnItemStatus = typeof OrchestrationV2TurnItemStatus.Type;
724809
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+
725837 export const OrchestrationV2ProviderFailureClass = Schema.Literals([
726838 "provider_error",
727839 "transport_error",
@@ -1074,6 +1186,11 @@ export const OrchestrationV2DomainEvent = Schema.Union([ · 346 unmodified lines
10741186 type: Schema.Literal("subagent.updated"),
10751187 payload: OrchestrationV2Subagent,
10761188 }),
1189+ Schema.Struct({
1190+ ...OrchestrationV2EventBase.fields,
1191+ type: Schema.Literal("subagent-activation.updated"),
1192+ payload: OrchestrationV2SubagentActivation,
1193+ }),
10771194 Schema.Struct({
10781195 ...OrchestrationV2EventBase.fields,
10791196 type: Schema.Literals(["provider-session.attached", "provider-session.updated"]),
@@ -1153,6 +1270,7 @@ export const OrchestrationV2ThreadProjection = Schema.Struct({ · 73 unmodified lines
11531270 attempts: Schema.Array(OrchestrationV2RunAttempt),
11541271 nodes: Schema.Array(OrchestrationV2ExecutionNode),
11551272 subagents: Schema.Array(OrchestrationV2Subagent),
1273+ subagentActivations: Schema.Array(OrchestrationV2SubagentActivation),
11561274 providerSessions: Schema.Array(OrchestrationV2ProviderSession),
11571275 providerThreads: Schema.Array(OrchestrationV2ProviderThread),
11581276 providerTurns: Schema.Array(OrchestrationV2ProviderTurn),
@@ -1356,12 +1474,29 @@ export type OrchestrationV2ExecutionNodeJson = typeof OrchestrationV2ExecutionNo · 197 unmodified lines
13561474
13571475 export const OrchestrationV2SubagentJson = OrchestrationV2Subagent.mapFields((fields) => ({
13581476 ...fields,
1477+ recentActivity: Schema.Array(
1478+ OrchestrationV2SubagentActivity.mapFields((activityFields) => ({
1479+ ...activityFields,
1480+ at: Schema.DateTimeUtcFromString,
1481+ })),
1482+ ).pipe(Schema.withDecodingDefault(Effect.succeed([]))),
13591483 startedAt: Schema.NullOr(Schema.DateTimeUtcFromString),
13601484 completedAt: Schema.NullOr(Schema.DateTimeUtcFromString),
13611485 updatedAt: Schema.DateTimeUtcFromString,
13621486 }));
13631487 export type OrchestrationV2SubagentJson = typeof OrchestrationV2SubagentJson.Type;
13641488
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+
13651500 export const OrchestrationV2CheckpointScopeJson = OrchestrationV2CheckpointScope.mapFields(
13661501 (fields) => ({
13671502 ...fields,
@@ -1650,6 +1785,9 @@ export const OrchestrationV2ThreadProjectionJson = OrchestrationV2ThreadProjecti · 282 unmodified lines
16501785 attempts: Schema.Array(OrchestrationV2RunAttemptJson),
16511786 nodes: Schema.Array(OrchestrationV2ExecutionNodeJson),
16521787 subagents: Schema.Array(OrchestrationV2SubagentJson),
1788+ subagentActivations: Schema.Array(OrchestrationV2SubagentActivationJson).pipe(
1789+ Schema.withDecodingDefault(Effect.succeed([])),
1790+ ),
16531791 providerSessions: Schema.Array(OrchestrationV2ProviderSessionJson),
16541792 providerThreads: Schema.Array(OrchestrationV2ProviderThreadJson),
16551793 providerTurns: Schema.Array(OrchestrationV2ProviderTurnJson),
@@ -1780,6 +1918,11 @@ export const OrchestrationV2DomainEventJson = Schema.Union([ · 124 unmodified lines
17801918 type: Schema.Literal("subagent.updated"),
17811919 payload: OrchestrationV2SubagentJson,
17821920 }),
1921+ Schema.Struct({
1922+ ...OrchestrationV2JsonEventBaseFields,
1923+ type: Schema.Literal("subagent-activation.updated"),
1924+ payload: OrchestrationV2SubagentActivationJson,
1925+ }),
17831926 Schema.Struct({
17841927 ...OrchestrationV2JsonEventBaseFields,
17851928 type: Schema.Literals(["provider-session.attached", "provider-session.updated"]),