@agwab/pi-workflow 0.2.1 → 0.4.0

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 (119) hide show
  1. package/README.md +3 -1
  2. package/dist/artifact-graph-runtime.d.ts +1 -1
  3. package/dist/artifact-graph-runtime.js +10 -5
  4. package/dist/artifact-graph-schema.js +127 -5
  5. package/dist/compiler.js +52 -19
  6. package/dist/dynamic-generated-task-runtime.js +3 -1
  7. package/dist/dynamic-profiles.d.ts +1 -1
  8. package/dist/engine-run-graph.d.ts +3 -0
  9. package/dist/engine-run-graph.js +194 -4
  10. package/dist/engine.d.ts +5 -0
  11. package/dist/engine.js +389 -41
  12. package/dist/extension.d.ts +2 -1
  13. package/dist/extension.js +30 -8
  14. package/dist/index.d.ts +11 -3
  15. package/dist/index.js +6 -1
  16. package/dist/prompt-json.d.ts +7 -0
  17. package/dist/prompt-json.js +13 -0
  18. package/dist/roles.d.ts +1 -1
  19. package/dist/roles.js +5 -8
  20. package/dist/store.d.ts +20 -1
  21. package/dist/store.js +139 -35
  22. package/dist/strings.d.ts +11 -0
  23. package/dist/strings.js +24 -0
  24. package/dist/subagent-backend.js +710 -40
  25. package/dist/types.d.ts +107 -1
  26. package/dist/verification-ontology.d.ts +31 -0
  27. package/dist/verification-ontology.js +66 -0
  28. package/dist/workflow-artifact-tool.js +5 -6
  29. package/dist/workflow-artifacts.d.ts +7 -0
  30. package/dist/workflow-artifacts.js +55 -4
  31. package/dist/workflow-fetch-cache-extension.d.ts +1 -0
  32. package/dist/workflow-fetch-cache-extension.js +57 -9
  33. package/dist/workflow-metrics.d.ts +113 -0
  34. package/dist/workflow-metrics.js +272 -0
  35. package/dist/workflow-output-artifacts.js +5 -3
  36. package/dist/workflow-partial-output.d.ts +45 -0
  37. package/dist/workflow-partial-output.js +205 -0
  38. package/dist/workflow-progress-health.js +42 -10
  39. package/dist/workflow-runtime.js +10 -1
  40. package/dist/workflow-view.js +3 -1
  41. package/dist/workflow-web-source-extension.js +194 -52
  42. package/dist/workflow-web-source.d.ts +2 -1
  43. package/dist/workflow-web-source.js +109 -30
  44. package/docs/usage.md +76 -29
  45. package/node_modules/@agwab/pi-subagent/README.md +3 -3
  46. package/node_modules/@agwab/pi-subagent/api.mjs +1 -0
  47. package/node_modules/@agwab/pi-subagent/docs/usage.md +63 -12
  48. package/node_modules/@agwab/pi-subagent/package.json +2 -2
  49. package/node_modules/@agwab/pi-subagent/src/api.ts +54 -1
  50. package/node_modules/@agwab/pi-subagent/src/artifacts/registry.ts +9 -4
  51. package/node_modules/@agwab/pi-subagent/src/artifacts/result.ts +8 -0
  52. package/node_modules/@agwab/pi-subagent/src/core/constants.ts +9 -0
  53. package/node_modules/@agwab/pi-subagent/src/core/validation.ts +21 -0
  54. package/node_modules/@agwab/pi-subagent/src/index.ts +1046 -576
  55. package/node_modules/@agwab/pi-subagent/src/orchestrate/async.ts +279 -156
  56. package/node_modules/@agwab/pi-subagent/src/orchestrate/interrupt.ts +165 -89
  57. package/node_modules/@agwab/pi-subagent/src/orchestrate/reconcile.ts +111 -65
  58. package/node_modules/@agwab/pi-subagent/src/orchestrate/run-ref.ts +219 -0
  59. package/node_modules/@agwab/pi-subagent/src/orchestrate/run.ts +88 -8
  60. package/node_modules/@agwab/pi-subagent/src/orchestrate/status.ts +614 -298
  61. package/node_modules/@agwab/pi-subagent/src/panel.ts +1356 -560
  62. package/node_modules/@agwab/pi-subagent/src/runners/headless-model.ts +53 -5
  63. package/node_modules/@agwab/pi-subagent/src/runners/tmux.ts +13 -6
  64. package/package.json +2 -2
  65. package/skills/workflow-guide/SKILL.md +1 -0
  66. package/src/artifact-graph-runtime.ts +19 -13
  67. package/src/artifact-graph-schema.ts +143 -3
  68. package/src/cli.mjs +52 -0
  69. package/src/compiler.ts +63 -18
  70. package/src/dynamic-generated-task-runtime.ts +3 -1
  71. package/src/dynamic-profiles.ts +1 -1
  72. package/src/engine-run-graph.ts +246 -4
  73. package/src/engine.ts +545 -38
  74. package/src/extension.ts +36 -6
  75. package/src/index.ts +52 -1
  76. package/src/prompt-json.ts +13 -0
  77. package/src/roles.ts +6 -9
  78. package/src/store.ts +194 -42
  79. package/src/strings.ts +38 -0
  80. package/src/subagent-backend.ts +921 -62
  81. package/src/types.ts +116 -2
  82. package/src/verification-ontology.ts +88 -0
  83. package/src/workflow-artifact-tool.ts +5 -7
  84. package/src/workflow-artifacts.ts +83 -3
  85. package/src/workflow-fetch-cache-extension.ts +78 -13
  86. package/src/workflow-metrics.ts +478 -0
  87. package/src/workflow-output-artifacts.ts +5 -3
  88. package/src/workflow-partial-output.ts +299 -0
  89. package/src/workflow-progress-health.ts +47 -15
  90. package/src/workflow-runtime.ts +18 -2
  91. package/src/workflow-view.ts +2 -1
  92. package/src/workflow-web-source-extension.ts +654 -232
  93. package/src/workflow-web-source.ts +153 -39
  94. package/workflows/README.md +7 -25
  95. package/workflows/deep-research/batched-verification.spec.json +253 -0
  96. package/workflows/deep-research/helpers/batch-verification-candidates.mjs +136 -0
  97. package/workflows/deep-research/helpers/claim-evidence-gate.mjs +229 -36
  98. package/workflows/deep-research/helpers/final-audit-packet.mjs +1 -4
  99. package/workflows/deep-research/helpers/normalize-input-packet.mjs +81 -2
  100. package/workflows/deep-research/helpers/render-executive.mjs +40 -26
  101. package/workflows/deep-research/helpers/sanitize-verification-candidates.mjs +89 -15
  102. package/workflows/deep-research/helpers/shadow-select-verification.mjs +229 -0
  103. package/workflows/deep-research/helpers/verification-ontology.mjs +77 -0
  104. package/workflows/deep-research/schemas/deep-research-executive-render-control.schema.json +3 -3
  105. package/workflows/deep-research/schemas/deep-research-research-questions-control.schema.json +38 -0
  106. package/workflows/deep-research/schemas/deep-research-sanitize-claims-control.schema.json +63 -0
  107. package/workflows/deep-research/schemas/deep-research-verify-claims-batch-control.schema.json +47 -0
  108. package/workflows/deep-research/schemas/deep-research-verify-claims-control.schema.json +13 -3
  109. package/workflows/deep-research/spec.json +32 -12
  110. package/workflows/impact-review/spec.json +3 -3
  111. package/workflows/spec-review/helpers/spec-review-pipeline.mjs +1 -8
  112. package/dist/dynamic-loader.d.ts +0 -25
  113. package/dist/dynamic-loader.js +0 -13
  114. package/skills/workflow-guide/scaffolds/dag-required-reads/spec.json.validate.stderr +0 -0
  115. package/skills/workflow-guide/scaffolds/dag-required-reads/spec.json.validate.stdout +0 -13
  116. package/src/dynamic-loader.ts +0 -49
  117. package/workflows/impact-review/schemas/docs-release-impact-control.schema.json +0 -42
  118. package/workflows/impact-review/schemas/security-performance-impact-control.schema.json +0 -42
  119. package/workflows/impact-review/schemas/state-data-impact-control.schema.json +0 -42
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { existsSync } from "node:fs";
2
3
  import {
3
4
  copyFile,
@@ -24,6 +25,11 @@ import type {
24
25
  CompiledTask,
25
26
  CompiledToolProvider,
26
27
  WorkflowRunRecord,
28
+ WorkflowTaskTimingAttemptRecord,
29
+ WorkflowTaskTimingRecord,
30
+ WorkflowTaskUsageAttemptRecord,
31
+ WorkflowTaskUsageRecord,
32
+ WorkflowTaskUsageValues,
27
33
  WorkflowTaskRunRecord,
28
34
  } from "./types.js";
29
35
  import type { JsonSchema } from "./json-schema.js";
@@ -49,15 +55,26 @@ import {
49
55
  parseWorkflowOutputForBundle,
50
56
  writeWorkflowTaskArtifactBundle,
51
57
  } from "./workflow-output-artifacts.js";
58
+ import { writeWorkflowPartialOutputLedgerFromFile } from "./workflow-partial-output.js";
52
59
 
53
60
  const DEFAULT_SUBAGENT_RUNS_ROOT = ".pi/workflow-subagents";
61
+ const MAX_SUBAGENT_SESSION_ID_LENGTH = 64;
54
62
  const EXTRA_SUBAGENT_EXTENSIONS_ENV = "PI_WORKFLOW_SUBAGENT_EXTRA_EXTENSIONS";
55
63
  const FETCH_CONTENT_CACHE_ENV = "PI_WORKFLOW_FETCH_CONTENT_CACHE";
56
64
  const LEGACY_FETCH_CACHE_ENV = "PI_WORKFLOW_FETCH_CACHE";
65
+ const FETCH_CONTENT_INLINE_CHARS_ENV = "PI_WORKFLOW_FETCH_CONTENT_INLINE_CHARS";
66
+ const DEFAULT_WORKFLOW_FETCH_CONTENT_INLINE_CHARS = 12_000;
57
67
  const DEFAULT_TRANSIENT_MODEL_FAILURE_RETRIES = 5;
58
68
  const DEFAULT_ARTIFACT_OUTPUT_RETRIES = 2;
59
69
  const MAX_CONCURRENT_LAUNCHES_ENV = "PI_WORKFLOW_MAX_CONCURRENT_LAUNCHES";
70
+ const LAUNCH_SLOT_RELEASE_DELAY_MS_ENV =
71
+ "PI_WORKFLOW_LAUNCH_SLOT_RELEASE_DELAY_MS";
72
+ const PARENT_SUBAGENT_CWD_ENV = "PI_WORKFLOW_PARENT_SUBAGENT_CWD";
73
+ const PARENT_SUBAGENT_RUNS_DIR_ENV = "PI_WORKFLOW_PARENT_SUBAGENT_RUNS_DIR";
74
+ const PARENT_SUBAGENT_RUN_ID_ENV = "PI_WORKFLOW_PARENT_SUBAGENT_RUN_ID";
75
+ const PARENT_SUBAGENT_ATTEMPT_ID_ENV = "PI_WORKFLOW_PARENT_SUBAGENT_ATTEMPT_ID";
60
76
  const DEFAULT_LAUNCH_SLOT_RELEASE_DELAY_MS = 3_000;
77
+ const STALE_LAUNCH_CLAIM_GRACE_MS = 30_000;
61
78
  const MIN_TRANSIENT_RETRY_JITTER_MS = 1_000;
62
79
  const MAX_TRANSIENT_RETRY_JITTER_MS = 5_000;
63
80
  const MODULE_PATH = fileURLToPath(import.meta.url);
@@ -138,6 +155,7 @@ interface SubagentRunStatusSnapshot {
138
155
  failureKind: string | null;
139
156
  startedAt: string;
140
157
  completedAt: string | null;
158
+ durationMs?: number | null;
141
159
  logs: SubagentRunLogRef[];
142
160
  metadata?: { contextLengthExceeded?: boolean; [key: string]: unknown };
143
161
  completion?: unknown;
@@ -161,8 +179,29 @@ interface SubagentApi {
161
179
  ): Promise<SubagentRunStatusSnapshot | null>;
162
180
  interruptSubagent(options: Record<string, unknown>): Promise<unknown>;
163
181
  reconcileSubagentRun(options: Record<string, unknown>): Promise<unknown>;
182
+ recordSubagentChildEvent?(options: Record<string, unknown>): Promise<unknown>;
164
183
  }
165
184
 
185
+ type ParentSubagentChildEvent =
186
+ | "started"
187
+ | "completed"
188
+ | "failed"
189
+ | "cancelled";
190
+
191
+ interface ParentSubagentRef {
192
+ cwd: string;
193
+ runsDir: string;
194
+ runId: string;
195
+ attemptId?: string;
196
+ }
197
+
198
+ const GENERIC_TASK_STATUS_DETAILS = new Set([
199
+ "completed",
200
+ "failed",
201
+ "interrupted",
202
+ "running",
203
+ ]);
204
+
166
205
  const subagentApiSpecifier = "@agwab/pi-subagent/api";
167
206
  let cachedSubagentApi: Promise<SubagentApi> | undefined;
168
207
  let injectedSubagentApi: SubagentApi | undefined;
@@ -180,7 +219,86 @@ async function loadSubagentApi(): Promise<SubagentApi> {
180
219
  return cachedSubagentApi;
181
220
  }
182
221
 
183
- let launchSlotReleaseDelayMs = DEFAULT_LAUNCH_SLOT_RELEASE_DELAY_MS;
222
+ function nonEmptyEnv(
223
+ env: Record<string, string | undefined>,
224
+ key: string,
225
+ ): string | undefined {
226
+ const value = env[key]?.trim();
227
+ return value ? value : undefined;
228
+ }
229
+
230
+ function parentSubagentRefFromEnv(
231
+ env: Record<string, string | undefined> = process.env,
232
+ ): ParentSubagentRef | undefined {
233
+ const cwd = nonEmptyEnv(env, PARENT_SUBAGENT_CWD_ENV);
234
+ const runsDir = nonEmptyEnv(env, PARENT_SUBAGENT_RUNS_DIR_ENV);
235
+ const runId = nonEmptyEnv(env, PARENT_SUBAGENT_RUN_ID_ENV);
236
+ if (!cwd || !runsDir || !runId) return undefined;
237
+ const attemptId = nonEmptyEnv(env, PARENT_SUBAGENT_ATTEMPT_ID_ENV);
238
+ return { cwd, runsDir, runId, ...(attemptId ? { attemptId } : {}) };
239
+ }
240
+
241
+ function terminalChildEventForTaskStatus(
242
+ status: WorkflowTaskRunRecord["status"],
243
+ ): ParentSubagentChildEvent | undefined {
244
+ if (status === "completed") return "completed";
245
+ if (status === "failed") return "failed";
246
+ if (status === "interrupted") return "cancelled";
247
+ return undefined;
248
+ }
249
+
250
+ async function recordParentSubagentChildEvent(options: {
251
+ event: ParentSubagentChildEvent;
252
+ childRunId: string;
253
+ run: WorkflowRunRecord;
254
+ task: WorkflowTaskRunRecord;
255
+ failureKind?: string | null;
256
+ message?: string;
257
+ }): Promise<void> {
258
+ const parent = parentSubagentRefFromEnv();
259
+ if (!parent) return;
260
+ const api = await loadSubagentApi().catch(() => undefined);
261
+ if (!api?.recordSubagentChildEvent) return;
262
+ await api
263
+ .recordSubagentChildEvent({
264
+ ...parent,
265
+ event: options.event,
266
+ childRunId: options.childRunId,
267
+ workflowRunId: options.run.runId,
268
+ childTaskId: options.task.taskId,
269
+ ...(options.failureKind === undefined
270
+ ? {}
271
+ : { failureKind: options.failureKind }),
272
+ ...(options.message === undefined ? {} : { message: options.message }),
273
+ })
274
+ .catch(() => undefined);
275
+ }
276
+
277
+ async function recordTerminalParentSubagentChildEvent(
278
+ run: WorkflowRunRecord,
279
+ task: WorkflowTaskRunRecord,
280
+ snapshot: SubagentRunStatusSnapshot,
281
+ ): Promise<void> {
282
+ const event = terminalChildEventForTaskStatus(task.status);
283
+ if (!event) return;
284
+ const taskFailureKind =
285
+ task.statusDetail && !GENERIC_TASK_STATUS_DETAILS.has(task.statusDetail)
286
+ ? task.statusDetail
287
+ : undefined;
288
+ await recordParentSubagentChildEvent({
289
+ event,
290
+ childRunId: snapshot.runId,
291
+ run,
292
+ task,
293
+ failureKind:
294
+ event === "completed"
295
+ ? undefined
296
+ : (snapshot.failureKind ?? taskFailureKind ?? task.statusDetail),
297
+ message: task.lastMessage,
298
+ });
299
+ }
300
+
301
+ let launchSlotReleaseDelayMsForTests: number | undefined;
184
302
  let transientRetryJitterForTests: (() => number) | undefined;
185
303
  const launchWaitQueue: Array<() => void> = [];
186
304
  let activeLaunchSlots = 0;
@@ -217,6 +335,18 @@ function releaseLaunchSlot(): void {
217
335
  activeLaunchSlots = Math.max(0, activeLaunchSlots - 1);
218
336
  }
219
337
 
338
+ function resolveLaunchSlotReleaseDelayMs(): number {
339
+ if (launchSlotReleaseDelayMsForTests !== undefined) {
340
+ return launchSlotReleaseDelayMsForTests;
341
+ }
342
+ const override = Number.parseInt(
343
+ process.env[LAUNCH_SLOT_RELEASE_DELAY_MS_ENV] ?? "",
344
+ 10,
345
+ );
346
+ if (Number.isFinite(override)) return Math.max(0, Math.floor(override));
347
+ return DEFAULT_LAUNCH_SLOT_RELEASE_DELAY_MS;
348
+ }
349
+
220
350
  function releaseLaunchSlotAfterDelay(
221
351
  delayMs: number,
222
352
  release: () => void,
@@ -225,12 +355,15 @@ function releaseLaunchSlotAfterDelay(
225
355
  release();
226
356
  return;
227
357
  }
228
- const timer = setTimeout(release, delayMs);
229
- timer.unref?.();
358
+ setTimeout(release, delayMs);
230
359
  }
231
360
 
232
- async function runWithLaunchSlot<T>(action: () => Promise<T>): Promise<T> {
361
+ async function runWithLaunchSlot<T>(
362
+ action: () => Promise<T>,
363
+ onAcquired?: () => void,
364
+ ): Promise<T> {
233
365
  const release = await acquireLaunchSlot();
366
+ onAcquired?.();
234
367
  let holdAfterReturn = false;
235
368
  try {
236
369
  const result = await action();
@@ -238,7 +371,7 @@ async function runWithLaunchSlot<T>(action: () => Promise<T>): Promise<T> {
238
371
  return result;
239
372
  } finally {
240
373
  releaseLaunchSlotAfterDelay(
241
- holdAfterReturn ? launchSlotReleaseDelayMs : 0,
374
+ holdAfterReturn ? resolveLaunchSlotReleaseDelayMs() : 0,
242
375
  release,
243
376
  );
244
377
  }
@@ -259,13 +392,571 @@ function sleep(ms: number): Promise<void> {
259
392
  return new Promise((resolve) => setTimeout(resolve, ms));
260
393
  }
261
394
 
395
+ type UsageMetricKey = keyof WorkflowTaskUsageValues;
396
+ const USAGE_METRIC_KEYS: UsageMetricKey[] = [
397
+ "inputTokens",
398
+ "outputTokens",
399
+ "totalTokens",
400
+ "cachedInputTokens",
401
+ "cacheCreationInputTokens",
402
+ "cacheReadInputTokens",
403
+ "reasoningTokens",
404
+ "costUsd",
405
+ ];
406
+ const USAGE_FIELD_ALIASES: Record<
407
+ UsageMetricKey,
408
+ readonly (readonly string[])[]
409
+ > = {
410
+ inputTokens: [
411
+ ["inputTokens"],
412
+ ["input_tokens"],
413
+ ["input"],
414
+ ["promptTokens"],
415
+ ["prompt_tokens"],
416
+ ],
417
+ outputTokens: [
418
+ ["outputTokens"],
419
+ ["output_tokens"],
420
+ ["output"],
421
+ ["completionTokens"],
422
+ ["completion_tokens"],
423
+ ],
424
+ totalTokens: [["totalTokens"], ["total_tokens"], ["tokens"], ["total"]],
425
+ cachedInputTokens: [
426
+ ["cachedInputTokens"],
427
+ ["cached_input_tokens"],
428
+ ["prompt_tokens_details", "cached_tokens"],
429
+ ["input_tokens_details", "cached_tokens"],
430
+ ],
431
+ cacheCreationInputTokens: [
432
+ ["cacheCreationInputTokens"],
433
+ ["cacheCreationTokens"],
434
+ ["cacheWriteTokens"],
435
+ ["cache_creation_input_tokens"],
436
+ ["cache_write_input_tokens"],
437
+ ["cacheWrite"],
438
+ ["cache_write"],
439
+ ],
440
+ cacheReadInputTokens: [
441
+ ["cacheReadInputTokens"],
442
+ ["cacheReadTokens"],
443
+ ["cache_read_input_tokens"],
444
+ ["cacheRead"],
445
+ ["cache_read"],
446
+ ],
447
+ reasoningTokens: [
448
+ ["reasoningTokens"],
449
+ ["reasoning_tokens"],
450
+ ["reasoning"],
451
+ ["completion_tokens_details", "reasoning_tokens"],
452
+ ["output_tokens_details", "reasoning_tokens"],
453
+ ],
454
+ costUsd: [
455
+ ["costUsd"],
456
+ ["cost_usd"],
457
+ ["totalCostUsd"],
458
+ ["total_cost_usd"],
459
+ ["estimatedCostUsd"],
460
+ ["estimated_cost_usd"],
461
+ ["cost", "total"],
462
+ ["cost", "totalUsd"],
463
+ ["cost", "total_usd"],
464
+ ],
465
+ };
466
+
467
+ type TimingAggregateKey =
468
+ | "launchWaitMs"
469
+ | "launchDurationMs"
470
+ | "executionMs"
471
+ | "totalMs";
472
+ const TIMING_AGGREGATE_KEYS: TimingAggregateKey[] = [
473
+ "launchWaitMs",
474
+ "launchDurationMs",
475
+ "executionMs",
476
+ "totalMs",
477
+ ];
478
+
479
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
480
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
481
+ }
482
+
483
+ function hasOwnValue(record: object, key: string): boolean {
484
+ return Object.hasOwn(record, key);
485
+ }
486
+
487
+ function valueAtPath(
488
+ record: Record<string, unknown>,
489
+ path: readonly string[],
490
+ ): { found: boolean; value: unknown } {
491
+ let current: unknown = record;
492
+ for (const part of path) {
493
+ if (!isPlainRecord(current) || !hasOwnValue(current, part)) {
494
+ return { found: false, value: undefined };
495
+ }
496
+ current = current[part];
497
+ }
498
+ return { found: true, value: current };
499
+ }
500
+
501
+ function usageNumberOrNull(value: unknown): number | null | undefined {
502
+ if (value === null) return null;
503
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
504
+ return value;
505
+ }
506
+ return undefined;
507
+ }
508
+
509
+ function normalizedUsageValues(raw: unknown): WorkflowTaskUsageValues {
510
+ const record = isPlainRecord(raw) ? raw : undefined;
511
+ const values: WorkflowTaskUsageValues = {};
512
+ if (!record) return values;
513
+ for (const key of USAGE_METRIC_KEYS) {
514
+ for (const path of USAGE_FIELD_ALIASES[key]) {
515
+ const candidate = valueAtPath(record, path);
516
+ if (!candidate.found) continue;
517
+ const value = usageNumberOrNull(candidate.value);
518
+ if (value === undefined) continue;
519
+ values[key] = value;
520
+ break;
521
+ }
522
+ }
523
+ return values;
524
+ }
525
+
526
+ function firstStringValue(
527
+ records: Array<Record<string, unknown> | undefined>,
528
+ keys: string[],
529
+ ): string | undefined {
530
+ for (const record of records) {
531
+ if (!record) continue;
532
+ for (const key of keys) {
533
+ const value = record[key];
534
+ if (typeof value === "string" && value.trim()) return value;
535
+ }
536
+ }
537
+ return undefined;
538
+ }
539
+
540
+ function metadataRecord(value: unknown): Record<string, unknown> | undefined {
541
+ if (!isPlainRecord(value)) return undefined;
542
+ return isPlainRecord(value.metadata) ? value.metadata : undefined;
543
+ }
544
+
545
+ function usageObservation(
546
+ subagentResult: Record<string, unknown> | undefined,
547
+ snapshot: SubagentRunStatusSnapshot,
548
+ ): { source: string; raw: unknown; present: true } | undefined {
549
+ const resultMetadata = metadataRecord(subagentResult);
550
+ if (resultMetadata && hasOwnValue(resultMetadata, "usage")) {
551
+ return {
552
+ source: "subagent-result-metadata",
553
+ raw: resultMetadata.usage,
554
+ present: true,
555
+ };
556
+ }
557
+ const snapshotMetadata = isPlainRecord(snapshot.metadata)
558
+ ? snapshot.metadata
559
+ : undefined;
560
+ if (snapshotMetadata && hasOwnValue(snapshotMetadata, "usage")) {
561
+ return {
562
+ source: "subagent-snapshot-metadata",
563
+ raw: snapshotMetadata.usage,
564
+ present: true,
565
+ };
566
+ }
567
+ if (subagentResult && hasOwnValue(subagentResult, "usage")) {
568
+ return {
569
+ source: "subagent-result",
570
+ raw: subagentResult.usage,
571
+ present: true,
572
+ };
573
+ }
574
+ const snapshotRecord = snapshot as unknown as Record<string, unknown>;
575
+ if (hasOwnValue(snapshotRecord, "usage")) {
576
+ return {
577
+ source: "subagent-snapshot",
578
+ raw: snapshotRecord.usage,
579
+ present: true,
580
+ };
581
+ }
582
+ return undefined;
583
+ }
584
+
585
+ function buildTaskUsageAttempt(options: {
586
+ task: WorkflowTaskRunRecord;
587
+ snapshot: SubagentRunStatusSnapshot;
588
+ subagentResult?: Record<string, unknown>;
589
+ capturedAt: string;
590
+ }): WorkflowTaskUsageAttemptRecord {
591
+ const resultMetadata = metadataRecord(options.subagentResult);
592
+ const snapshotMetadata = isPlainRecord(options.snapshot.metadata)
593
+ ? options.snapshot.metadata
594
+ : undefined;
595
+ const resultRecord = options.subagentResult;
596
+ const snapshotRecord = options.snapshot as unknown as Record<string, unknown>;
597
+ const records = [
598
+ resultMetadata,
599
+ snapshotMetadata,
600
+ resultRecord,
601
+ snapshotRecord,
602
+ ];
603
+ const observed = usageObservation(options.subagentResult, options.snapshot);
604
+ const raw = observed?.raw;
605
+ const unavailable = !observed || raw === null || raw === undefined;
606
+ const provider = firstStringValue(records, ["provider"]);
607
+ const model =
608
+ firstStringValue(records, ["model"]) ?? options.task.runtime.model;
609
+ const thinking =
610
+ firstStringValue(records, [
611
+ "thinking",
612
+ "thinkingLevel",
613
+ "reasoningLevel",
614
+ ]) ??
615
+ options.task.runtime.thinkingResolution?.resolved ??
616
+ options.task.runtime.thinking;
617
+ return {
618
+ source: observed?.source ?? "subagent-usage-unavailable",
619
+ capturedAt: options.capturedAt,
620
+ backendRunId: options.snapshot.runId,
621
+ backendAttemptId: options.snapshot.attemptId,
622
+ ...(provider === undefined ? {} : { provider }),
623
+ ...(model === undefined ? {} : { model }),
624
+ ...(thinking === undefined ? {} : { thinking }),
625
+ ...(unavailable ? { unavailable: true as const } : {}),
626
+ ...(observed?.present && raw !== undefined ? { raw } : {}),
627
+ ...normalizedUsageValues(raw),
628
+ };
629
+ }
630
+
631
+ function usageAttemptKey(attempt: WorkflowTaskUsageAttemptRecord): string {
632
+ return `${attempt.backendRunId ?? ""}\0${attempt.backendAttemptId ?? ""}\0${attempt.source}`;
633
+ }
634
+
635
+ function upsertUsageAttempt(
636
+ attempts: WorkflowTaskUsageAttemptRecord[],
637
+ attempt: WorkflowTaskUsageAttemptRecord,
638
+ ): WorkflowTaskUsageAttemptRecord[] {
639
+ const key = usageAttemptKey(attempt);
640
+ const index = attempts.findIndex(
641
+ (candidate) => usageAttemptKey(candidate) === key,
642
+ );
643
+ if (index < 0) return [...attempts, attempt];
644
+ return attempts.map((candidate, candidateIndex) =>
645
+ candidateIndex === index ? attempt : candidate,
646
+ );
647
+ }
648
+
649
+ function aggregateUsageAttempts(attempts: WorkflowTaskUsageAttemptRecord[]): {
650
+ values: WorkflowTaskUsageValues;
651
+ incomplete: boolean;
652
+ } {
653
+ const values: WorkflowTaskUsageValues = {};
654
+ let incomplete = attempts.some((attempt) => attempt.unavailable === true);
655
+ for (const key of USAGE_METRIC_KEYS) {
656
+ const anyPresent = attempts.some((attempt) => hasOwnValue(attempt, key));
657
+ if (!anyPresent) continue;
658
+ let total = 0;
659
+ let complete = true;
660
+ for (const attempt of attempts) {
661
+ if (!hasOwnValue(attempt, key)) {
662
+ complete = false;
663
+ break;
664
+ }
665
+ const value = attempt[key];
666
+ if (typeof value !== "number") {
667
+ complete = false;
668
+ break;
669
+ }
670
+ total += value;
671
+ }
672
+ values[key] = complete ? total : null;
673
+ if (!complete) incomplete = true;
674
+ }
675
+ return { values, incomplete };
676
+ }
677
+
678
+ function latestUsageString(
679
+ attempts: WorkflowTaskUsageAttemptRecord[],
680
+ key: "provider" | "model" | "thinking",
681
+ ): string | undefined {
682
+ for (let index = attempts.length - 1; index >= 0; index -= 1) {
683
+ const value = attempts[index]?.[key];
684
+ if (typeof value === "string" && value.trim()) return value;
685
+ }
686
+ return undefined;
687
+ }
688
+
689
+ function recordTaskUsageObservation(options: {
690
+ task: WorkflowTaskRunRecord;
691
+ snapshot: SubagentRunStatusSnapshot;
692
+ subagentResult?: Record<string, unknown>;
693
+ capturedAt: string;
694
+ }): void {
695
+ const attempt = buildTaskUsageAttempt(options);
696
+ const attempts = upsertUsageAttempt(
697
+ options.task.usage?.attempts ?? [],
698
+ attempt,
699
+ );
700
+ const aggregate = aggregateUsageAttempts(attempts);
701
+ const usage: WorkflowTaskUsageRecord = {
702
+ source: "pi-subagent",
703
+ capturedAt: options.capturedAt,
704
+ ...(latestUsageString(attempts, "provider") === undefined
705
+ ? {}
706
+ : { provider: latestUsageString(attempts, "provider") }),
707
+ ...(latestUsageString(attempts, "model") === undefined
708
+ ? {}
709
+ : { model: latestUsageString(attempts, "model") }),
710
+ ...(latestUsageString(attempts, "thinking") === undefined
711
+ ? {}
712
+ : { thinking: latestUsageString(attempts, "thinking") }),
713
+ ...(aggregate.incomplete ? { incomplete: true } : {}),
714
+ ...aggregate.values,
715
+ aggregate: {
716
+ attempts: attempts.length,
717
+ ...(aggregate.incomplete ? { incomplete: true } : {}),
718
+ ...aggregate.values,
719
+ },
720
+ attempts,
721
+ };
722
+ options.task.usage = usage;
723
+ }
724
+
725
+ function isoTimestampMs(timestamp: string | undefined): number | undefined {
726
+ if (!timestamp) return undefined;
727
+ const parsed = Date.parse(timestamp);
728
+ return Number.isFinite(parsed) ? parsed : undefined;
729
+ }
730
+
731
+ function durationBetween(
732
+ startedAt: string | undefined,
733
+ completedAt: string | undefined,
734
+ ): number | undefined {
735
+ const startedAtMs = isoTimestampMs(startedAt);
736
+ const completedAtMs = isoTimestampMs(completedAt);
737
+ if (startedAtMs === undefined || completedAtMs === undefined)
738
+ return undefined;
739
+ return Math.max(0, completedAtMs - startedAtMs);
740
+ }
741
+
742
+ function durationNumber(value: unknown): number | null | undefined {
743
+ if (value === null) return null;
744
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
745
+ return value;
746
+ }
747
+ return undefined;
748
+ }
749
+
750
+ function recordTaskLaunchTiming(
751
+ task: WorkflowTaskRunRecord,
752
+ observation: {
753
+ launchQueuedAt: string;
754
+ launchStartedAt?: string;
755
+ launchCompletedAt?: string;
756
+ },
757
+ ): void {
758
+ const capturedAt = observation.launchCompletedAt ?? nowIso();
759
+ const launchWaitMs = durationBetween(
760
+ observation.launchQueuedAt,
761
+ observation.launchStartedAt,
762
+ );
763
+ const launchDurationMs = durationBetween(
764
+ observation.launchStartedAt,
765
+ observation.launchCompletedAt,
766
+ );
767
+ task.timing = {
768
+ source: "pi-workflow",
769
+ capturedAt,
770
+ launchQueuedAt: observation.launchQueuedAt,
771
+ ...(observation.launchStartedAt === undefined
772
+ ? {}
773
+ : { launchStartedAt: observation.launchStartedAt }),
774
+ ...(observation.launchCompletedAt === undefined
775
+ ? {}
776
+ : { launchCompletedAt: observation.launchCompletedAt }),
777
+ ...(launchWaitMs === undefined ? {} : { launchWaitMs }),
778
+ ...(launchDurationMs === undefined ? {} : { launchDurationMs }),
779
+ launchSlotReleaseDelayMs: resolveLaunchSlotReleaseDelayMs(),
780
+ ...(task.timing?.aggregate === undefined
781
+ ? {}
782
+ : { aggregate: task.timing.aggregate }),
783
+ ...(task.timing?.attempts === undefined
784
+ ? {}
785
+ : { attempts: task.timing.attempts }),
786
+ };
787
+ }
788
+
789
+ function buildTaskTimingAttempt(options: {
790
+ task: WorkflowTaskRunRecord;
791
+ snapshot: SubagentRunStatusSnapshot;
792
+ subagentResult?: Record<string, unknown>;
793
+ startedAt?: string;
794
+ completedAt?: string;
795
+ capturedAt: string;
796
+ }): WorkflowTaskTimingAttemptRecord {
797
+ const resultDuration = options.subagentResult?.durationMs;
798
+ let executionMs = durationNumber(
799
+ resultDuration === undefined ? options.snapshot.durationMs : resultDuration,
800
+ );
801
+ if (executionMs === undefined || executionMs === null) {
802
+ executionMs =
803
+ durationBetween(options.startedAt, options.completedAt) ?? executionMs;
804
+ }
805
+ const totalMs = durationBetween(
806
+ options.task.startedAt ?? options.task.timing?.launchQueuedAt,
807
+ options.completedAt,
808
+ );
809
+ return {
810
+ source: "pi-subagent",
811
+ capturedAt: options.capturedAt,
812
+ backendRunId: options.snapshot.runId,
813
+ backendAttemptId: options.snapshot.attemptId,
814
+ ...(options.task.timing?.launchQueuedAt === undefined
815
+ ? {}
816
+ : { launchQueuedAt: options.task.timing.launchQueuedAt }),
817
+ ...(options.task.timing?.launchStartedAt === undefined
818
+ ? {}
819
+ : { launchStartedAt: options.task.timing.launchStartedAt }),
820
+ ...(options.task.timing?.launchCompletedAt === undefined
821
+ ? {}
822
+ : { launchCompletedAt: options.task.timing.launchCompletedAt }),
823
+ ...(options.task.timing?.launchWaitMs === undefined
824
+ ? {}
825
+ : { launchWaitMs: options.task.timing.launchWaitMs }),
826
+ ...(options.task.timing?.launchDurationMs === undefined
827
+ ? {}
828
+ : { launchDurationMs: options.task.timing.launchDurationMs }),
829
+ ...(options.startedAt === undefined
830
+ ? {}
831
+ : { executionStartedAt: options.startedAt }),
832
+ ...(options.completedAt === undefined
833
+ ? {}
834
+ : { executionCompletedAt: options.completedAt }),
835
+ ...(executionMs === undefined ? {} : { executionMs }),
836
+ ...(totalMs === undefined ? {} : { totalMs }),
837
+ };
838
+ }
839
+
840
+ function timingAttemptKey(attempt: WorkflowTaskTimingAttemptRecord): string {
841
+ return `${attempt.backendRunId ?? ""}\0${attempt.backendAttemptId ?? ""}`;
842
+ }
843
+
844
+ function upsertTimingAttempt(
845
+ attempts: WorkflowTaskTimingAttemptRecord[],
846
+ attempt: WorkflowTaskTimingAttemptRecord,
847
+ ): WorkflowTaskTimingAttemptRecord[] {
848
+ const key = timingAttemptKey(attempt);
849
+ const index = attempts.findIndex(
850
+ (candidate) => timingAttemptKey(candidate) === key,
851
+ );
852
+ if (index < 0) return [...attempts, attempt];
853
+ return attempts.map((candidate, candidateIndex) =>
854
+ candidateIndex === index ? attempt : candidate,
855
+ );
856
+ }
857
+
858
+ function aggregateTimingAttempts(
859
+ attempts: WorkflowTaskTimingAttemptRecord[],
860
+ ): NonNullable<WorkflowTaskTimingRecord["aggregate"]> {
861
+ const aggregate: NonNullable<WorkflowTaskTimingRecord["aggregate"]> = {
862
+ attempts: attempts.length,
863
+ };
864
+ let incomplete = false;
865
+ for (const key of TIMING_AGGREGATE_KEYS) {
866
+ const anyPresent = attempts.some((attempt) => hasOwnValue(attempt, key));
867
+ if (!anyPresent) continue;
868
+ let total = 0;
869
+ let complete = true;
870
+ for (const attempt of attempts) {
871
+ if (!hasOwnValue(attempt, key)) {
872
+ complete = false;
873
+ break;
874
+ }
875
+ const value = attempt[key];
876
+ if (typeof value !== "number") {
877
+ complete = false;
878
+ break;
879
+ }
880
+ total += value;
881
+ }
882
+ aggregate[key] = complete ? total : null;
883
+ if (!complete) incomplete = true;
884
+ }
885
+ if (incomplete) aggregate.incomplete = true;
886
+ return aggregate;
887
+ }
888
+
889
+ function recordTaskTerminalTiming(options: {
890
+ task: WorkflowTaskRunRecord;
891
+ snapshot: SubagentRunStatusSnapshot;
892
+ subagentResult?: Record<string, unknown>;
893
+ startedAt?: string;
894
+ completedAt?: string;
895
+ capturedAt: string;
896
+ }): void {
897
+ const attempt = buildTaskTimingAttempt(options);
898
+ const attempts = upsertTimingAttempt(
899
+ options.task.timing?.attempts ?? [],
900
+ attempt,
901
+ );
902
+ options.task.timing = {
903
+ source: "pi-workflow",
904
+ capturedAt: options.capturedAt,
905
+ ...(attempt.launchQueuedAt === undefined
906
+ ? {}
907
+ : { launchQueuedAt: attempt.launchQueuedAt }),
908
+ ...(attempt.launchStartedAt === undefined
909
+ ? {}
910
+ : { launchStartedAt: attempt.launchStartedAt }),
911
+ ...(attempt.launchCompletedAt === undefined
912
+ ? {}
913
+ : { launchCompletedAt: attempt.launchCompletedAt }),
914
+ ...(attempt.launchWaitMs === undefined
915
+ ? {}
916
+ : { launchWaitMs: attempt.launchWaitMs }),
917
+ ...(attempt.launchDurationMs === undefined
918
+ ? {}
919
+ : { launchDurationMs: attempt.launchDurationMs }),
920
+ ...(options.task.timing?.launchSlotReleaseDelayMs === undefined
921
+ ? {}
922
+ : {
923
+ launchSlotReleaseDelayMs:
924
+ options.task.timing.launchSlotReleaseDelayMs,
925
+ }),
926
+ ...(attempt.executionStartedAt === undefined
927
+ ? {}
928
+ : { executionStartedAt: attempt.executionStartedAt }),
929
+ ...(attempt.executionCompletedAt === undefined
930
+ ? {}
931
+ : { executionCompletedAt: attempt.executionCompletedAt }),
932
+ ...(attempt.executionMs === undefined
933
+ ? {}
934
+ : { executionMs: attempt.executionMs }),
935
+ ...(attempt.totalMs === undefined ? {} : { totalMs: attempt.totalMs }),
936
+ aggregate: aggregateTimingAttempts(attempts),
937
+ attempts,
938
+ };
939
+ }
940
+
941
+ function recordTerminalTaskObservability(options: {
942
+ task: WorkflowTaskRunRecord;
943
+ snapshot: SubagentRunStatusSnapshot;
944
+ subagentResult?: Record<string, unknown>;
945
+ startedAt?: string;
946
+ completedAt?: string;
947
+ }): void {
948
+ const capturedAt = nowIso();
949
+ recordTaskUsageObservation({ ...options, capturedAt });
950
+ recordTaskTerminalTiming({ ...options, capturedAt });
951
+ }
952
+
262
953
  export function setSubagentLaunchControlsForTests(options?: {
263
954
  releaseDelayMs?: number;
264
955
  retryJitterMs?: number | (() => number);
265
956
  }): void {
266
- launchSlotReleaseDelayMs =
957
+ launchSlotReleaseDelayMsForTests =
267
958
  options?.releaseDelayMs === undefined
268
- ? DEFAULT_LAUNCH_SLOT_RELEASE_DELAY_MS
959
+ ? undefined
269
960
  : Math.max(0, Math.floor(options.releaseDelayMs));
270
961
  transientRetryJitterForTests =
271
962
  options?.retryJitterMs === undefined
@@ -282,7 +973,6 @@ export async function cleanupSubagentRun(
282
973
  run: WorkflowRunRecord,
283
974
  ): Promise<void> {
284
975
  for (const task of run.tasks) {
285
- if (isTerminalTaskStatus(task.status)) continue;
286
976
  const handle = getSubagentHandle(task);
287
977
  if (!handle) continue;
288
978
  const api = await loadSubagentApi();
@@ -377,11 +1067,25 @@ export async function launchSubagentTask(
377
1067
  };
378
1068
  subagentOptions.extensions = extensions;
379
1069
  if (captureToolCallsEnabled()) subagentOptions.captureToolCalls = true;
1070
+ const launchQueuedAt = nowIso();
1071
+ let launchStartedAt: string | undefined;
1072
+ recordTaskLaunchTiming(task, { launchQueuedAt });
380
1073
  if (isLaunchGateSaturated()) {
381
1074
  task.lastMessage = `waiting for pi-subagent launch slot (${resolveMaxConcurrentLaunches()} max)`;
382
1075
  await writeRunRecord(cwd, run).catch(() => undefined);
383
1076
  }
384
- launched = await runWithLaunchSlot(() => api.runSubagent(subagentOptions));
1077
+ launched = await runWithLaunchSlot(
1078
+ () => api.runSubagent(subagentOptions),
1079
+ () => {
1080
+ launchStartedAt = nowIso();
1081
+ recordTaskLaunchTiming(task, { launchQueuedAt, launchStartedAt });
1082
+ },
1083
+ );
1084
+ recordTaskLaunchTiming(task, {
1085
+ launchQueuedAt,
1086
+ launchStartedAt,
1087
+ launchCompletedAt: nowIso(),
1088
+ });
385
1089
  } catch (error) {
386
1090
  task.status = "pending";
387
1091
  task.statusDetail = "pending";
@@ -409,6 +1113,13 @@ export async function launchSubagentTask(
409
1113
  task.statusDetail = "running";
410
1114
  task.lastMessage = "launched via pi-subagent/headless";
411
1115
  await writeRunRecord(cwd, run).catch(() => undefined);
1116
+ await recordParentSubagentChildEvent({
1117
+ event: "started",
1118
+ childRunId: launched.runId,
1119
+ run,
1120
+ task,
1121
+ message: task.lastMessage,
1122
+ });
412
1123
  return { kind: "launched" };
413
1124
  }
414
1125
 
@@ -440,8 +1151,13 @@ export async function refreshRunFromSubagentArtifacts(
440
1151
  }
441
1152
  }
442
1153
  if (!handle) {
1154
+ if (isStaleLaunchClaim(task)) {
1155
+ resetStaleLaunchClaim(task);
1156
+ changed = true;
1157
+ continue;
1158
+ }
443
1159
  if (isTaskTimedOut(task)) {
444
- markTaskTimedOut(task);
1160
+ markSubagentTaskTimedOut(task);
445
1161
  changed = true;
446
1162
  }
447
1163
  continue;
@@ -466,16 +1182,8 @@ export async function refreshRunFromSubagentArtifacts(
466
1182
 
467
1183
  if (snapshot === null) {
468
1184
  if (isTaskTimedOut(task)) {
469
- await api
470
- .interruptSubagent({
471
- cwd: handle.cwd,
472
- runsDir: handle.runsDir,
473
- runId: handle.runId,
474
- attemptId: handle.attemptId,
475
- reason: "workflow timeout",
476
- })
477
- .catch(() => undefined);
478
- markTaskTimedOut(task);
1185
+ await interruptTimedOutSubagent(api, handle);
1186
+ markSubagentTaskTimedOut(task);
479
1187
  changed = true;
480
1188
  }
481
1189
  continue;
@@ -485,23 +1193,29 @@ export async function refreshRunFromSubagentArtifacts(
485
1193
  snapshot.attempts?.find(
486
1194
  (attempt) => attempt.attemptId === handle.attemptId,
487
1195
  ) ?? snapshot.attempts?.at(-1);
488
- task.pid = activeAttempt?.workerPid ?? activeAttempt?.pid ?? task.pid;
1196
+ const nextPid = activeAttempt?.workerPid ?? activeAttempt?.pid ?? task.pid;
1197
+ if (task.pid !== nextPid) {
1198
+ task.pid = nextPid;
1199
+ changed = true;
1200
+ }
489
1201
  if (snapshot.status === "running" || snapshot.status === "pending") {
490
- task.statusDetail = "running";
491
- task.lastMessage = activeAttempt?.heartbeatAt
1202
+ await refreshRunningArtifactGraphPartialOutput(cwd, task, snapshot).catch(
1203
+ () => undefined,
1204
+ );
1205
+ if (task.statusDetail !== "running") {
1206
+ task.statusDetail = "running";
1207
+ changed = true;
1208
+ }
1209
+ const nextLastMessage = activeAttempt?.heartbeatAt
492
1210
  ? `pi-subagent heartbeat ${activeAttempt.heartbeatAt}`
493
1211
  : "pi-subagent running";
1212
+ if (task.lastMessage !== nextLastMessage) {
1213
+ task.lastMessage = nextLastMessage;
1214
+ changed = true;
1215
+ }
494
1216
  if (isTaskTimedOut(task)) {
495
- await api
496
- .interruptSubagent({
497
- cwd: handle.cwd,
498
- runsDir: handle.runsDir,
499
- runId: handle.runId,
500
- attemptId: handle.attemptId,
501
- reason: "workflow timeout",
502
- })
503
- .catch(() => undefined);
504
- markTaskTimedOut(task);
1217
+ await interruptTimedOutSubagent(api, handle);
1218
+ markSubagentTaskTimedOut(task);
505
1219
  changed = true;
506
1220
  }
507
1221
  continue;
@@ -515,6 +1229,68 @@ export async function refreshRunFromSubagentArtifacts(
515
1229
  return run;
516
1230
  }
517
1231
 
1232
+ async function refreshRunningArtifactGraphPartialOutput(
1233
+ cwd: string,
1234
+ task: WorkflowTaskRunRecord,
1235
+ snapshot: SubagentRunStatusSnapshot,
1236
+ ): Promise<void> {
1237
+ const partial = task.artifactGraph?.output.partial;
1238
+ if (!partial || partial.paths.length === 0) return;
1239
+ const outputRef = findLog(snapshot, "output");
1240
+ const outputFile = fromProjectPath(cwd, task.files.output);
1241
+ const artifactRoot = task.backendFiles?.runsDir
1242
+ ? fromProjectPath(task.cwd, task.backendFiles.runsDir)
1243
+ : undefined;
1244
+ await copyLogOrEmpty(snapshot, outputRef, outputFile, artifactRoot);
1245
+ await writeWorkflowPartialOutputLedgerFromFile({
1246
+ taskDir: dirname(fromProjectPath(cwd, task.files.result)),
1247
+ outputFile,
1248
+ allowedPaths: partial.paths,
1249
+ });
1250
+ }
1251
+
1252
+ async function interruptTimedOutSubagent(
1253
+ api: Awaited<ReturnType<typeof loadSubagentApi>>,
1254
+ handle: NonNullable<WorkflowTaskRunRecord["backendHandle"]>,
1255
+ ): Promise<void> {
1256
+ await api
1257
+ .interruptSubagent({
1258
+ cwd: handle.cwd,
1259
+ runsDir: handle.runsDir,
1260
+ runId: handle.runId,
1261
+ attemptId: handle.attemptId,
1262
+ reason: "workflow timeout",
1263
+ })
1264
+ .catch(() => undefined);
1265
+ }
1266
+
1267
+ function markSubagentTaskTimedOut(task: WorkflowTaskRunRecord): void {
1268
+ markTaskTimedOut(task);
1269
+ task.backendHandle = undefined;
1270
+ task.backendTaskId = task.taskId;
1271
+ task.pid = undefined;
1272
+ }
1273
+
1274
+ function isStaleLaunchClaim(task: WorkflowTaskRunRecord): boolean {
1275
+ if (task.statusDetail !== "launching" || !task.startedAt) return false;
1276
+ const startedAtMs = Date.parse(task.startedAt);
1277
+ return (
1278
+ Number.isFinite(startedAtMs) &&
1279
+ Date.now() - startedAtMs > STALE_LAUNCH_CLAIM_GRACE_MS
1280
+ );
1281
+ }
1282
+
1283
+ function resetStaleLaunchClaim(task: WorkflowTaskRunRecord): void {
1284
+ task.status = "pending";
1285
+ task.statusDetail = "pending";
1286
+ task.startedAt = undefined;
1287
+ task.backendHandle = undefined;
1288
+ task.backendFiles = undefined;
1289
+ task.backendTaskId = task.taskId;
1290
+ task.pid = undefined;
1291
+ task.lastMessage = "stale pi-subagent launch claim reset";
1292
+ }
1293
+
518
1294
  async function materializeTerminalSubagentResult(
519
1295
  cwd: string,
520
1296
  run: WorkflowRunRecord,
@@ -592,16 +1368,30 @@ async function materializeTerminalSubagentResult(
592
1368
  (subagentResult?.metadata as any)?.contextLengthExceeded ??
593
1369
  snapshot.metadata?.contextLengthExceeded,
594
1370
  );
1371
+ recordTerminalTaskObservability({
1372
+ task,
1373
+ snapshot,
1374
+ subagentResult,
1375
+ startedAt,
1376
+ completedAt,
1377
+ });
595
1378
  if (task.artifactGraph?.enabled && statusInfo.status === "completed") {
596
- return await materializeTerminalArtifactGraphResult(cwd, run, task, {
597
- outputFile,
598
- stderrFile,
599
- resultFile,
600
- completedAt,
601
- startedAt,
602
- exitCode,
603
- subagentResult,
604
- });
1379
+ const changed = await materializeTerminalArtifactGraphResult(
1380
+ cwd,
1381
+ run,
1382
+ task,
1383
+ {
1384
+ outputFile,
1385
+ stderrFile,
1386
+ resultFile,
1387
+ completedAt,
1388
+ startedAt,
1389
+ exitCode,
1390
+ subagentResult,
1391
+ },
1392
+ );
1393
+ await recordTerminalParentSubagentChildEvent(run, task, snapshot);
1394
+ return changed;
605
1395
  }
606
1396
  if (
607
1397
  shouldAttemptArtifactGraphSalvage({
@@ -615,20 +1405,28 @@ async function materializeTerminalSubagentResult(
615
1405
  snapshot,
616
1406
  })
617
1407
  ) {
618
- return await materializeTerminalArtifactGraphResult(cwd, run, task, {
619
- outputFile,
620
- stderrFile,
621
- resultFile,
622
- completedAt,
623
- startedAt,
624
- exitCode,
625
- subagentResult,
626
- salvage: {
627
- failureKind: statusInfo.failureKind ?? snapshot.failureKind ?? "model",
628
- subagentStatus: snapshot.status,
629
- subagentFailureKind: snapshot.failureKind,
1408
+ const changed = await materializeTerminalArtifactGraphResult(
1409
+ cwd,
1410
+ run,
1411
+ task,
1412
+ {
1413
+ outputFile,
1414
+ stderrFile,
1415
+ resultFile,
1416
+ completedAt,
1417
+ startedAt,
1418
+ exitCode,
1419
+ subagentResult,
1420
+ salvage: {
1421
+ failureKind:
1422
+ statusInfo.failureKind ?? snapshot.failureKind ?? "model",
1423
+ subagentStatus: snapshot.status,
1424
+ subagentFailureKind: snapshot.failureKind,
1425
+ },
630
1426
  },
631
- });
1427
+ );
1428
+ await recordTerminalParentSubagentChildEvent(run, task, snapshot);
1429
+ return changed;
632
1430
  }
633
1431
  const workflowResult = {
634
1432
  status: statusInfo.status,
@@ -664,10 +1462,12 @@ async function materializeTerminalSubagentResult(
664
1462
  ),
665
1463
  workflowResult,
666
1464
  );
667
- return retryOrFailTransientSubagentFailure(task, {
1465
+ const changed = retryOrFailTransientSubagentFailure(task, {
668
1466
  reason: statusInfo.failureKind ?? "model",
669
1467
  message: errorMessage ?? "pi-subagent run failed before producing output",
670
1468
  });
1469
+ await recordTerminalParentSubagentChildEvent(run, task, snapshot);
1470
+ return changed;
671
1471
  }
672
1472
  await writeJson(resultFile, workflowResult);
673
1473
 
@@ -682,6 +1482,7 @@ async function materializeTerminalSubagentResult(
682
1482
  delete task.backendHandle;
683
1483
  delete task.backendFiles;
684
1484
  }
1485
+ await recordTerminalParentSubagentChildEvent(run, task, snapshot);
685
1486
  return changed;
686
1487
  }
687
1488
 
@@ -737,6 +1538,13 @@ async function materializeTerminalArtifactGraphResult(
737
1538
  ): Promise<boolean> {
738
1539
  const rawOutput = await readFile(options.outputFile, "utf8").catch(() => "");
739
1540
  const artifactOptions = task.artifactGraph?.output;
1541
+ if (artifactOptions?.partial && artifactOptions.partial.paths.length > 0) {
1542
+ await writeWorkflowPartialOutputLedgerFromFile({
1543
+ taskDir: dirname(options.resultFile),
1544
+ outputFile: options.outputFile,
1545
+ allowedPaths: artifactOptions.partial.paths,
1546
+ }).catch(() => undefined);
1547
+ }
740
1548
  let controlJsonSchema: JsonSchema | undefined;
741
1549
  try {
742
1550
  controlJsonSchema = await readTaskControlJsonSchema(task);
@@ -1432,6 +2240,7 @@ async function workflowTaskExtensions(
1432
2240
  "source-cache",
1433
2241
  "fetch-content",
1434
2242
  ),
2243
+ maxInlineChars: fetchContentInlineCharsEnvValue(),
1435
2244
  },
1436
2245
  });
1437
2246
  extensions = uniqueStrings([
@@ -1536,6 +2345,17 @@ function fetchContentCacheEnvValue(): string | undefined {
1536
2345
  );
1537
2346
  }
1538
2347
 
2348
+ function fetchContentInlineCharsEnvValue(): number | undefined {
2349
+ const raw = process.env[FETCH_CONTENT_INLINE_CHARS_ENV];
2350
+ if (raw === undefined || raw.trim() === "")
2351
+ return DEFAULT_WORKFLOW_FETCH_CONTENT_INLINE_CHARS;
2352
+ if (isExplicitlyDisabled(raw)) return undefined;
2353
+ const parsed = Number(raw);
2354
+ if (!Number.isFinite(parsed))
2355
+ return DEFAULT_WORKFLOW_FETCH_CONTENT_INLINE_CHARS;
2356
+ return Math.max(1, Math.floor(parsed));
2357
+ }
2358
+
1539
2359
  function isExplicitlyDisabled(value: string | undefined): boolean {
1540
2360
  return typeof value === "string" && /^(0|false|no|off)$/i.test(value.trim());
1541
2361
  }
@@ -1674,6 +2494,7 @@ async function recoverSubagentHandle(
1674
2494
  const runsDir = subagentRunsDir(run, task);
1675
2495
  const absoluteRunsDir = resolve(task.cwd, runsDir);
1676
2496
  const expectedCorrelationId = `${run.runId}:${task.taskId}`;
2497
+ const claimStartedAtMs = timestampMs(task.startedAt);
1677
2498
  const entries = await readdir(absoluteRunsDir, { withFileTypes: true }).catch(
1678
2499
  () => [],
1679
2500
  );
@@ -1688,6 +2509,7 @@ async function recoverSubagentHandle(
1688
2509
  join(absoluteRunsDir, entry.name, "run.json"),
1689
2510
  );
1690
2511
  if (!record || record.correlationId !== expectedCorrelationId) continue;
2512
+ if (isPreClaimSubagentRecord(record, claimStartedAtMs)) continue;
1691
2513
  const attemptId =
1692
2514
  record.activeAttemptId ??
1693
2515
  record.latestAttemptId ??
@@ -1714,6 +2536,20 @@ async function recoverSubagentHandle(
1714
2536
  return candidates[0]?.handle;
1715
2537
  }
1716
2538
 
2539
+ function isPreClaimSubagentRecord(
2540
+ record: SubagentRunRecordLike,
2541
+ claimStartedAtMs: number | undefined,
2542
+ ): boolean {
2543
+ if (claimStartedAtMs === undefined) return false;
2544
+ const recordStartedAtMs =
2545
+ timestampMs(record.startedAt) ??
2546
+ timestampMs(record.attempts?.[0]?.startedAt) ??
2547
+ timestampMs(record.updatedAt);
2548
+ return (
2549
+ recordStartedAtMs !== undefined && recordStartedAtMs < claimStartedAtMs
2550
+ );
2551
+ }
2552
+
1717
2553
  function timestampMs(value: string | undefined): number | undefined {
1718
2554
  if (value === undefined) return undefined;
1719
2555
  const time = Date.parse(value);
@@ -1774,17 +2610,24 @@ function subagentSessionId(
1774
2610
  task: WorkflowTaskRunRecord,
1775
2611
  ): string | undefined {
1776
2612
  if (!task.artifactGraph?.enabled) return undefined;
1777
- return task.outputRetry?.sessionId ?? baseSubagentSessionId(run, task);
2613
+ const baseSessionId = baseSubagentSessionId(run, task);
2614
+ if (task.outputRetry?.sessionId) return task.outputRetry.sessionId;
2615
+ const launchAttempt = task.launchRetry?.attempts ?? 0;
2616
+ if (launchAttempt > 0)
2617
+ return boundedSubagentSessionId(
2618
+ `${baseSessionId}.launch-retry-${launchAttempt}`,
2619
+ );
2620
+ const resumeAttempt = task.resumeEvents?.length ?? 0;
2621
+ if (resumeAttempt > 0)
2622
+ return boundedSubagentSessionId(`${baseSessionId}.resume-${resumeAttempt}`);
2623
+ return baseSessionId;
1778
2624
  }
1779
2625
 
1780
2626
  function baseSubagentSessionId(
1781
2627
  run: WorkflowRunRecord,
1782
2628
  task: WorkflowTaskRunRecord,
1783
2629
  ): string {
1784
- return `pi-workflow.${run.runId}.${task.taskId}`.replace(
1785
- /[^A-Za-z0-9._-]/g,
1786
- "-",
1787
- );
2630
+ return boundedSubagentSessionId(`pi-workflow.${run.runId}.${task.taskId}`);
1788
2631
  }
1789
2632
 
1790
2633
  function retrySubagentSessionId(
@@ -1792,7 +2635,23 @@ function retrySubagentSessionId(
1792
2635
  task: WorkflowTaskRunRecord,
1793
2636
  attempt: number,
1794
2637
  ): string {
1795
- return `${baseSubagentSessionId(run, task)}.retry-${attempt}`;
2638
+ return boundedSubagentSessionId(
2639
+ `${baseSubagentSessionId(run, task)}.retry-${attempt}`,
2640
+ );
2641
+ }
2642
+
2643
+ function boundedSubagentSessionId(value: string): string {
2644
+ const sanitized = value.replace(/[^A-Za-z0-9._-]/g, "-");
2645
+ if (sanitized.length <= MAX_SUBAGENT_SESSION_ID_LENGTH) return sanitized;
2646
+ const digest = createHash("sha256")
2647
+ .update(sanitized)
2648
+ .digest("hex")
2649
+ .slice(0, 16);
2650
+ const suffix = sanitized.split(".").at(-1) || "session";
2651
+ const prefix = `piwf.${digest}`;
2652
+ const maxSuffixLength = MAX_SUBAGENT_SESSION_ID_LENGTH - prefix.length - 1;
2653
+ const boundedSuffix = suffix.slice(-Math.max(1, maxSuffixLength));
2654
+ return `${prefix}.${boundedSuffix}`;
1796
2655
  }
1797
2656
 
1798
2657
  function buildSystemPrompt(task: CompiledTask): string {