@senad-d/observme 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +24 -8
  3. package/docs/agent-subagent-observability-requirements.md +4 -3
  4. package/docs/compatibility-matrix.md +11 -2
  5. package/docs/configuration.md +2 -2
  6. package/docs/extension-integration.md +20 -13
  7. package/docs/reference/03-pi-event-and-session-model.md +6 -6
  8. package/docs/reference/04-telemetry-semantic-conventions.md +1 -0
  9. package/docs/reference/06-security-privacy-redaction.md +13 -6
  10. package/docs/reference/08-query-grafana-integration.md +30 -7
  11. package/docs/reference/11-deployment-runbooks.md +21 -0
  12. package/docs/reference/12-configuration-reference.md +30 -4
  13. package/examples/integrations/subagent-runner.ts +8 -5
  14. package/examples/observme.yaml +2 -2
  15. package/package.json +12 -4
  16. package/src/commands/obs-agents.ts +17 -12
  17. package/src/commands/obs-backfill.ts +2 -2
  18. package/src/commands/obs-command-support.ts +2 -1
  19. package/src/commands/obs-cost.ts +15 -7
  20. package/src/commands/obs-health.ts +22 -2
  21. package/src/commands/obs-loki-summary.ts +4 -8
  22. package/src/commands/obs-session.ts +35 -28
  23. package/src/commands/obs-status.ts +56 -13
  24. package/src/commands/obs-tools.ts +19 -8
  25. package/src/commands/obs-trace.ts +9 -0
  26. package/src/commands/obs.ts +6 -1
  27. package/src/config/bootstrap-project-config.ts +49 -32
  28. package/src/config/defaults.ts +0 -2
  29. package/src/config/load-config.ts +270 -92
  30. package/src/config/project-paths.ts +318 -6
  31. package/src/config/query-limits.ts +10 -0
  32. package/src/config/schema.ts +9 -8
  33. package/src/config/transport-security.ts +107 -0
  34. package/src/config/validate.ts +68 -11
  35. package/src/extension.ts +2 -12
  36. package/src/integration.ts +6 -2
  37. package/src/otel/logs.ts +24 -13
  38. package/src/otel/metrics.ts +24 -13
  39. package/src/otel/otlp-endpoint.ts +41 -2
  40. package/src/otel/otlp-http-options.ts +11 -0
  41. package/src/otel/sdk.ts +155 -9
  42. package/src/otel/shutdown.ts +39 -11
  43. package/src/otel/traces.ts +34 -16
  44. package/src/pi/agent-tree-tracker.ts +14 -1
  45. package/src/pi/compatibility.ts +121 -0
  46. package/src/pi/event-handlers/agent-turn.ts +16 -9
  47. package/src/pi/event-handlers/lifecycle.ts +207 -75
  48. package/src/pi/event-handlers/llm.ts +17 -9
  49. package/src/pi/event-handlers/session-events.ts +53 -19
  50. package/src/pi/event-handlers/tool-bash.ts +21 -27
  51. package/src/pi/handler-internals.ts +16 -26
  52. package/src/pi/handler-runtime.ts +138 -51
  53. package/src/pi/handler-types.ts +30 -15
  54. package/src/pi/handlers.ts +12 -1
  55. package/src/pi/integration-api.ts +27 -15
  56. package/src/pi/session-correlation.ts +167 -0
  57. package/src/pi/subagent-spawn.ts +164 -31
  58. package/src/privacy/redact.ts +574 -68
  59. package/src/privacy/secret-patterns.ts +6 -1
  60. package/src/query/grafana-readiness.ts +18 -2
  61. package/src/query/grafana-transport.ts +150 -27
  62. package/src/query/grafana-url.ts +36 -0
  63. package/src/query/grafana.ts +4 -136
  64. package/src/query/loki.ts +2 -4
  65. package/src/query/prometheus.ts +8 -10
  66. package/src/query/tempo.ts +2 -4
  67. package/src/query/trace-link.ts +186 -0
  68. package/src/safety/display-bounds.ts +84 -0
  69. package/src/semconv/metrics.ts +1 -0
  70. package/src/semconv/values.ts +2 -0
@@ -0,0 +1,167 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { OBSERVME_CORRELATION_ENTRY_TYPE } from "../semconv/values.ts";
3
+ import type { AgentLineageContext } from "./agent-lineage.ts";
4
+ import type { MinimalSessionCorrelation } from "./handler-types.ts";
5
+
6
+ export { OBSERVME_CORRELATION_ENTRY_TYPE } from "../semconv/values.ts";
7
+ export const OBSERVME_CORRELATION_ENTRY_SCHEMA_VERSION = 1;
8
+
9
+ const maximumCorrelationValueLength = 128;
10
+ const correlationValuePattern = /^[A-Za-z0-9._:-]+$/u;
11
+ const correlationDataKeys = new Set([
12
+ "schemaVersion",
13
+ "workflowId",
14
+ "agentId",
15
+ "parentAgentId",
16
+ "rootAgentId",
17
+ "parentSessionId",
18
+ "depth",
19
+ "spawnId",
20
+ "capability",
21
+ ]);
22
+
23
+ export interface ObservMeCorrelationEntryData extends MinimalSessionCorrelation {
24
+ readonly schemaVersion: typeof OBSERVME_CORRELATION_ENTRY_SCHEMA_VERSION;
25
+ readonly workflowId: string;
26
+ readonly agentId: string;
27
+ readonly rootAgentId: string;
28
+ readonly depth: number;
29
+ }
30
+
31
+ export function readLatestSessionCorrelation(entries: readonly unknown[] | undefined): MinimalSessionCorrelation | undefined {
32
+ if (!entries) return undefined;
33
+
34
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
35
+ const correlation = readSessionCorrelationEntry(entries[index]);
36
+ if (correlation) return correlation;
37
+ }
38
+
39
+ return undefined;
40
+ }
41
+
42
+ export function appendSessionCorrelationEntry(
43
+ appendEntry: ExtensionAPI["appendEntry"] | undefined,
44
+ lineage: AgentLineageContext,
45
+ recovered: MinimalSessionCorrelation | undefined,
46
+ ): void {
47
+ if (!appendEntry) return;
48
+
49
+ const data = buildSessionCorrelationEntryData(lineage);
50
+ if (!data || correlationsEqual(data, recovered)) return;
51
+
52
+ try {
53
+ appendEntry(OBSERVME_CORRELATION_ENTRY_TYPE, data);
54
+ } catch {
55
+ return;
56
+ }
57
+ }
58
+
59
+ export function buildSessionCorrelationEntryData(
60
+ lineage: AgentLineageContext,
61
+ ): ObservMeCorrelationEntryData | undefined {
62
+ return normalizeSessionCorrelationData({
63
+ schemaVersion: OBSERVME_CORRELATION_ENTRY_SCHEMA_VERSION,
64
+ workflowId: lineage.workflowId,
65
+ agentId: lineage.agentId,
66
+ parentAgentId: lineage.parentAgentId,
67
+ rootAgentId: lineage.rootAgentId,
68
+ parentSessionId: lineage.parentSessionId,
69
+ depth: lineage.depth,
70
+ spawnId: lineage.spawnId,
71
+ capability: lineage.capability,
72
+ });
73
+ }
74
+
75
+ function readSessionCorrelationEntry(value: unknown): ObservMeCorrelationEntryData | undefined {
76
+ if (!isRecord(value)) return undefined;
77
+ if (value.type !== "custom" || value.customType !== OBSERVME_CORRELATION_ENTRY_TYPE) return undefined;
78
+ return normalizeSessionCorrelationData(value.data);
79
+ }
80
+
81
+ function normalizeSessionCorrelationData(value: unknown): ObservMeCorrelationEntryData | undefined {
82
+ if (!isRecord(value) || !hasOnlyCorrelationDataKeys(value)) return undefined;
83
+ if (value.schemaVersion !== OBSERVME_CORRELATION_ENTRY_SCHEMA_VERSION) return undefined;
84
+
85
+ const workflowId = readCorrelationString(value, "workflowId");
86
+ const agentId = readCorrelationString(value, "agentId");
87
+ const parentAgentId = readOptionalCorrelationString(value, "parentAgentId");
88
+ const rootAgentId = readCorrelationString(value, "rootAgentId");
89
+ const parentSessionId = readOptionalCorrelationString(value, "parentSessionId");
90
+ const depth = readCorrelationDepth(value.depth);
91
+ const spawnId = readOptionalCorrelationString(value, "spawnId");
92
+ const capability = readOptionalCorrelationString(value, "capability");
93
+
94
+ if (!workflowId || !agentId || !rootAgentId || depth === undefined) return undefined;
95
+ if (parentAgentId === null || parentSessionId === null || spawnId === null || capability === null) return undefined;
96
+ if (!isConsistentCorrelationLineage(agentId, parentAgentId, rootAgentId, depth)) return undefined;
97
+
98
+ return withoutUndefinedValues({
99
+ schemaVersion: OBSERVME_CORRELATION_ENTRY_SCHEMA_VERSION,
100
+ workflowId,
101
+ agentId,
102
+ parentAgentId,
103
+ rootAgentId,
104
+ parentSessionId,
105
+ depth,
106
+ spawnId,
107
+ capability,
108
+ }) as ObservMeCorrelationEntryData;
109
+ }
110
+
111
+ function hasOnlyCorrelationDataKeys(value: Record<string, unknown>): boolean {
112
+ return Object.keys(value).every(key => correlationDataKeys.has(key));
113
+ }
114
+
115
+ function readCorrelationString(value: Record<string, unknown>, key: string): string | undefined {
116
+ const candidate = value[key];
117
+ if (typeof candidate !== "string") return undefined;
118
+ if (candidate.length === 0 || candidate.length > maximumCorrelationValueLength) return undefined;
119
+ return correlationValuePattern.test(candidate) ? candidate : undefined;
120
+ }
121
+
122
+ function readOptionalCorrelationString(
123
+ value: Record<string, unknown>,
124
+ key: string,
125
+ ): string | undefined | null {
126
+ if (!Object.hasOwn(value, key) || value[key] === undefined) return undefined;
127
+ return readCorrelationString(value, key) ?? null;
128
+ }
129
+
130
+ function readCorrelationDepth(value: unknown): number | undefined {
131
+ if (typeof value !== "number" || !Number.isInteger(value)) return undefined;
132
+ return value >= 0 && value <= 64 ? value : undefined;
133
+ }
134
+
135
+ function isConsistentCorrelationLineage(
136
+ agentId: string,
137
+ parentAgentId: string | undefined,
138
+ rootAgentId: string,
139
+ depth: number,
140
+ ): boolean {
141
+ if (depth === 0) return parentAgentId === undefined && rootAgentId === agentId;
142
+ return parentAgentId !== undefined && parentAgentId !== agentId;
143
+ }
144
+
145
+ function correlationsEqual(
146
+ left: ObservMeCorrelationEntryData,
147
+ right: MinimalSessionCorrelation | undefined,
148
+ ): boolean {
149
+ if (!right) return false;
150
+
151
+ return left.workflowId === right.workflowId
152
+ && left.agentId === right.agentId
153
+ && left.parentAgentId === right.parentAgentId
154
+ && left.rootAgentId === right.rootAgentId
155
+ && left.parentSessionId === right.parentSessionId
156
+ && left.depth === right.depth
157
+ && left.spawnId === right.spawnId
158
+ && left.capability === right.capability;
159
+ }
160
+
161
+ function withoutUndefinedValues<T extends Record<string, unknown>>(value: T): T {
162
+ return Object.fromEntries(Object.entries(value).filter(entry => entry[1] !== undefined)) as T;
163
+ }
164
+
165
+ function isRecord(value: unknown): value is Record<string, unknown> {
166
+ return typeof value === "object" && value !== null && !Array.isArray(value);
167
+ }
@@ -24,7 +24,7 @@ import {
24
24
  } from "../semconv/values.ts";
25
25
  import { BoundedMap } from "../util/bounded-map.ts";
26
26
  import type { AgentChildStatus, AgentTreeNode, AgentTreeSummary } from "./agent-tree-tracker.ts";
27
- import { AgentTreeTracker } from "./agent-tree-tracker.ts";
27
+ import { AgentTreeTracker, isAgentStatusTransitionAllowed } from "./agent-tree-tracker.ts";
28
28
  import type { AgentLineageContext, AgentRole } from "./agent-lineage.ts";
29
29
  import { createAgentLineageContext, createPropagationEnvironment, sanitizePropagationEnvironment } from "./agent-lineage.ts";
30
30
  import { recordActiveSpanEnd, recordActiveSpanStart } from "./handler-internals.ts";
@@ -44,6 +44,7 @@ export type {
44
44
 
45
45
  export type SubagentSpawnType = "command" | "tool" | "extension" | "unknown";
46
46
  export type AgentJoinStatus = "completed" | "failed" | "cancelled" | "timeout" | "unknown" | "waiting";
47
+ export type AgentTerminalStatus = Extract<AgentChildStatus, "completed" | "failed" | "cancelled">;
47
48
  export type AttributePrimitive = boolean | number | string | string[];
48
49
  export type AttributeMap = Record<string, AttributePrimitive>;
49
50
 
@@ -125,13 +126,35 @@ export interface StartedSubagentSpawn {
125
126
  readonly attributes: AttributeMap;
126
127
  }
127
128
 
129
+ export interface SubagentSpawnIdentity {
130
+ readonly spawnId: string;
131
+ readonly childAgentId: string;
132
+ }
133
+
128
134
  export interface CompleteSubagentSpawnOptions {
129
135
  readonly childAgentId?: string;
130
- readonly childStatus?: AgentChildStatus;
131
- readonly outcome?: "completed" | "failed" | "cancelled";
136
+ readonly childStatus?: AgentTerminalStatus;
137
+ readonly outcome?: AgentTerminalStatus;
132
138
  readonly now?: () => number;
133
139
  }
134
140
 
141
+ export type SubagentTransitionFailureReason =
142
+ | "spawn_not_found"
143
+ | "child_agent_mismatch"
144
+ | "invalid_terminal_transition";
145
+
146
+ export type SubagentTransitionResult =
147
+ | { readonly ok: true }
148
+ | { readonly ok: false; readonly reason: SubagentTransitionFailureReason };
149
+
150
+ interface ResolvedCompletionTransition {
151
+ readonly ok: true;
152
+ readonly childAgentId: string;
153
+ readonly status: AgentTerminalStatus;
154
+ }
155
+
156
+ type CompletionTransitionResult = ResolvedCompletionTransition | Extract<SubagentTransitionResult, { readonly ok: false }>;
157
+
135
158
  export interface FailSubagentSpawnOptions {
136
159
  readonly childAgentId?: string;
137
160
  readonly errorClass?: string;
@@ -219,12 +242,18 @@ export async function runSubagentWithObservability<Result>(
219
242
  }
220
243
  }
221
244
 
245
+ export function resolveSubagentSpawnIdentity(
246
+ options: Pick<StartSubagentSpawnOptions, "spawnId" | "childAgentId"> = {},
247
+ ): SubagentSpawnIdentity {
248
+ const spawnId = options.spawnId ?? `spawn-${randomUUID()}`;
249
+ return { spawnId, childAgentId: options.childAgentId ?? `child-${spawnId}` };
250
+ }
251
+
222
252
  export function startSubagentSpawn(
223
253
  session: SubagentTelemetrySession,
224
254
  options: StartSubagentSpawnOptions = {},
225
255
  ): StartedSubagentSpawn {
226
- const spawnId = options.spawnId ?? `spawn-${randomUUID()}`;
227
- const childAgentId = options.childAgentId ?? `child-${spawnId}`;
256
+ const { spawnId, childAgentId } = resolveSubagentSpawnIdentity(options);
228
257
  const spawnReason = normalizeSpawnReason(options.spawnReason);
229
258
  const labels = subagentSpawnMetricLabels(session, options, spawnReason);
230
259
  const parentSpan = resolveSubagentParentSpan(session);
@@ -269,48 +298,55 @@ export function completeSubagentSpawn(
269
298
  session: SubagentTelemetrySession,
270
299
  spawnId: string,
271
300
  options: CompleteSubagentSpawnOptions = {},
272
- ): void {
301
+ ): SubagentTransitionResult {
273
302
  const state = session.spans.activeSubagentSpawns.get(spawnId);
274
- if (!state) return;
303
+ if (!state) return subagentTransitionFailure("spawn_not_found");
275
304
 
276
- const childAgentId = options.childAgentId ?? state.childAgentId;
277
- const childStatus = options.childStatus ?? "completed";
305
+ const transition = resolveCompletionTransition(session, state, options);
306
+ if (!transition.ok) return transition;
307
+
308
+ updateChildStatus(session, transition.childAgentId, transition.status);
309
+ recordChildFailureCompletion(session, transition.childAgentId, transition.status);
310
+ recordObsAgentsTreeState(session);
278
311
  const attributes = buildSubagentCompletionAttributes(
279
312
  session,
280
313
  spawnId,
281
- childAgentId,
282
- options.outcome ?? "completed",
314
+ transition.childAgentId,
315
+ transition.status,
283
316
  state.spawnReason,
284
317
  );
318
+ const eventName = terminalSpawnEventName(transition.status);
319
+ const severityText = transition.status === "completed" ? "INFO" : "ERROR";
285
320
 
286
- updateChildStatus(session, childAgentId, childStatus);
287
- recordChildFailureCompletion(session, childAgentId, childStatus);
288
- recordObsAgentsTreeState(session);
289
321
  recordSubagentSpawnDuration(session, state, options);
290
322
  state.span.setAttributes(attributes);
291
- state.span.setStatus({ code: SpanStatusCode.OK });
292
- state.span.addEvent(LOG_EVENT_NAMES.AGENT_SPAWN_COMPLETED, attributes);
323
+ setTerminalSpawnStatus(state.span, transition.status);
324
+ state.span.addEvent(eventName, attributes);
293
325
  endSubagentSpan(session, state.span);
294
326
  session.spans.activeSubagentSpawns.delete(spawnId);
295
- emitSubagentLog(session, LOG_EVENT_NAMES.AGENT_SPAWN_COMPLETED, attributes);
327
+ emitSubagentLog(session, eventName, attributes, severityText);
328
+ return subagentTransitionSuccess();
296
329
  }
297
330
 
298
331
  export function failSubagentSpawn(
299
332
  session: SubagentTelemetrySession,
300
333
  spawnId: string,
301
334
  options: FailSubagentSpawnOptions = {},
302
- ): void {
335
+ ): SubagentTransitionResult {
303
336
  const state = session.spans.activeSubagentSpawns.get(spawnId);
304
- if (!state) return;
337
+ if (!state) return subagentTransitionFailure("spawn_not_found");
305
338
 
306
339
  const childAgentId = options.childAgentId ?? state.childAgentId;
340
+ const transitionFailure = validateLauncherFailureTransition(session, state, childAgentId);
341
+ if (transitionFailure) return transitionFailure;
342
+
343
+ updateChildStatus(session, childAgentId, "failed");
344
+ recordObsAgentsTreeState(session);
307
345
  const attributes = {
308
346
  ...buildSubagentCompletionAttributes(session, spawnId, childAgentId, "failed", state.spawnReason),
309
347
  [LOG_ATTRIBUTES.ERROR_TYPE]: normalizeMetricLabel(options.errorClass ?? "subagent_spawn_error", "subagent_spawn_error"),
310
348
  };
311
349
 
312
- updateChildStatus(session, childAgentId, "failed");
313
- recordObsAgentsTreeState(session);
314
350
  recordSubagentSpawnDuration(session, state, options);
315
351
  state.span.setAttributes(attributes);
316
352
  state.span.setStatus({ code: SpanStatusCode.ERROR, message: String(attributes[LOG_ATTRIBUTES.ERROR_TYPE]) });
@@ -319,6 +355,7 @@ export function failSubagentSpawn(
319
355
  session.spans.activeSubagentSpawns.delete(spawnId);
320
356
  session.metrics.subagentSpawnFailures.add(1, subagentFailureMetricLabels(state.labels, attributes));
321
357
  emitSubagentLog(session, LOG_EVENT_NAMES.AGENT_SPAWN_FAILED, attributes, "ERROR");
358
+ return subagentTransitionSuccess();
322
359
  }
323
360
 
324
361
  export function buildSubagentPropagationEnvironment(
@@ -372,8 +409,8 @@ export function endAgentWait(
372
409
  session: SubagentTelemetrySession,
373
410
  waitId: string,
374
411
  options: AgentWaitJoinOptions = {},
375
- ): void {
376
- endWaitJoinSpan(session, waitId, options, "wait");
412
+ ): SubagentTransitionResult {
413
+ return endWaitJoinSpan(session, waitId, options, "wait");
377
414
  }
378
415
 
379
416
  export function recordAgentWait(session: SubagentTelemetrySession, options: AgentWaitJoinOptions = {}): StartedAgentWaitJoin {
@@ -393,8 +430,8 @@ export function endAgentJoin(
393
430
  session: SubagentTelemetrySession,
394
431
  joinId: string,
395
432
  options: AgentWaitJoinOptions = {},
396
- ): void {
397
- endWaitJoinSpan(session, joinId, options, "join");
433
+ ): SubagentTransitionResult {
434
+ return endWaitJoinSpan(session, joinId, options, "join");
398
435
  }
399
436
 
400
437
  export function recordAgentJoin(session: SubagentTelemetrySession, options: AgentWaitJoinOptions = {}): StartedAgentWaitJoin {
@@ -468,11 +505,13 @@ function endWaitJoinSpan(
468
505
  id: string,
469
506
  options: AgentWaitJoinOptions,
470
507
  kind: "wait" | "join",
471
- ): void {
508
+ ): SubagentTransitionResult {
472
509
  const registry = waitJoinRegistry(session, kind);
473
510
  const state = registry.get(id);
474
- if (!state) return;
511
+ if (!state) return subagentTransitionFailure("spawn_not_found");
475
512
 
513
+ const transition = validateWaitJoinTransition(session, options, kind);
514
+ if (!transition.ok) return transition;
476
515
  if (kind === "join" && options.childAgentId && options.childStatus) updateChildStatus(session, options.childAgentId, options.childStatus);
477
516
 
478
517
  const attributes = buildWaitJoinAttributes(session, options, state.reason);
@@ -489,6 +528,28 @@ function endWaitJoinSpan(
489
528
  recordWaitJoinDuration(session, durationMs, state.labels, kind);
490
529
  if (kind === "join") recordChildJoinAccounting(session, options);
491
530
  emitSubagentLog(session, eventName, attributes, waitJoinFailed(options) ? "ERROR" : "INFO");
531
+ return subagentTransitionSuccess();
532
+ }
533
+
534
+ function validateWaitJoinTransition(
535
+ session: SubagentTelemetrySession,
536
+ options: AgentWaitJoinOptions,
537
+ kind: "wait" | "join",
538
+ ): SubagentTransitionResult {
539
+ if (kind === "wait" || !options.childAgentId || !options.childStatus) return subagentTransitionSuccess();
540
+
541
+ if (options.spawnId) {
542
+ const spawn = session.spans.activeSubagentSpawns.get(options.spawnId);
543
+ if (spawn && spawn.childAgentId !== options.childAgentId) {
544
+ return subagentTransitionFailure("child_agent_mismatch");
545
+ }
546
+ }
547
+
548
+ const child = ensureAgentTree(session).getAgent(options.childAgentId);
549
+ if (child && !isAgentStatusTransitionAllowed(child.status, options.childStatus)) {
550
+ return subagentTransitionFailure("invalid_terminal_transition");
551
+ }
552
+ return subagentTransitionSuccess();
492
553
  }
493
554
 
494
555
  function waitJoinRegistry(
@@ -523,7 +584,7 @@ function recordSubagentSpawnDuration(
523
584
  function recordChildFailureCompletion(
524
585
  session: SubagentTelemetrySession,
525
586
  childAgentId: string,
526
- childStatus: AgentChildStatus,
587
+ childStatus: AgentTerminalStatus,
527
588
  ): void {
528
589
  if (childStatus !== "failed") return;
529
590
  recordChildFailureAccounting(session, childAgentId, false);
@@ -644,11 +705,83 @@ function buildSubagentSpawnAttributes(
644
705
  });
645
706
  }
646
707
 
708
+ function resolveCompletionTransition(
709
+ session: SubagentTelemetrySession,
710
+ state: SubagentSpawnState,
711
+ options: CompleteSubagentSpawnOptions,
712
+ ): CompletionTransitionResult {
713
+ const childAgentId = options.childAgentId ?? state.childAgentId;
714
+ const requestedStatus: unknown = options.childStatus ?? options.outcome ?? "completed";
715
+ if (!isAgentTerminalStatus(requestedStatus)) return subagentTransitionFailure("invalid_terminal_transition");
716
+ if (options.childStatus !== undefined && options.outcome !== undefined && options.childStatus !== options.outcome) {
717
+ return subagentTransitionFailure("invalid_terminal_transition");
718
+ }
719
+
720
+ const transitionFailure = validateSpawnChildTransition(session, state, childAgentId, requestedStatus);
721
+ if (transitionFailure) return transitionFailure;
722
+ return { ok: true, childAgentId, status: requestedStatus };
723
+ }
724
+
725
+ function validateSpawnChildTransition(
726
+ session: SubagentTelemetrySession,
727
+ state: SubagentSpawnState,
728
+ childAgentId: string,
729
+ status: AgentTerminalStatus,
730
+ ): Extract<SubagentTransitionResult, { readonly ok: false }> | undefined {
731
+ if (childAgentId !== state.childAgentId) return subagentTransitionFailure("child_agent_mismatch");
732
+
733
+ const child = ensureAgentTree(session).getAgent(childAgentId);
734
+ if (!child || !isAgentStatusTransitionAllowed(child.status, status)) {
735
+ return subagentTransitionFailure("invalid_terminal_transition");
736
+ }
737
+ return undefined;
738
+ }
739
+
740
+ function validateLauncherFailureTransition(
741
+ session: SubagentTelemetrySession,
742
+ state: SubagentSpawnState,
743
+ childAgentId: string,
744
+ ): Extract<SubagentTransitionResult, { readonly ok: false }> | undefined {
745
+ if (childAgentId !== state.childAgentId) return subagentTransitionFailure("child_agent_mismatch");
746
+
747
+ const child = ensureAgentTree(session).getAgent(childAgentId);
748
+ if (child?.status !== "starting") return subagentTransitionFailure("invalid_terminal_transition");
749
+ return undefined;
750
+ }
751
+
752
+ function isAgentTerminalStatus(value: unknown): value is AgentTerminalStatus {
753
+ return value === "completed" || value === "failed" || value === "cancelled";
754
+ }
755
+
756
+ function terminalSpawnEventName(status: AgentTerminalStatus): string {
757
+ if (status === "completed") return LOG_EVENT_NAMES.AGENT_SPAWN_COMPLETED;
758
+ if (status === "failed") return LOG_EVENT_NAMES.AGENT_SPAWN_FAILED;
759
+ return LOG_EVENT_NAMES.AGENT_SPAWN_CANCELLED;
760
+ }
761
+
762
+ function setTerminalSpawnStatus(span: Span, status: AgentTerminalStatus): void {
763
+ if (status === "completed") {
764
+ span.setStatus({ code: SpanStatusCode.OK });
765
+ return;
766
+ }
767
+ span.setStatus({ code: SpanStatusCode.ERROR, message: status });
768
+ }
769
+
770
+ function subagentTransitionSuccess(): SubagentTransitionResult {
771
+ return { ok: true };
772
+ }
773
+
774
+ function subagentTransitionFailure(
775
+ reason: SubagentTransitionFailureReason,
776
+ ): Extract<SubagentTransitionResult, { readonly ok: false }> {
777
+ return { ok: false, reason };
778
+ }
779
+
647
780
  function buildSubagentCompletionAttributes(
648
781
  session: SubagentTelemetrySession,
649
782
  spawnId: string,
650
783
  childAgentId: string,
651
- outcome: "completed" | "failed" | "cancelled",
784
+ outcome: AgentTerminalStatus,
652
785
  spawnReason: SubagentSpawnReason,
653
786
  ): AttributeMap {
654
787
  const summary = ensureAgentTree(session).summarize(session.lineage.rootAgentId);
@@ -736,8 +869,8 @@ function updateChildStatus(
736
869
  session: SubagentTelemetrySession,
737
870
  childAgentId: string,
738
871
  status: AgentChildStatus,
739
- ): void {
740
- ensureAgentTree(session).updateStatus(childAgentId, status);
872
+ ): AgentTreeNode | undefined {
873
+ return ensureAgentTree(session).updateStatus(childAgentId, status);
741
874
  }
742
875
 
743
876
  function recordOrphanAgent(session: SubagentTelemetrySession, node: AgentTreeNode): void {