pi-subagents 0.65.1 → 0.67.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 (148) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/README.md +5 -4
  3. package/agents/evidence-auditor.md +34 -0
  4. package/agents/researcher.md +23 -13
  5. package/agents/reviewer.md +3 -2
  6. package/docs/agents.md +20 -3
  7. package/docs/configuration.md +25 -5
  8. package/docs/extension-api.md +124 -18
  9. package/docs/missions.md +8 -0
  10. package/docs/models.md +59 -2
  11. package/docs/observability.md +46 -6
  12. package/docs/standalone-background.md +49 -0
  13. package/docs/tool-reference.md +20 -10
  14. package/docs/watchdog.md +35 -4
  15. package/docs/workflows.md +40 -19
  16. package/inspector-runner.mjs +2 -2
  17. package/package.json +2 -1
  18. package/prompts/parallel-review.md +1 -1
  19. package/{runner-server-preload.mjs → runner-peer-preload.mjs} +8 -3
  20. package/skills/pi-subagents/SKILL.md +14 -0
  21. package/skills/pi-subagents/references/execution-controls.md +20 -5
  22. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -1
  23. package/skills/pi-subagents/references/prompting-and-roles.md +2 -2
  24. package/src/agents/advertised-agent-prompt.ts +94 -0
  25. package/src/agents/agent-management.ts +14 -1
  26. package/src/agents/agent-serializer.ts +2 -0
  27. package/src/agents/agents.ts +14 -0
  28. package/src/agents/builtin-names.ts +1 -0
  29. package/src/api/delegation.ts +4 -0
  30. package/src/api/preflight.ts +76 -45
  31. package/src/api/shared-types.ts +3 -1
  32. package/src/api/workflow-resources.ts +6 -0
  33. package/src/extension/fanout-child.ts +63 -4
  34. package/src/extension/index.ts +58 -8
  35. package/src/extension/public-execution.ts +4 -3
  36. package/src/extension/rpc.ts +8 -21
  37. package/src/extension/schemas.ts +71 -80
  38. package/src/extension/tool-description.ts +29 -81
  39. package/src/inspectors/actions.ts +148 -0
  40. package/src/inspectors/ghostty/actions.ts +74 -0
  41. package/src/inspectors/ghostty/plugin.ts +17 -0
  42. package/src/inspectors/herdr/actions.ts +99 -179
  43. package/src/inspectors/herdr/plugin.ts +20 -0
  44. package/src/inspectors/herdr/project-panes.ts +1 -1
  45. package/src/inspectors/{herdr/inspector-runner.ts → inspector-runner.ts} +12 -12
  46. package/src/inspectors/plugins.ts +8 -0
  47. package/src/inspectors/{herdr/session-roots-codec.ts → session-roots-codec.ts} +3 -14
  48. package/src/inspectors/types.ts +51 -0
  49. package/src/intercom/intercom-bridge.ts +50 -8
  50. package/src/intercom/native-supervisor-channel.ts +104 -67
  51. package/src/runs/background/active-async-capacity.ts +22 -18
  52. package/src/runs/background/async-execution.ts +45 -56
  53. package/src/runs/background/async-job-tracker.ts +35 -3
  54. package/src/runs/background/async-resume.ts +5 -9
  55. package/src/runs/background/async-status-snapshot.ts +10 -12
  56. package/src/runs/background/async-status.ts +17 -9
  57. package/src/runs/background/auto-drain.ts +44 -30
  58. package/src/runs/background/binary-bootstrap.ts +33 -0
  59. package/src/runs/background/chain-root-attachment.ts +8 -0
  60. package/src/runs/background/control-channel.ts +78 -44
  61. package/src/runs/background/fleet-view.ts +30 -2
  62. package/src/runs/background/notify.ts +117 -13
  63. package/src/runs/background/owned-process-tree.ts +35 -8
  64. package/src/runs/background/process-terminal.ts +23 -23
  65. package/src/runs/background/run-child-session.ts +121 -36
  66. package/src/runs/background/run-status.ts +78 -5
  67. package/src/runs/background/runner-aliases.ts +28 -9
  68. package/src/runs/background/runner-child-launch.ts +88 -0
  69. package/src/runs/background/runner-child-sessions.ts +5 -4
  70. package/src/runs/background/scheduled-runs.ts +40 -13
  71. package/src/runs/background/stale-run-reconciler.ts +3 -1
  72. package/src/runs/background/steering.ts +20 -2
  73. package/src/runs/background/subagent-runner.ts +458 -239
  74. package/src/runs/background/subagent-wait.ts +54 -8
  75. package/src/runs/background/wait-completions.ts +4 -0
  76. package/src/runs/background/wait-tool.ts +1 -1
  77. package/src/runs/foreground/async-steering-action.ts +37 -7
  78. package/src/runs/foreground/execution.ts +145 -56
  79. package/src/runs/foreground/prompt-audit.ts +3 -1
  80. package/src/runs/foreground/subagent-executor.ts +584 -297
  81. package/src/runs/foreground/workflow-detach-reconcile.ts +10 -5
  82. package/src/runs/foreground/workflow-foreground-steering.ts +57 -2
  83. package/src/runs/shared/acceptance.ts +7 -4
  84. package/src/runs/shared/agent-contract.ts +1 -1
  85. package/src/runs/shared/async-status-projection.ts +51 -47
  86. package/src/runs/shared/capability-ceiling.ts +2 -0
  87. package/src/runs/shared/child-hooks.ts +167 -3
  88. package/src/runs/shared/child-launch.ts +28 -13
  89. package/src/runs/shared/child-lifecycle.ts +6 -3
  90. package/src/runs/shared/child-runtime-config.ts +3 -1
  91. package/src/runs/shared/child-session.ts +75 -8
  92. package/src/runs/shared/child-tool-plan.ts +124 -5
  93. package/src/runs/shared/completion-evidence.ts +2 -2
  94. package/src/runs/shared/completion-guard.ts +6 -3
  95. package/src/runs/shared/effective-system-prompt.ts +33 -0
  96. package/src/runs/shared/external-cli-runner.ts +9 -7
  97. package/src/runs/shared/host-step-status.ts +11 -11
  98. package/src/runs/shared/llm-intent-arbiter.ts +21 -11
  99. package/src/runs/shared/model-fallback.ts +12 -6
  100. package/src/runs/shared/nested-events.ts +5 -5
  101. package/src/runs/shared/orca-progress-tabs.ts +7 -1
  102. package/src/runs/shared/parallel-handoff.ts +57 -12
  103. package/src/runs/shared/parallel-utils.ts +2 -2
  104. package/src/runs/shared/pi-spawn.ts +10 -0
  105. package/src/runs/shared/readonly-drain-observation.ts +42 -0
  106. package/src/runs/shared/readonly-model-continuation.ts +69 -0
  107. package/src/runs/shared/readonly-session-evidence.ts +307 -0
  108. package/src/runs/shared/run-fanout-budget.ts +8 -8
  109. package/src/runs/shared/runtime-acknowledged-extensions.ts +3 -3
  110. package/src/runs/shared/subagent-prompt-runtime.ts +20 -4
  111. package/src/runs/shared/task-intent.ts +46 -13
  112. package/src/runs/shared/workflow-async-child-guidance.ts +18 -0
  113. package/src/runs/shared/worktree-setup-command.ts +190 -0
  114. package/src/runs/shared/worktree.ts +366 -208
  115. package/src/shared/fork-context.ts +15 -72
  116. package/src/shared/launch-contract.ts +65 -2
  117. package/src/shared/opencode-session-headers.ts +30 -0
  118. package/src/shared/types.ts +85 -61
  119. package/src/shared/utils.ts +7 -2
  120. package/src/shared/workflow-child-permit.ts +18 -13
  121. package/src/slash/delegation-adapters.ts +3 -1
  122. package/src/slash/delegation-request.ts +14 -0
  123. package/src/slash/slash-commands.ts +2 -1
  124. package/src/slash/subagents-admin.ts +11 -4
  125. package/src/tui/fleet-status.ts +164 -19
  126. package/src/tui/fleet.ts +27 -19
  127. package/src/tui/render.ts +172 -33
  128. package/src/watchdog/child-status.ts +8 -0
  129. package/src/watchdog/model-selection.ts +20 -0
  130. package/src/watchdog/permission-arbiter.ts +3 -1
  131. package/src/watchdog/register-child.ts +1 -0
  132. package/src/watchdog/register-main.ts +31 -27
  133. package/src/watchdog/review.ts +132 -67
  134. package/src/watchdog/runtime.ts +82 -20
  135. package/src/watchdog/scope.ts +1 -1
  136. package/src/watchdog/settings.ts +9 -3
  137. package/src/watchdog/tool-actions.ts +13 -12
  138. package/src/watchdog/turn-delta.ts +23 -0
  139. package/src/watchdog/types.ts +4 -0
  140. package/src/workflows/chat-progress.ts +3 -3
  141. package/src/workflows/scripted-workflow.ts +275 -17
  142. package/src/workflows/workflow-checklist.ts +13 -17
  143. package/src/workflows/workflow-child-summary.ts +57 -8
  144. package/src/workflows/workflow-preflight.ts +19 -19
  145. package/src/workflows/workflow-receipt.ts +3 -3
  146. package/src/workflows/workflow-resources.ts +96 -21
  147. package/src/workflows/workflow-settlement.ts +3 -0
  148. /package/src/inspectors/{herdr/shell-command.ts → shell-command.ts} +0 -0
@@ -6,10 +6,11 @@ import { DEFAULT_GLOBAL_CONCURRENCY_LIMIT, Semaphore } from "../runs/shared/para
6
6
  import { HOST_STEP_MAX_COUNT } from "../runs/shared/host-step-status.ts";
7
7
  import { classifyTaskMutationIntent } from "../runs/shared/task-intent.ts";
8
8
  import { describeGateAcceptanceConflict } from "../runs/shared/acceptance.ts";
9
- import type { AcceptanceRecoveryMetadata, HostStepNodeV1, SingleResult } from "../shared/types.ts";
9
+ import type { AcceptanceRecoveryMetadata, HostStepNode, SingleResult } from "../shared/types.ts";
10
10
  import { normalizeWorkflowHostCommandParams, type WorkflowHostCommandParams, type WorkflowHostCommandResult } from "./host-command.ts";
11
11
 
12
12
  const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
13
+ const BASE_REF_VALIDATION_ERROR = "baseRef must be a valid Git ref: use HEAD or a supported named ref (for example, refs/heads/main). Full 40/64-character commit IDs and revision expressions are unsupported.";
13
14
  function validGitRef(ref: unknown): ref is string {
14
15
  if (typeof ref !== "string" || !ref || ref === "@" || Buffer.byteLength(ref, "utf-8") > 1024 || ref.startsWith("/") || ref.endsWith("/") || ref.includes("//") || ref.includes("..") || ref.includes("@{")) return false;
15
16
  if (/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu.test(ref)) return false;
@@ -23,11 +24,22 @@ export interface WorkflowScriptValidationError {
23
24
  message: string;
24
25
  line?: number;
25
26
  column?: number;
27
+ kind?: "spawn-budget";
28
+ }
29
+
30
+ export interface WorkflowScriptValidationWarning {
31
+ message: string;
32
+ kind: "dynamic-spawn-count";
26
33
  }
27
34
 
28
35
  export interface WorkflowScriptValidationResult {
29
36
  ok: boolean;
30
37
  errors: WorkflowScriptValidationError[];
38
+ warnings?: WorkflowScriptValidationWarning[];
39
+ }
40
+
41
+ export interface WorkflowScriptValidationOptions {
42
+ maxSubagentSpawnsPerRun?: number;
31
43
  }
32
44
 
33
45
  const WORKER_SOURCE = String.raw`
@@ -644,7 +656,7 @@ function validateRunCall(key, params, label, fingerprints) {
644
656
  }
645
657
  if (Object.prototype.hasOwnProperty.call(params, "clarify")) throw new Error(label + " does not support clarify UI.");
646
658
  if (params.worktree !== undefined && typeof params.worktree !== "boolean") throw new Error(label + " worktree must be true or false.");
647
- if (params.baseRef !== undefined && (typeof params.baseRef !== "string" || !validGitRef(params.baseRef))) throw new Error(label + " baseRef must be a valid Git ref.");
659
+ if (params.baseRef !== undefined && (typeof params.baseRef !== "string" || !validGitRef(params.baseRef))) throw new Error(label + " baseRef must be a valid Git ref: use HEAD or a supported named ref (for example, refs/heads/main). Full 40/64-character commit IDs and revision expressions are unsupported.");
648
660
  validateLaneMetadata(params.lane, label + " lane", key);
649
661
  if (params.gate !== undefined && (typeof params.gate !== "string" || !params.gate.trim())) throw new Error(label + " gate must be a non-empty command string.");
650
662
  if (params.gate !== undefined && params.acceptance !== undefined && params.acceptance !== false) throw new Error(label + " gate cannot be combined with acceptance; use one gate command or acceptance.verify." + describeGateAcceptanceConflict(params.gate, params.acceptance));
@@ -695,8 +707,26 @@ function validateHostCommand(key, params) {
695
707
  hostKeys.add(key);
696
708
  }
697
709
 
710
+ const RUNS_ALL_PERMISSIVE_WARNING = "runs.all received runs.run(...) launch promise(s) or thenable(s). Those children were already launched individually, so runs.all cannot apply batch fingerprint validation, batch grouping in the fleet view, or collectFailure semantics; a failed child will throw at the runs.all boundary instead of returning as { ok: false }. To get full runs.all semantics, pass config objects: runs.all(items.map((item) => ({ key: item.key, agent: item.agent, task: item.task }))).";
711
+
712
+ function runsAllItemThenableInfo(item, index) {
713
+ if (!item || typeof item !== "object" || Array.isArray(item)) return null;
714
+ if (typeof item.then !== "function") return null;
715
+ const tracker = promiseObservationTracker(item);
716
+ const observation = tracker && Array.isArray(tracker.observations) ? tracker.observations.find((entry) => entry && entry.operation === "run") : undefined;
717
+ return {
718
+ key: observation && typeof observation.key === "string" ? observation.key : ("runs.all[" + index + "]"),
719
+ callId: observation && typeof observation.callId === "number" ? observation.callId : null,
720
+ promise: item,
721
+ };
722
+ }
723
+
724
+ let warnedPermissiveRunsAll = false;
725
+
698
726
  function launchRunsAll(items, generatedLaneKeys) {
699
727
  if (!Array.isArray(items)) throw new Error("runs.all(items) requires an array.");
728
+ const anyThenable = items.some((item, index) => runsAllItemThenableInfo(item, index) !== null);
729
+ if (anyThenable) return launchRunsAllPermissive(items);
700
730
  const fingerprints = new Map(runFingerprints);
701
731
  const calls = [];
702
732
  for (let index = 0; index < items.length; index++) {
@@ -713,6 +743,33 @@ function launchRunsAll(items, generatedLaneKeys) {
713
743
  return { calls, launched };
714
744
  }
715
745
 
746
+ function launchRunsAllPermissive(items) {
747
+ if (!warnedPermissiveRunsAll) {
748
+ warnedPermissiveRunsAll = true;
749
+ try { capturedConsole.warn(RUNS_ALL_PERMISSIVE_WARNING); } catch { /* console emission is best-effort */ }
750
+ }
751
+ const fingerprints = new Map(runFingerprints);
752
+ const calls = [];
753
+ const launched = [];
754
+ for (let index = 0; index < items.length; index++) {
755
+ if (!Object.prototype.hasOwnProperty.call(items, index)) throw new Error("runs.all items must not contain sparse entries.");
756
+ const item = items[index];
757
+ if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("runs.all item " + index + " must be an object or a runs.run(...) launch promise.");
758
+ const thenable = runsAllItemThenableInfo(item, index);
759
+ if (thenable) {
760
+ calls.push({ key: thenable.key, params: {} });
761
+ launched.push(thenable);
762
+ continue;
763
+ }
764
+ const { key, ...params } = item;
765
+ validateRunCall(key, params, "runs.all item " + index, fingerprints);
766
+ calls.push({ key, params });
767
+ launched.push(null);
768
+ }
769
+ runFingerprints = fingerprints;
770
+ return { calls, launched: launched.map((entry, index) => entry ?? runHostCall(calls[index].key, calls[index].params, false, undefined)) };
771
+ }
772
+
716
773
  const runs = Object.freeze({
717
774
  run(key, params) {
718
775
  validateRunCall(key, params, "runs.run", runFingerprints);
@@ -721,7 +778,7 @@ const runs = Object.freeze({
721
778
  },
722
779
  all(items) {
723
780
  const { calls, launched } = launchRunsAll(items);
724
- return trackRunObservation(launched.map(({ key, callId }) => ({ key, operation: "run", callId })), Promise.all(launched.map(({ promise }) => promise)).then((results) => wrapRunsAllResults(results.map(decorateWorkflowChildResult), calls.map(({ key }) => key))));
781
+ return trackRunObservation(launched.map(({ key, callId }) => ({ key, operation: "run", callId })), trackPromiseCombinator(launched.map(({ promise }) => promise), (values) => Promise.all(values).then((results) => wrapRunsAllResults(results.map(decorateWorkflowChildResult), calls.map(({ key }) => key)))));
725
782
  },
726
783
  lanes(laneSpecs) {
727
784
  return runLanes(laneSpecs);
@@ -1098,8 +1155,22 @@ export class WorkflowScriptError extends Error {
1098
1155
  }
1099
1156
  }
1100
1157
 
1158
+ export type WorkflowChildSettledOutcome = "completed" | "failed" | "paused" | "stopped";
1159
+
1160
+ export interface WorkflowChildSettledNotification {
1161
+ workflowRunId: string;
1162
+ childKey: string;
1163
+ childRunId?: string;
1164
+ outcome: WorkflowChildSettledOutcome;
1165
+ outputReference?: string;
1166
+ error?: string;
1167
+ workflowRunning: boolean;
1168
+ }
1169
+
1101
1170
  export interface RunWorkflowScriptOptions {
1102
1171
  script: string;
1172
+ /** Workflow run ID for notifications. Required when onChildSettled is provided. */
1173
+ workflowRunId?: string;
1103
1174
  /** Host-only first-slice admission context. It is never sent to the workflow worker. */
1104
1175
  oneUsePermit?: { claim: (key: string) => string | undefined };
1105
1176
  timeoutMs?: number;
@@ -1108,13 +1179,13 @@ export interface RunWorkflowScriptOptions {
1108
1179
  continueAfterAbortWhenChildrenSettled?: (abortError: Error) => boolean;
1109
1180
  /** Maximum children executing concurrently within this workflow. Defaults to 20. */
1110
1181
  globalConcurrencyLimit?: number;
1111
- admit?: (calls: Array<{ key: string; params: Record<string, unknown> }>) => void | Promise<void>;
1182
+ admit?: (calls: Array<{ key: string; params: Record<string, unknown> }>, signal: AbortSignal) => void | Promise<void>;
1112
1183
  launch: (key: string, params: Record<string, unknown>, signal: AbortSignal, admission: { admitted: boolean; batch: boolean }) => Promise<WorkflowScriptChildResult>;
1113
- resolveResume?: (reference: WorkflowReceiptResumeReference, signal: AbortSignal) => string | WorkflowResolvedResumeReference | Promise<string | WorkflowResolvedResumeReference>;
1184
+ resolveResume?: (reference: WorkflowReceiptResumeReference | string, signal: AbortSignal, index?: number) => string | WorkflowResolvedResumeReference | Promise<string | WorkflowResolvedResumeReference>;
1114
1185
  status: (keyOrRunId: string, signal: AbortSignal) => Promise<WorkflowScriptChildResult>;
1115
1186
  steer?: (key: string, message: string, options: WorkflowSteerOptions, signal: AbortSignal) => Promise<WorkflowSteerResult>;
1116
1187
  host?: (key: string, params: WorkflowHostCommandParams, signal: AbortSignal) => Promise<WorkflowHostCommandResult>;
1117
- onHostStep?: (hostStep: HostStepNodeV1) => void;
1188
+ onHostStep?: (hostStep: HostStepNode) => void;
1118
1189
  state?: {
1119
1190
  get: (key: string) => unknown | Promise<unknown>;
1120
1191
  set: (key: string, value: unknown) => void | Promise<void>;
@@ -1123,6 +1194,7 @@ export interface RunWorkflowScriptOptions {
1123
1194
  onTrace?: (trace: WorkflowScriptTraceEntry[]) => void;
1124
1195
  onLanePlan?: (lanes: WorkflowLanePlan[]) => void;
1125
1196
  onEmit?: (emits: unknown[]) => void;
1197
+ onChildSettled?: (notification: WorkflowChildSettledNotification) => void;
1126
1198
  }
1127
1199
 
1128
1200
  function combinedAbortSignal(signals: AbortSignal[]): AbortSignal {
@@ -1425,7 +1497,7 @@ function literalString(node: unknown): string | undefined {
1425
1497
  return undefined;
1426
1498
  }
1427
1499
 
1428
- function directRunsCall(node: unknown, method: "run" | "all" | "host"): node is AstNode {
1500
+ function directRunsCall(node: unknown, method: "run" | "all" | "host" | "lanes"): boolean {
1429
1501
  if (!astNode(node) || node.type !== "CallExpression" || !astNode(node.callee) || node.callee.type !== "MemberExpression") return false;
1430
1502
  const property = node.callee.computed === true ? literalString(node.callee.property) : astNode(node.callee.property) && node.callee.property.type === "Identifier" ? node.callee.property.name : undefined;
1431
1503
  return property === method && astNode(node.callee.object) && node.callee.object.type === "Identifier" && node.callee.object.name === "runs";
@@ -1502,7 +1574,7 @@ function definitelyNonJson(node: AstNode, normalizeUndefined = false): string |
1502
1574
  if (node.type === "ObjectExpression" && Array.isArray(node.properties)) {
1503
1575
  const values = new Map<string, AstNode>();
1504
1576
  for (const property of node.properties) {
1505
- if (!astNode(property) || property.type !== "Property" || !astNode(property.value)) return undefined;
1577
+ if (!astNode(property) || property.type !== "Property" || !astNode(property.value) || property.kind !== "init") return undefined;
1506
1578
  const key = staticPropertyKey(property);
1507
1579
  if (key === undefined) return undefined;
1508
1580
  values.set(key, property.value);
@@ -1531,6 +1603,26 @@ function directObjectPropertyValue(node: AstNode, name: string): AstNode | undef
1531
1603
  return value;
1532
1604
  }
1533
1605
 
1606
+ function validateStaticBaseRef(params: AstNode, owner: string): WorkflowScriptValidationError[] {
1607
+ if (params.type !== "ObjectExpression" || !Array.isArray(params.properties)) return [];
1608
+ // Inspect the final definition only. A later spread or unknown key may overwrite it.
1609
+ for (let index = params.properties.length - 1; index >= 0; index--) {
1610
+ const property = params.properties[index];
1611
+ if (!astNode(property) || property.type !== "Property") return [];
1612
+ const key = staticPropertyKey(property);
1613
+ if (key === undefined) return [];
1614
+ if (key !== "baseRef") continue;
1615
+ if (property.kind !== "init" || !astNode(property.value)) return [];
1616
+ const valueNode = property.value;
1617
+ const value = literalString(valueNode);
1618
+ if ((value !== undefined || valueNode.type === "Literal") && !validGitRef(value)) {
1619
+ return [{ message: `${owner} ${BASE_REF_VALIDATION_ERROR}`, ...nodeLocation(valueNode) }];
1620
+ }
1621
+ return [];
1622
+ }
1623
+ return [];
1624
+ }
1625
+
1534
1626
  function directRunsAllKeys(call: AstNode): Array<{ key: string; node: AstNode }> {
1535
1627
  const args = Array.isArray(call.arguments) ? call.arguments : [];
1536
1628
  const items = astNode(args[0]) && args[0].type === "ArrayExpression" && Array.isArray(args[0].elements) ? args[0].elements : [];
@@ -1542,8 +1634,121 @@ function directRunsAllKeys(call: AstNode): Array<{ key: string; node: AstNode }>
1542
1634
  });
1543
1635
  }
1544
1636
 
1637
+ function containsWorkflowLaunch(node: unknown): boolean {
1638
+ let found = false;
1639
+ walkAst(node, (candidate) => {
1640
+ if (directRunsCall(candidate, "run") || directRunsCall(candidate, "all") || directRunsCall(candidate, "lanes")) found = true;
1641
+ });
1642
+ return found;
1643
+ }
1644
+
1645
+ function containsRunsIdentifier(node: unknown): boolean {
1646
+ let found = false;
1647
+ walkAst(node, (candidate) => {
1648
+ if (candidate.type === "Identifier" && candidate.name === "runs") found = true;
1649
+ });
1650
+ return found;
1651
+ }
1652
+
1653
+ function invalidatesRunsBinding(workflowBody: AstNode): boolean {
1654
+ let invalidated = false;
1655
+ walkAst(workflowBody, (node) => {
1656
+ if ((node.type === "VariableDeclarator" || node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ClassDeclaration" || node.type === "ClassExpression")
1657
+ && containsRunsIdentifier(node.id)) invalidated = true;
1658
+ if ((node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression") && Array.isArray(node.params)
1659
+ && node.params.some(containsRunsIdentifier)) invalidated = true;
1660
+ if (node.type === "CatchClause" && containsRunsIdentifier(node.param)) invalidated = true;
1661
+ if ((node.type === "AssignmentExpression" || node.type === "UpdateExpression") && containsRunsIdentifier(node.left ?? node.argument)) invalidated = true;
1662
+ });
1663
+ return invalidated;
1664
+ }
1665
+
1666
+ function containsAbruptControl(node: AstNode): boolean {
1667
+ let found = false;
1668
+ walkAst(node, (candidate) => {
1669
+ if (candidate !== node && (candidate.type === "ReturnStatement" || candidate.type === "ThrowStatement" || candidate.type === "BreakStatement" || candidate.type === "ContinueStatement")) found = true;
1670
+ }, false);
1671
+ return found;
1672
+ }
1673
+
1674
+ function directLaunchCall(expression: unknown): AstNode | undefined {
1675
+ if (!astNode(expression)) return undefined;
1676
+ const candidate = expression.type === "AwaitExpression" && astNode(expression.argument) ? expression.argument : expression;
1677
+ return directRunsCall(candidate, "run") || directRunsCall(candidate, "all") ? candidate : undefined;
1678
+ }
1679
+
1680
+ function staticWorkflowLaunchPlan(workflowBody: AstNode): { keys: string[]; dynamic: boolean } {
1681
+ if (workflowBody.type !== "BlockStatement" || !Array.isArray(workflowBody.body) || invalidatesRunsBinding(workflowBody)) {
1682
+ return { keys: [], dynamic: containsWorkflowLaunch(workflowBody) };
1683
+ }
1684
+ const keys = new Set<string>();
1685
+ let dynamic = false;
1686
+ let remainderUncertain = false;
1687
+ const addCall = (call: AstNode): void => {
1688
+ const args = Array.isArray(call.arguments) ? call.arguments : [];
1689
+ let nestedLaunch = false;
1690
+ for (const argument of args) walkAst(argument, (node) => {
1691
+ if (node !== call && (directRunsCall(node, "run") || directRunsCall(node, "all") || directRunsCall(node, "lanes"))) nestedLaunch = true;
1692
+ });
1693
+ if (nestedLaunch) {
1694
+ dynamic = true;
1695
+ return;
1696
+ }
1697
+ if (directRunsCall(call, "run")) {
1698
+ const key = literalString(args[0]);
1699
+ if (key === undefined) dynamic = true;
1700
+ else keys.add(key);
1701
+ return;
1702
+ }
1703
+ const array = astNode(args[0]) && args[0].type === "ArrayExpression" && Array.isArray(args[0].elements) ? args[0] : undefined;
1704
+ if (!array) {
1705
+ dynamic = true;
1706
+ return;
1707
+ }
1708
+ const batchKeys: string[] = [];
1709
+ for (const item of array.elements as unknown[]) {
1710
+ if (!astNode(item) || item.type !== "ObjectExpression" || !Array.isArray(item.properties)
1711
+ || item.properties.some((property) => !astNode(property) || property.type !== "Property" || staticPropertyKey(property) === undefined)) {
1712
+ dynamic = true;
1713
+ return;
1714
+ }
1715
+ const key = literalString(directObjectPropertyValue(item, "key"));
1716
+ if (key === undefined) {
1717
+ dynamic = true;
1718
+ return;
1719
+ }
1720
+ batchKeys.push(key);
1721
+ }
1722
+ for (const key of batchKeys) keys.add(key);
1723
+ };
1724
+ for (const statement of workflowBody.body) {
1725
+ if (!astNode(statement)) continue;
1726
+ if (remainderUncertain) {
1727
+ if (containsWorkflowLaunch(statement)) dynamic = true;
1728
+ continue;
1729
+ }
1730
+ const expressions: unknown[] = [];
1731
+ if (statement.type === "VariableDeclaration" && Array.isArray(statement.declarations)) {
1732
+ for (const declaration of statement.declarations) if (astNode(declaration)) expressions.push(declaration.init);
1733
+ } else if (statement.type === "ExpressionStatement") expressions.push(statement.expression);
1734
+ else if (statement.type === "ReturnStatement" || statement.type === "ThrowStatement") expressions.push(statement.argument);
1735
+ let handledLaunch = false;
1736
+ for (const expression of expressions) {
1737
+ const call = directLaunchCall(expression);
1738
+ if (call) {
1739
+ addCall(call);
1740
+ handledLaunch = true;
1741
+ } else if (containsWorkflowLaunch(expression)) dynamic = true;
1742
+ }
1743
+ if (!handledLaunch && expressions.length === 0 && containsWorkflowLaunch(statement)) dynamic = true;
1744
+ if (statement.type === "ReturnStatement" || statement.type === "ThrowStatement") remainderUncertain = true;
1745
+ else if (containsAbruptControl(statement)) remainderUncertain = true;
1746
+ }
1747
+ return { keys: [...keys], dynamic };
1748
+ }
1749
+
1545
1750
  /** Parse a workflowScript and apply only rules that are decidable from its local syntax. */
1546
- export function validateWorkflowScript(script: string): WorkflowScriptValidationResult {
1751
+ export function validateWorkflowScript(script: string, options: WorkflowScriptValidationOptions = {}): WorkflowScriptValidationResult {
1547
1752
  const errors: WorkflowScriptValidationError[] = [];
1548
1753
  if (!script.trim()) return { ok: false, errors: [{ message: "workflowScript must not be empty." }] };
1549
1754
  let root: AstNode;
@@ -1572,6 +1777,7 @@ export function validateWorkflowScript(script: string): WorkflowScriptValidation
1572
1777
  const key = literalString(keyNode);
1573
1778
  if (keyNode && key !== undefined && !KEY_PATTERN.test(key)) errors.push({ message: "runs.run key must be 1-128 characters using letters, numbers, '.', '_' or '-', and start with a letter or number.", ...nodeLocation(keyNode) });
1574
1779
  if (astNode(args[1])) {
1780
+ errors.push(...validateStaticBaseRef(args[1], "runs.run"));
1575
1781
  const message = definitelyNonJson(args[1]);
1576
1782
  if (message) errors.push({ message: `runs.run params are invalid: ${message}.`, ...nodeLocation(args[1]) });
1577
1783
  }
@@ -1581,6 +1787,7 @@ export function validateWorkflowScript(script: string): WorkflowScriptValidation
1581
1787
  const args = Array.isArray(node.arguments) ? node.arguments : [];
1582
1788
  if (astNode(args[0]) && args[0].type === "ArrayExpression" && Array.isArray(args[0].elements)) {
1583
1789
  for (const item of args[0].elements) if (astNode(item)) {
1790
+ errors.push(...validateStaticBaseRef(item, "runs.all item"));
1584
1791
  const message = definitelyNonJson(item);
1585
1792
  if (message) errors.push({ message: `runs.all item params are invalid: ${message}.`, ...nodeLocation(item) });
1586
1793
  }
@@ -1608,7 +1815,7 @@ export function validateWorkflowScript(script: string): WorkflowScriptValidation
1608
1815
  const statement = workflowBody.body[statementIndex];
1609
1816
  if (!astNode(statement) || statement.type !== "VariableDeclaration" || !Array.isArray(statement.declarations)) continue;
1610
1817
  for (const declaration of statement.declarations) {
1611
- if (!astNode(declaration) || !astNode(declaration.id) || declaration.id.type !== "Identifier" || !astNode(declaration.init) || declaration.init.type !== "AwaitExpression" || !directRunsCall(declaration.init.argument, "all")) continue;
1818
+ if (!astNode(declaration) || !astNode(declaration.id) || declaration.id.type !== "Identifier" || !astNode(declaration.init) || declaration.init.type !== "AwaitExpression" || !astNode(declaration.init.argument) || !directRunsCall(declaration.init.argument, "all")) continue;
1612
1819
  const name = declaration.id.name as string;
1613
1820
  const keys = new Set(directRunsAllKeys(declaration.init.argument).map((entry) => entry.key));
1614
1821
  if (keys.size === 0) continue;
@@ -1624,8 +1831,24 @@ export function validateWorkflowScript(script: string): WorkflowScriptValidation
1624
1831
  }
1625
1832
  }
1626
1833
 
1834
+ const warnings: WorkflowScriptValidationWarning[] = [];
1835
+ if (options.maxSubagentSpawnsPerRun !== undefined) {
1836
+ const plan = staticWorkflowLaunchPlan(workflowBody);
1837
+ if (plan.keys.length > options.maxSubagentSpawnsPerRun) {
1838
+ errors.push({
1839
+ kind: "spawn-budget",
1840
+ message: `workflowScript statically requires child launches ${plan.keys.map((key) => `'${key}'`).join(", ")}; minimum required: ${plan.keys.length}; configured: ${options.maxSubagentSpawnsPerRun}.`,
1841
+ });
1842
+ }
1843
+ if (plan.dynamic) {
1844
+ warnings.push({
1845
+ kind: "dynamic-spawn-count",
1846
+ message: `workflowScript contains dynamic child launches; static validation proved ${plan.keys.length} launch(es), so runtime fan-out enforcement remains authoritative for the configured budget of ${options.maxSubagentSpawnsPerRun}.`,
1847
+ });
1848
+ }
1849
+ }
1627
1850
  const unique = errors.filter((error, index) => errors.findIndex((candidate) => candidate.message === error.message && candidate.line === error.line && candidate.column === error.column) === index);
1628
- return { ok: unique.length === 0, errors: unique };
1851
+ return { ok: unique.length === 0, errors: unique, ...(warnings.length > 0 ? { warnings } : {}) };
1629
1852
  }
1630
1853
  function workflowStringMetadata(params: Record<string, unknown>): Pick<WorkflowScriptTraceEntry, "phase" | "label" | "agent"> {
1631
1854
  return {
@@ -1751,7 +1974,7 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
1751
1974
  console.error("Workflow onLanePlan callback failed:", error);
1752
1975
  }
1753
1976
  };
1754
- const hostStepChanged = (hostStep: HostStepNodeV1) => {
1977
+ const hostStepChanged = (hostStep: HostStepNode) => {
1755
1978
  try {
1756
1979
  options.onHostStep?.(hostStep);
1757
1980
  } catch (error) {
@@ -1789,6 +2012,30 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
1789
2012
  traceChanged();
1790
2013
  return true;
1791
2014
  };
2015
+ const notifyChildSettled = (key: string, result: WorkflowScriptChildResult): void => {
2016
+ if (!options.onChildSettled || !options.workflowRunId) return;
2017
+ const outcome: WorkflowChildSettledOutcome = result.ok
2018
+ ? "completed"
2019
+ : result.stopped
2020
+ ? "stopped"
2021
+ : result.detached
2022
+ ? "paused"
2023
+ : "failed";
2024
+ const outputReference = result.outputReference ?? result.artifactPaths[0];
2025
+ try {
2026
+ options.onChildSettled({
2027
+ workflowRunId: options.workflowRunId,
2028
+ childKey: key,
2029
+ ...(result.runId ? { childRunId: result.runId } : {}),
2030
+ outcome,
2031
+ ...(outputReference ? { outputReference } : {}),
2032
+ ...(!result.ok && result.error ? { error: result.error } : {}),
2033
+ workflowRunning: !settled && !finishing,
2034
+ });
2035
+ } catch (error) {
2036
+ console.error("Workflow onChildSettled callback failed:", error);
2037
+ }
2038
+ };
1792
2039
  options.registerStopChild?.(stopChild);
1793
2040
 
1794
2041
  return await new Promise<WorkflowScriptResult>((resolve, reject) => {
@@ -2047,7 +2294,7 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
2047
2294
  if (!options.host) return respond(Promise.reject(new Error("runs.host is unavailable in this host context.")));
2048
2295
  if (hostCalls.size >= HOST_STEP_MAX_COUNT) return respond(Promise.reject(new Error(`workflowScript supports at most ${HOST_STEP_MAX_COUNT} runs.host calls.`)));
2049
2296
  const startedAt = Date.now();
2050
- const startedStep: HostStepNodeV1 = {
2297
+ const startedStep: HostStepNode = {
2051
2298
  version: 1,
2052
2299
  kind: "host-step",
2053
2300
  monitorKind: "command",
@@ -2130,7 +2377,7 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
2130
2377
  return respond(Promise.reject(new Error(`runs.run('${key}') worktree must be true or false.`)));
2131
2378
  }
2132
2379
  if (params.baseRef !== undefined && (typeof params.baseRef !== "string" || !validGitRef(params.baseRef))) {
2133
- return respond(Promise.reject(new Error(`runs.run('${key}') baseRef must be a valid Git ref.`)));
2380
+ return respond(Promise.reject(new Error(`runs.run('${key}') ${BASE_REF_VALIDATION_ERROR}`)));
2134
2381
  }
2135
2382
  if (params.gate !== undefined && (typeof params.gate !== "string" || !params.gate.trim())) {
2136
2383
  return respond(Promise.reject(new Error(`runs.run('${key}') gate must be a non-empty command string.`)));
@@ -2169,7 +2416,7 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
2169
2416
  admission = Promise.resolve().then(() => {
2170
2417
  if (settled || finishing) return;
2171
2418
  for (const call of calls) assertRecoveryBarrierAllowsRun(call.key, call.params);
2172
- return options.admit?.(calls);
2419
+ return options.admit?.(calls, childController.signal);
2173
2420
  });
2174
2421
  if (batch) batchAdmissions.set(batch.id, admission);
2175
2422
  }
@@ -2183,10 +2430,11 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
2183
2430
  const childStopController = new AbortController();
2184
2431
  childStopControllers.set(key, childStopController);
2185
2432
  const childSignal = combinedAbortSignal([childController.signal, childStopController.signal]);
2186
- const resolvedResumeValue = resumeReference
2433
+ const resumeInput = resumeReference ?? (typeof params.resume === "string" && options.resolveResume ? params.resume : undefined);
2434
+ const resolvedResumeValue = resumeInput
2187
2435
  ? await Promise.resolve().then(() => {
2188
2436
  if (!options.resolveResume) throw new Error("Keyed workflow receipt resume is unavailable in this host.");
2189
- return options.resolveResume(resumeReference, childSignal);
2437
+ return options.resolveResume(resumeInput, childSignal, typeof params.index === "number" ? params.index : undefined);
2190
2438
  })
2191
2439
  : undefined;
2192
2440
  const resolvedResume = typeof resolvedResumeValue === "string"
@@ -2202,6 +2450,10 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
2202
2450
  : [];
2203
2451
  resolvedResumeLineage = [...new Set(lineage.length ? lineage : [resolvedResumeId!])];
2204
2452
  if (resolvedResumeLineage.at(-1) !== resolvedResumeId) resolvedResumeLineage.push(resolvedResumeId!);
2453
+ if (typeof resumeInput === "string") {
2454
+ const predecessor = [...children.values()].find((child) => child.runId === resolvedResumeId);
2455
+ if (predecessor?.continuation && predecessor.continuation.runIds.at(-1) === resolvedResumeId) resolvedResumeLineage = predecessor.continuation.runIds;
2456
+ }
2205
2457
  }
2206
2458
  const launchParams = resolvedResumeId ? { ...params, resume: resolvedResumeId } : params;
2207
2459
  await launchSemaphore.acquire();
@@ -2221,6 +2473,10 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
2221
2473
  } finally {
2222
2474
  launchSemaphore.release();
2223
2475
  }
2476
+ }, (error: unknown) => {
2477
+ if (!childController.signal.aborted) throw error;
2478
+ const reason = childController.signal.reason;
2479
+ return stoppedChildResult(key, reason instanceof Error ? reason.message : typeof reason === "string" ? reason : "Workflow script aborted.");
2224
2480
  }).then((result) => {
2225
2481
  let normalized = !result.ok && !result.error ? { ...result, error: result.output } : result;
2226
2482
  if (resolvedResumeLineage?.length && normalized.runId) {
@@ -2233,6 +2489,7 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
2233
2489
  const state = normalized.ok ? "completed" : normalized.stopped ? "stopped" : normalized.detached ? "detached" : "failed";
2234
2490
  trace.push({ operation: "run", key, state, durationMs: Date.now() - startedAt, ...workflowStringMetadata(params), ...(generatedLaneKey ? { generatedLaneKey } : {}), ...(normalized.agent ? { agent: normalized.agent } : {}), ...(normalized.runId ? { runId: normalized.runId } : {}), ...(!normalized.ok ? { error: normalized.error ?? normalized.output } : {}) });
2235
2491
  traceChanged();
2492
+ notifyChildSettled(key, normalized);
2236
2493
  return normalized;
2237
2494
  }, (error: unknown) => {
2238
2495
  const text = error instanceof Error ? error.message : String(error);
@@ -2242,6 +2499,7 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
2242
2499
  children.set(key, failure);
2243
2500
  trace.push({ operation: "run", key, state: "failed", durationMs: Date.now() - startedAt, ...workflowStringMetadata(params), ...(generatedLaneKey ? { generatedLaneKey } : {}), error: text });
2244
2501
  traceChanged();
2502
+ notifyChildSettled(key, failure);
2245
2503
  return failure;
2246
2504
  });
2247
2505
  launches.set(key, { fingerprint, promise, observed: callObserved, ...(generatedLaneKey ? { generatedLaneKey } : {}) });
@@ -1,6 +1,6 @@
1
- import type { AsyncJobStep, HostStepNodeV1, WorkflowGraphNode, WorkflowGraphSnapshot, WorkflowPreflightLaneV1, WorkflowPreflightV1 } from "../shared/types.ts";
1
+ import type { AsyncJobStep, HostStepNode, WorkflowGraphNode, WorkflowGraphSnapshot, WorkflowPreflightLane, WorkflowPreflight } from "../shared/types.ts";
2
2
  import { sanitizeDisplayText } from "../shared/display-text.ts";
3
- import { workflowPreflightLaneForRuntimeKey } from "./workflow-preflight.ts";
3
+ import { workflowPreflightLaneForRuntimeKey as laneFor } from "./workflow-preflight.ts";
4
4
 
5
5
  export type WorkflowChecklistState = "complete" | "running" | "queued" | "blocked" | "failed" | "paused" | "stopped";
6
6
 
@@ -62,12 +62,12 @@ export interface WorkflowChecklistItem {
62
62
  toolCount?: number;
63
63
  outputName?: string;
64
64
  error?: string;
65
- preflight?: WorkflowPreflightLaneV1;
65
+ preflight?: WorkflowPreflightLane;
66
66
  kind?: "child" | "host";
67
- monitorKind?: HostStepNodeV1["monitorKind"];
67
+ monitorKind?: HostStepNode["monitorKind"];
68
68
  provider?: string;
69
69
  role?: string;
70
- verdict?: HostStepNodeV1["verdict"];
70
+ verdict?: HostStepNode["verdict"];
71
71
  target?: string;
72
72
  reasonCode?: string;
73
73
  stale?: boolean;
@@ -106,8 +106,8 @@ export interface WorkflowChecklistProjection {
106
106
  export interface WorkflowChecklistInput {
107
107
  graph?: WorkflowGraphSnapshot;
108
108
  steps?: readonly WorkflowChecklistStep[] | readonly AsyncJobStep[];
109
- hostSteps?: readonly HostStepNodeV1[];
110
- preflight?: WorkflowPreflightV1;
109
+ hostSteps?: readonly HostStepNode[];
110
+ preflight?: WorkflowPreflight;
111
111
  trace?: readonly WorkflowChecklistTraceEntry[];
112
112
  now?: number;
113
113
  }
@@ -186,19 +186,15 @@ function duration(step: Pick<WorkflowChecklistStep, "durationMs" | "startedAt" |
186
186
  const startedAt = finite(step.startedAt);
187
187
  if (explicit !== undefined) return Math.max(0, explicit);
188
188
  if (startedAt === undefined) return undefined;
189
- const end = state === "running" ? now : finite(step.endedAt) ?? now;
189
+ const end = state === "running" ? now : finite(step.endedAt);
190
190
  return end === undefined ? undefined : Math.max(0, end - startedAt);
191
191
  }
192
192
 
193
- function laneFor(preflight: WorkflowPreflightV1 | undefined, key: string, preferredKeys: readonly (string | undefined)[] = []): WorkflowPreflightLaneV1 | undefined {
194
- return workflowPreflightLaneForRuntimeKey(preflight, key, preferredKeys);
195
- }
196
-
197
193
  function stepKey(step: WorkflowChecklistStep): string | undefined {
198
194
  return step.key ?? step.workflowKey ?? step.runId;
199
195
  }
200
196
 
201
- function stepItem(step: WorkflowChecklistStep, index: number, phase: string, key = stepKey(step) ?? `step-${index + 1}`, label = step.label ?? step.description ?? stepKey(step) ?? step.agent ?? key, preflight?: WorkflowPreflightLaneV1): WorkflowChecklistItem {
197
+ function stepItem(step: WorkflowChecklistStep, index: number, phase: string, key = stepKey(step) ?? `step-${index + 1}`, label = step.label ?? step.description ?? stepKey(step) ?? step.agent ?? key, preflight?: WorkflowPreflightLane): WorkflowChecklistItem {
202
198
  const state = checklistState(step);
203
199
  return {
204
200
  key: keyText(key, `step-${index + 1}`),
@@ -219,7 +215,7 @@ function stepItem(step: WorkflowChecklistStep, index: number, phase: string, key
219
215
  };
220
216
  }
221
217
 
222
- function hostItem(host: HostStepNodeV1, phase: string, key = host.id): WorkflowChecklistItem {
218
+ function hostItem(host: HostStepNode, phase: string, key = host.id): WorkflowChecklistItem {
223
219
  const state = checklistState({ status: host.state, verdict: host.verdict, stale: host.freshness?.stale });
224
220
  return {
225
221
  key: keyText(key, "host-step"),
@@ -264,7 +260,7 @@ function traceSources(trace: readonly WorkflowChecklistTraceEntry[] | undefined)
264
260
  return [...latest.values()];
265
261
  }
266
262
 
267
- function traceItem(entry: WorkflowChecklistTraceEntry, index: number, preflight: WorkflowPreflightLaneV1 | undefined): WorkflowChecklistItem {
263
+ function traceItem(entry: WorkflowChecklistTraceEntry, index: number, preflight: WorkflowPreflightLane | undefined): WorkflowChecklistItem {
268
264
  const phase = keyText(preflight?.key ?? entry.generatedLaneKey ?? entry.phase, "Workflow");
269
265
  const item = stepItem({ key: entry.key, label: entry.label, phase, agent: entry.agent, status: entry.state === "started" ? "running" : entry.state, durationMs: entry.durationMs, error: entry.error }, index, phase, entry.key, entry.label ?? entry.key, preflight);
270
266
  if (entry.operation === "host") item.kind = "host";
@@ -284,7 +280,7 @@ function add(phases: Map<string, WorkflowChecklistPhase>, phase: string, item: W
284
280
  phaseFor(phases, phase).items.push(item);
285
281
  }
286
282
 
287
- function mergeNodeStep(node: WorkflowGraphNode, step: WorkflowChecklistStep, phase: string, trace: WorkflowChecklistTraceEntry | undefined, preflight: WorkflowPreflightLaneV1 | undefined): WorkflowChecklistItem {
283
+ function mergeNodeStep(node: WorkflowGraphNode, step: WorkflowChecklistStep, phase: string, trace: WorkflowChecklistTraceEntry | undefined, preflight: WorkflowPreflightLane | undefined): WorkflowChecklistItem {
288
284
  const state = checklistState(step);
289
285
  const nodeState = checklistState({ status: node.status, acceptance: node.acceptanceStatus ? { status: node.acceptanceStatus } : undefined });
290
286
  const status = TERMINAL_STATES.has(nodeState) && !TERMINAL_STATES.has(state)
@@ -300,7 +296,7 @@ function priority(item: WorkflowChecklistItem): number {
300
296
  }
301
297
 
302
298
  function applyNow(item: WorkflowChecklistItem, now: number | undefined): WorkflowChecklistItem {
303
- return item.durationMs === undefined && item.startedAt !== undefined && now !== undefined ? { ...item, durationMs: Math.max(0, now - item.startedAt) } : item;
299
+ return item.state === "running" && item.startedAt !== undefined && now !== undefined ? { ...item, durationMs: Math.max(0, now - item.startedAt) } : item;
304
300
  }
305
301
 
306
302
  function finalize(phase: WorkflowChecklistPhase): void {