pi-subagents 0.57.0 → 0.59.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 (115) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/docs/agents.md +18 -9
  3. package/docs/configuration.md +4 -4
  4. package/docs/extension-api.md +40 -1
  5. package/docs/models.md +27 -4
  6. package/docs/observability.md +1 -1
  7. package/docs/tool-reference.md +92 -6
  8. package/docs/workflows.md +115 -0
  9. package/package.json +3 -1
  10. package/prompts/review-loop.md +2 -2
  11. package/skills/council-mode/SKILL.md +1 -1
  12. package/skills/pi-subagents/SKILL.md +3 -1
  13. package/skills/pi-subagents/references/execution-controls.md +6 -2
  14. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
  15. package/skills/pi-subagents/references/prompting-and-roles.md +26 -5
  16. package/src/agents/agent-management.ts +28 -17
  17. package/src/agents/agent-serializer.ts +9 -2
  18. package/src/agents/agents.ts +103 -27
  19. package/src/agents/runtime-agent-events.ts +70 -0
  20. package/src/agents/runtime-agent-registry.ts +19 -18
  21. package/src/api/agents.ts +10 -5
  22. package/src/api/background-work.ts +5 -1
  23. package/src/api/delegation.ts +0 -7
  24. package/src/api/preflight.ts +32 -10
  25. package/src/extension/fanout-child.ts +5 -3
  26. package/src/extension/index.ts +66 -27
  27. package/src/extension/public-execution.ts +15 -2
  28. package/src/extension/rpc.ts +35 -14
  29. package/src/extension/schemas.ts +37 -12
  30. package/src/extension/tool-description.ts +25 -6
  31. package/src/integrations/herdr-status.ts +5 -0
  32. package/src/intercom/result-intercom.ts +2 -0
  33. package/src/profiles/profiles.ts +5 -6
  34. package/src/runs/background/active-async-capacity.ts +2 -2
  35. package/src/runs/background/async-execution.ts +78 -46
  36. package/src/runs/background/async-job-tracker.ts +14 -12
  37. package/src/runs/background/async-resume.ts +29 -27
  38. package/src/runs/background/async-status-snapshot.ts +23 -261
  39. package/src/runs/background/async-status.ts +68 -7
  40. package/src/runs/background/chain-append.ts +8 -3
  41. package/src/runs/background/chain-root-attachment.ts +60 -8
  42. package/src/runs/background/fleet-view.ts +21 -11
  43. package/src/runs/background/notify.ts +184 -9
  44. package/src/runs/background/result-delivery-ownership.ts +45 -0
  45. package/src/runs/background/result-files.ts +2 -1
  46. package/src/runs/background/result-watcher.ts +29 -10
  47. package/src/runs/background/resume-guidance.ts +1 -1
  48. package/src/runs/background/retained-children.ts +1 -1
  49. package/src/runs/background/run-status.ts +18 -8
  50. package/src/runs/background/scheduled-runs.ts +86 -7
  51. package/src/runs/background/stale-run-reconciler.ts +10 -4
  52. package/src/runs/background/steering.ts +4 -14
  53. package/src/runs/background/subagent-runner.ts +389 -350
  54. package/src/runs/background/subagent-wait.ts +58 -10
  55. package/src/runs/background/terminal-run-index.ts +1 -1
  56. package/src/runs/background/wait-completions.ts +22 -1
  57. package/src/runs/background/wait-config.ts +23 -9
  58. package/src/runs/background/wait-tool.ts +9 -2
  59. package/src/runs/foreground/async-steering-action.ts +2 -2
  60. package/src/runs/foreground/execution.ts +134 -121
  61. package/src/runs/foreground/foreground-control.ts +3 -0
  62. package/src/runs/foreground/foreground-history.ts +1 -0
  63. package/src/runs/foreground/subagent-executor.ts +527 -257
  64. package/src/runs/foreground/workflow-detach-reconcile.ts +130 -120
  65. package/src/runs/shared/abort-recovery.ts +119 -0
  66. package/src/runs/shared/async-status-projection.ts +463 -0
  67. package/src/runs/shared/child-identity.ts +19 -4
  68. package/src/runs/shared/child-launch-plan.ts +151 -0
  69. package/src/runs/shared/completion-evidence.ts +89 -0
  70. package/src/runs/shared/completion-guard.ts +5 -4
  71. package/src/runs/shared/dynamic-fanout.ts +3 -3
  72. package/src/runs/shared/fast-mode-extension.ts +5 -5
  73. package/src/runs/shared/host-step-status.ts +230 -0
  74. package/src/runs/shared/lane-metadata.ts +105 -0
  75. package/src/runs/shared/launch-cwd.ts +16 -0
  76. package/src/runs/shared/long-running-guard.ts +2 -1
  77. package/src/runs/shared/mcp-config-sources.ts +422 -0
  78. package/src/runs/shared/mcp-direct-tool-allowlist.ts +221 -158
  79. package/src/runs/shared/mcp-direct-tool-grant.ts +197 -0
  80. package/src/runs/shared/model-exclusions.ts +12 -0
  81. package/src/runs/shared/model-fallback.ts +25 -6
  82. package/src/runs/shared/nested-events.ts +6 -2
  83. package/src/runs/shared/nested-render.ts +7 -3
  84. package/src/runs/shared/parallel-handoff.ts +419 -7
  85. package/src/runs/shared/parallel-utils.ts +15 -1
  86. package/src/runs/shared/pi-args.ts +81 -8
  87. package/src/runs/shared/single-output.ts +44 -4
  88. package/src/runs/shared/subagent-prompt-runtime.ts +98 -13
  89. package/src/runs/shared/worktree-cleanup-plan.ts +847 -0
  90. package/src/runs/shared/worktree.ts +18 -0
  91. package/src/shared/child-session-name.ts +46 -0
  92. package/src/shared/extension-context.ts +24 -0
  93. package/src/shared/formatters.ts +11 -2
  94. package/src/shared/launch-contract.ts +5 -1
  95. package/src/shared/settings.ts +9 -103
  96. package/src/shared/types.ts +240 -30
  97. package/src/shared/utils.ts +41 -84
  98. package/src/slash/delegation-adapters.ts +1 -8
  99. package/src/slash/delegation-request.ts +0 -4
  100. package/src/slash/slash-bridge.ts +1 -2
  101. package/src/slash/slash-commands.ts +369 -90
  102. package/src/slash/slash-live-state.ts +22 -11
  103. package/src/slash/subagents-admin.ts +3 -0
  104. package/src/tui/fleet-status.ts +125 -74
  105. package/src/tui/fleet.ts +11 -5
  106. package/src/tui/render.ts +330 -33
  107. package/src/watchdog/turn-delta.ts +1 -1
  108. package/src/workflows/chat-progress.ts +6 -3
  109. package/src/workflows/host-command.ts +230 -0
  110. package/src/workflows/scripted-workflow.ts +496 -35
  111. package/src/workflows/workflow-child-summary.ts +9 -5
  112. package/src/workflows/workflow-preflight.ts +270 -0
  113. package/src/workflows/workflow-receipt.ts +79 -5
  114. package/src/workflows/workflow-settlement.ts +246 -0
  115. package/src/runs/shared/turn-budget.ts +0 -98
@@ -9,81 +9,50 @@ import {
9
9
  type IntercomEventBus,
10
10
  type SingleResult,
11
11
  type SubagentState,
12
+ type WorkflowTerminalResolution,
12
13
  } from "../../shared/types.ts";
13
14
  import { updateActiveRunIndex } from "../background/active-run-index.ts";
14
15
  import { resultFilePath, writeAsyncResultFile } from "../background/result-files.ts";
15
16
  import { resolveAsyncResumeTarget } from "../background/async-resume.ts";
16
17
  import { externalCliReceiptMetadata, normalizeExternalCliRunnerStatus } from "../shared/external-cli-contract.ts";
18
+ import { outputPathMappingFromTask } from "../shared/single-output.ts";
17
19
  import { readWorkflowReceipt, workflowReceiptPath, writeWorkflowReceipt, type WorkflowReceipt } from "../../workflows/workflow-receipt.ts";
18
- import { workflowChildSummary } from "../../workflows/workflow-child-summary.ts";
19
-
20
- function cloneWorkflowStatus(status: AsyncStatus): AsyncStatus {
21
- return {
22
- ...status,
23
- steps: status.steps?.map((step) => ({ ...step })),
24
- workflow: status.workflow ? { ...status.workflow, trace: [...(status.workflow.trace ?? [])] } : status.workflow,
25
- };
26
- }
27
-
28
- function childSucceeded(result: Pick<SingleResult, "exitCode" | "error" | "interrupted">): boolean {
29
- return result.exitCode === 0 && !result.error && !result.interrupted;
30
- }
31
-
32
- const UNSUPPORTED_DETACHED_WORKFLOW_CONTINUATION = "unsupported-continuation: detached workflow child settled, but JavaScript workflow continuation was not persisted. Resume the workflow explicitly instead of treating the completed child as top-level workflow completion.";
33
- const INTERRUPTED_DETACHED_CHILD = "Interrupted. Waiting for explicit next action.";
20
+ import {
21
+ applyDetachedChildSettlement,
22
+ classifyWorkflowSettlement,
23
+ findWorkflowSettlementStep,
24
+ planWorkflowSettlement,
25
+ promoteSettledPausedWorkflow,
26
+ workflowOutputPathMappingSummary,
27
+ workflowRecoveryActions,
28
+ workflowTerminalOutcomeForResult,
29
+ type WorkflowPublicChild,
30
+ type WorkflowStatusStep,
31
+ } from "../../workflows/workflow-settlement.ts";
34
32
 
35
33
  export function applyDetachedChildToPausedWorkflow(
36
34
  status: AsyncStatus,
37
- input: { childRunId: string; result: Pick<SingleResult, "exitCode" | "error" | "interrupted" | "sessionFile">; workflowKey?: string },
35
+ input: { childRunId: string; result: Pick<SingleResult, "exitCode" | "error" | "interrupted" | "sessionFile" | "sessionName" | "stopped">; workflowKey?: string },
38
36
  ): AsyncStatus | undefined {
39
- if (status.mode !== "workflow" || status.state !== "paused") return undefined;
40
- const next = cloneWorkflowStatus(status);
41
- const step = next.steps?.find((candidate) => candidate.runId === input.childRunId)
42
- ?? (input.workflowKey ? next.steps?.find((candidate) => candidate.workflowKey === input.workflowKey) : undefined);
43
- if (!step) return undefined;
44
- const succeeded = childSucceeded(input.result);
45
- const failedSiblingError = next.steps?.find((candidate) => candidate !== step && candidate.status === "failed" && candidate.error)?.error;
46
- const updatedAt = Date.now();
47
- step.status = succeeded ? "completed" : "failed";
48
- step.endedAt = updatedAt;
49
- delete step.activityState;
50
- delete step.currentTool;
51
- delete step.currentToolStartedAt;
52
- if (input.result.sessionFile) step.sessionFile = input.result.sessionFile;
53
- if (succeeded) delete step.error;
54
- else if (input.result.interrupted) step.error = input.result.error ?? INTERRUPTED_DETACHED_CHILD;
55
- else if (input.result.error) step.error = input.result.error;
56
- next.lastUpdate = updatedAt;
57
- const promoted = promotePausedWorkflowIfSettled(next);
58
- if (promoted?.state === "failed" && input.result.interrupted && !failedSiblingError) promoted.error = input.result.error ?? INTERRUPTED_DETACHED_CHILD;
59
- else if (promoted?.state === "failed" && input.result.error) promoted.error = input.result.error;
60
- const resolved = promoted ?? next;
61
- const workflowState = resolved.state === "complete" ? "completed" : resolved.state === "paused" ? "paused" : resolved.state === "stopped" ? "stopped" : "failed";
62
- resolved.workflowChildren = workflowChildSummary({ parentToolCallId: resolved.toolCallId ?? resolved.runId, workflowRunId: resolved.runId, workflowState, inventoryComplete: true, trace: resolved.workflow?.trace, steps: resolved.steps });
63
- return resolved;
37
+ return applyDetachedChildSettlement(status, input);
64
38
  }
65
39
 
66
40
  export function promotePausedWorkflowIfSettled(status: AsyncStatus): AsyncStatus | undefined {
67
- if (status.mode !== "workflow" || status.state !== "paused") return undefined;
68
- const next = cloneWorkflowStatus(status);
69
- const stillOpen = next.steps?.some((candidate) =>
70
- candidate.status === "running"
71
- || (candidate.status === "paused" && candidate.activityState === "needs_attention")
72
- ) === true;
73
- if (stillOpen || !next.steps?.length) return undefined;
74
- const failed = next.steps.some((candidate) => candidate.status === "failed");
75
- const updatedAt = Date.now();
76
- next.lastUpdate = updatedAt;
77
- next.state = "failed";
78
- next.endedAt = updatedAt;
79
- delete next.activityState;
80
- if (failed) return next;
81
- next.error = UNSUPPORTED_DETACHED_WORKFLOW_CONTINUATION;
82
- return next;
41
+ return promoteSettledPausedWorkflow(status);
83
42
  }
84
43
 
85
- function workflowResultChildren(status: AsyncStatus, childRunId: string, result: SingleResult, existingResults: unknown): unknown {
44
+ function usageWithValue(usage: SingleResult["usage"] | undefined): SingleResult["usage"] | undefined {
45
+ return usage && (usage.input !== 0 || usage.output !== 0 || usage.cacheRead !== 0 || usage.cacheWrite !== 0 || usage.cost !== 0 || usage.turns !== 0)
46
+ ? usage
47
+ : undefined;
48
+ }
49
+
50
+ function workflowResultChildren(status: AsyncStatus, childRunId: string, result: SingleResult, existingResults: unknown, receipt?: WorkflowReceipt): unknown {
86
51
  const output = getSingleResultOutput(result);
52
+ const outputReference = result.savedOutputPath ?? result.outputReference?.path;
53
+ const outputPathMapping = outputPathMappingFromTask(result.task, outputReference);
54
+ const terminalOutcome = workflowTerminalOutcomeForResult(result);
55
+ const usage = usageWithValue(result.usage);
87
56
  if (Array.isArray(existingResults)) {
88
57
  return existingResults.map((entry) => {
89
58
  if (!entry || typeof entry !== "object" || Array.isArray(entry)) return entry;
@@ -91,62 +60,45 @@ function workflowResultChildren(status: AsyncStatus, childRunId: string, result:
91
60
  if (child.runId !== childRunId) return child;
92
61
  return {
93
62
  ...child,
94
- success: childSucceeded(result),
63
+ success: result.exitCode === 0 && !result.error && !result.interrupted,
95
64
  output,
96
65
  outputState: output.trim() ? "present" : "absent",
97
66
  detached: undefined,
98
- ...(result.interrupted ? { interrupted: true } : {}),
67
+ ...(usage ? { usage } : {}),
68
+ ...(outputReference ? { outputReference } : {}),
69
+ ...(outputPathMapping ? { outputPathMapping } : {}),
70
+ ...(result.interrupted || result.stopped ? { interrupted: true } : {}),
71
+ ...(result.stopped ? { stopped: true } : {}),
72
+ ...(terminalOutcome ? { terminalOutcome } : {}),
73
+ ...(result.sessionName ? { sessionName: result.sessionName } : {}),
74
+ ...(result.sessionFile ? { sessionFile: result.sessionFile } : {}),
99
75
  ...(result.error ? { error: result.error } : {}),
100
76
  };
101
77
  });
102
78
  }
103
- return status.steps?.map((step) => ({
79
+ return status.steps?.map((step: WorkflowStatusStep) => ({
104
80
  workflowKey: step.workflowKey,
105
81
  agent: step.agent,
82
+ ...(step.sessionName ? { sessionName: step.sessionName } : {}),
106
83
  runId: step.runId,
107
84
  success: step.status === "completed" || step.status === "complete",
108
85
  output: step.runId === childRunId ? output : "",
109
86
  outputState: step.runId === childRunId && output.trim() ? "present" : "absent",
110
- ...(step.runId === childRunId && result.interrupted ? { interrupted: true } : {}),
87
+ ...(step.runId === childRunId ? { ...(usage ? { usage } : {}), ...(result.sessionFile ? { sessionFile: result.sessionFile } : {}) } : {}),
88
+ ...(step.runId === childRunId && outputReference ? { outputReference } : step.workflowKey && receipt?.entries[step.workflowKey]?.outputReference ? { outputReference: receipt.entries[step.workflowKey]!.outputReference } : {}),
89
+ ...(step.runId === childRunId && outputPathMapping ? { outputPathMapping } : step.outputPathMapping ? { outputPathMapping: step.outputPathMapping } : {}),
90
+ ...(step.interrupted ? { interrupted: true } : {}),
91
+ ...(step.runId === childRunId && terminalOutcome ? { terminalOutcome } : step.workflowKey && receipt?.entries[step.workflowKey]?.terminalOutcome ? { terminalOutcome: receipt.entries[step.workflowKey]!.terminalOutcome } : {}),
92
+ ...(step.stopped ? { stopped: true } : {}),
111
93
  ...(step.error ? { error: step.error } : {}),
112
94
  }));
113
95
  }
114
96
 
115
- function publishedWorkflowResult(status: AsyncStatus, childRunId: string, result: SingleResult, asyncDir: string, existing?: Record<string, unknown>, receipt?: WorkflowReceipt): Record<string, unknown> {
116
- const sessionId = status.sessionId ?? (typeof existing?.sessionId === "string" ? existing.sessionId : undefined);
117
- const summary = status.state === "complete"
118
- ? `Workflow completed after detached child ${childRunId} finished.`
119
- : status.error ?? (typeof existing?.summary === "string" ? existing.summary : undefined);
120
- return {
121
- ...(existing ?? {}),
122
- id: status.runId,
123
- runId: status.runId,
124
- toolCallId: status.toolCallId,
125
- agent: "workflow",
126
- mode: "workflow",
127
- success: status.state === "complete",
128
- state: status.state,
129
- summary,
130
- error: status.error,
131
- activityState: status.activityState,
132
- endedAt: status.endedAt,
133
- timestamp: Date.now(),
134
- results: workflowResultChildren(status, childRunId, result, existing?.results),
135
- workflow: status.workflow,
136
- workflowChildren: status.workflowChildren,
137
- reconciledFromDetachedChild: childRunId,
138
- ...(receipt ? { workflowReceipt: { path: path.join(asyncDir, "workflow-receipt.json"), receipt } } : {}),
139
- asyncDir,
140
- cwd: status.cwd,
141
- sessionId,
142
- completionOwnerId: status.completionOwnerId,
143
- };
144
- }
145
-
146
- function reconcileWorkflowReceipt(status: AsyncStatus, childRunId: string, result: SingleResult, asyncDir: string): WorkflowReceipt | undefined {
147
- const receiptPath = workflowReceiptPath(DIRS.async, status.runId);
97
+ function reconcileWorkflowReceipt(status: AsyncStatus, childRunId: string, result: SingleResult, asyncDir: string, resolution: WorkflowTerminalResolution | undefined): WorkflowReceipt | undefined {
98
+ const receiptRoot = path.dirname(asyncDir);
99
+ const receiptPath = workflowReceiptPath(receiptRoot, status.runId);
148
100
  if (!fs.existsSync(receiptPath)) return undefined;
149
- const receipt = readWorkflowReceipt(DIRS.async, status.runId);
101
+ const receipt = readWorkflowReceipt(receiptRoot, status.runId);
150
102
  const step = status.steps?.find((candidate) => candidate.runId === childRunId);
151
103
  const key = step?.workflowKey;
152
104
  if (!key) throw new Error(`Workflow receipt '${status.runId}' cannot identify detached child '${childRunId}' by stable key.`);
@@ -165,6 +117,7 @@ function reconcileWorkflowReceipt(status: AsyncStatus, childRunId: string, resul
165
117
  const externalRunner = normalizeExternalCliRunnerStatus(result.runner?.type === "external-cli" ? result.runner : externalStep?.runner);
166
118
  const externalProcess = result.externalProcess ?? externalStep?.externalProcess;
167
119
  const externalAdapter = externalRunner ? externalCliReceiptMetadata({ runner: externalRunner, externalProcess, outputReference }) : entry.externalAdapter;
120
+ const childTerminalOutcome = workflowTerminalOutcomeForResult(result) ?? entry.terminalOutcome;
168
121
  if (externalAdapter) resumability = { state: "not-resumable", reason: externalAdapter.nonResumableReason };
169
122
  const updatedEntry: WorkflowReceipt["entries"][string] = resumability.state === "resumable"
170
123
  ? {
@@ -174,6 +127,7 @@ function reconcileWorkflowReceipt(status: AsyncStatus, childRunId: string, resul
174
127
  latestRunId: entry.latestRunId ?? childRunId,
175
128
  resumability,
176
129
  ...(outputReference ? { outputReference } : {}),
130
+ ...(childTerminalOutcome ? { terminalOutcome: childTerminalOutcome } : {}),
177
131
  ...(externalAdapter ? { externalAdapter } : {}),
178
132
  }
179
133
  : {
@@ -182,6 +136,7 @@ function reconcileWorkflowReceipt(status: AsyncStatus, childRunId: string, resul
182
136
  ...(step.context ? { resolvedContext: step.context } : {}),
183
137
  resumability,
184
138
  ...(outputReference ? { outputReference } : {}),
139
+ ...(childTerminalOutcome ? { terminalOutcome: childTerminalOutcome } : {}),
185
140
  ...(externalAdapter ? { externalAdapter } : {}),
186
141
  };
187
142
  const next: WorkflowReceipt = {
@@ -192,7 +147,14 @@ function reconcileWorkflowReceipt(status: AsyncStatus, childRunId: string, resul
192
147
  [key]: updatedEntry,
193
148
  },
194
149
  workflowChildren: status.workflowChildren,
150
+ ...(receipt.terminalOutcome ? { terminalOutcome: receipt.terminalOutcome } : {}),
151
+ ...(resolution ? { workflowResolution: resolution } : {}),
195
152
  };
153
+ if (resolution) next.recovery = workflowRecoveryActions(next);
154
+ else {
155
+ delete next.workflowResolution;
156
+ delete next.recovery;
157
+ }
196
158
  writeWorkflowReceipt(asyncDir, next);
197
159
  return next;
198
160
  }
@@ -218,21 +180,26 @@ export function reconcileDetachedWorkflowChildCompletion(input: {
218
180
  const asyncDir = job?.asyncDir ?? path.join(DIRS.async, input.workflowRunId);
219
181
  const status = readStatus(asyncDir);
220
182
  if (!status) return false;
183
+ const matchingControls = input.workflowKey === undefined ? [] : [...(input.state.foregroundControls?.values() ?? [])].filter((control) =>
184
+ control.parentWorkflowRunId === input.workflowRunId && control.workflowKey === input.workflowKey
185
+ );
186
+ const confirmedLiveIdentity = matchingControls.length === 1 && matchingControls[0]?.runId === input.childRunId;
187
+ if (confirmedLiveIdentity) {
188
+ const candidates = status.steps?.filter((candidate) => candidate.workflowKey === input.workflowKey) ?? [];
189
+ const candidate = candidates.length === 1 ? candidates[0] : undefined;
190
+ if (candidate && candidate.runId === undefined && candidate.sessionFile === undefined
191
+ && candidate.status === "paused" && candidate.activityState === "needs_attention") candidate.runId = input.childRunId;
192
+ }
221
193
  const next = applyDetachedChildToPausedWorkflow(status, {
222
194
  childRunId: input.childRunId,
223
195
  result: input.result,
224
196
  workflowKey: input.workflowKey,
225
197
  });
226
198
  if (!next) return false;
227
- writeAtomicJson(path.join(asyncDir, "status.json"), next);
228
- updateActiveRunIndex(asyncDir, next.state, next.toolCallId);
229
- if (job) {
230
- job.status = next.state;
231
- job.updatedAt = next.lastUpdate;
232
- job.activityState = next.activityState;
233
- job.steps = next.steps?.map((step, index) => ({ ...step, index }));
234
- job.workflow = next.workflow;
235
- }
199
+ const outputReference = input.result.savedOutputPath ?? input.result.outputReference?.path;
200
+ const outputPathMapping = outputPathMappingFromTask(input.result.task, outputReference);
201
+ const settledStep = findWorkflowSettlementStep(next, input.childRunId);
202
+ if (settledStep && outputPathMapping) settledStep.outputPathMapping = outputPathMapping;
236
203
  const resultPath = resultFilePath(DIRS.results, input.workflowRunId);
237
204
  let existing: Record<string, unknown> | undefined;
238
205
  try {
@@ -243,13 +210,55 @@ export function reconcileDetachedWorkflowChildCompletion(input: {
243
210
  }
244
211
  let receipt: WorkflowReceipt | undefined;
245
212
  let receiptError: string | undefined;
213
+ const resolution = classifyWorkflowSettlement(next, input.result.interrupted);
246
214
  try {
247
- receipt = reconcileWorkflowReceipt(next, input.childRunId, input.result, asyncDir);
215
+ receipt = reconcileWorkflowReceipt(next, input.childRunId, input.result, asyncDir, resolution);
248
216
  } catch (error) {
249
217
  receiptError = `Failed to reconcile async workflow receipt: ${error instanceof Error ? error.message : String(error)}`;
250
218
  }
251
- const published = publishedWorkflowResult(next, input.childRunId, input.result, asyncDir, existing, receipt);
252
- writeAsyncResultFile(resultPath, published);
219
+ const results = workflowResultChildren(next, input.childRunId, input.result, existing?.results, receipt) as WorkflowPublicChild[];
220
+ const recovery = workflowRecoveryActions(receipt);
221
+ const summary = `${resolution === "settled-awaiting-resume"
222
+ ? `Workflow lanes settled after detached child ${input.childRunId} finished. JavaScript workflow continuation was not persisted.${recovery.length ? " Use the listed keyed recovery action to continue a child." : " No retained child is resumable."}`
223
+ : next.state === "complete"
224
+ ? `Workflow completed after detached child ${input.childRunId} finished.`
225
+ : next.error ?? (typeof existing?.summary === "string" ? existing.summary : "Workflow failed.")}${workflowOutputPathMappingSummary(results)}`;
226
+ const plan = planWorkflowSettlement({
227
+ status: next,
228
+ summary,
229
+ children: results,
230
+ baseResult: {
231
+ ...(existing ?? {}),
232
+ id: next.runId,
233
+ runId: next.runId,
234
+ toolCallId: next.toolCallId,
235
+ agent: "workflow",
236
+ mode: "workflow",
237
+ endedAt: next.endedAt,
238
+ workflow: next.workflow,
239
+ reconciledFromDetachedChild: input.childRunId,
240
+ asyncDir,
241
+ cwd: next.cwd,
242
+ sessionId: next.sessionId ?? (typeof existing?.sessionId === "string" ? existing.sessionId : undefined),
243
+ completionOwnerId: next.completionOwnerId,
244
+ },
245
+ receipt,
246
+ receiptPath: path.join(asyncDir, "workflow-receipt.json"),
247
+ receiptPersistenceError: receiptError,
248
+ resolution,
249
+ terminalOutcome: receipt?.terminalOutcome,
250
+ eventMetadata: { reconciledFromDetachedChild: input.childRunId },
251
+ });
252
+ writeAtomicJson(path.join(asyncDir, "status.json"), plan.status);
253
+ updateActiveRunIndex(asyncDir, plan.status.state, plan.status.toolCallId);
254
+ if (job) {
255
+ job.status = plan.status.state;
256
+ job.updatedAt = plan.status.lastUpdate;
257
+ job.activityState = plan.status.activityState;
258
+ job.steps = plan.status.steps?.map((step, index) => ({ ...step, index }));
259
+ job.workflow = plan.status.workflow;
260
+ }
261
+ writeAsyncResultFile(resultPath, plan.publicResult);
253
262
  if (receiptError) {
254
263
  appendDetachedWorkflowEvent(asyncDir, {
255
264
  ts: Date.now(),
@@ -259,14 +268,12 @@ export function reconcileDetachedWorkflowChildCompletion(input: {
259
268
  reconciledFromDetachedChild: input.childRunId,
260
269
  });
261
270
  }
262
- if (next.state === "complete" || next.state === "failed") {
271
+ if (plan.completionEvent) {
263
272
  appendDetachedWorkflowEvent(asyncDir, {
264
273
  ts: Date.now(),
265
274
  runId: input.workflowRunId,
266
- type: "subagent.workflow.completed",
267
- state: next.state,
268
- ...(next.error ? { error: next.error } : {}),
269
- reconciledFromDetachedChild: input.childRunId,
275
+ ...plan.completionEvent,
276
+ recovery: plan.recovery,
270
277
  });
271
278
  input.events?.emit(SUBAGENT_ASYNC_COMPLETE_EVENT, {
272
279
  id: input.workflowRunId,
@@ -274,13 +281,16 @@ export function reconcileDetachedWorkflowChildCompletion(input: {
274
281
  source: "async",
275
282
  mode: "workflow",
276
283
  agent: "workflow",
277
- success: next.state === "complete",
278
- state: next.state,
279
- summary: typeof published.summary === "string" ? published.summary : (next.state === "complete" ? "Workflow completed." : next.error),
284
+ success: plan.status.state === "complete",
285
+ state: plan.status.state,
286
+ ...(resolution ? { workflowResolution: resolution } : {}),
287
+ ...(plan.receipt?.terminalOutcome ? { terminalOutcome: plan.receipt.terminalOutcome } : {}),
288
+ recovery: plan.recovery,
289
+ summary: typeof plan.publicResult.summary === "string" ? plan.publicResult.summary : plan.status.error,
280
290
  reconciledFromDetachedChild: input.childRunId,
281
- ...(Array.isArray(published.results) ? { results: published.results } : {}),
282
- sessionId: next.sessionId,
283
- completionOwnerId: next.completionOwnerId,
291
+ results,
292
+ sessionId: plan.status.sessionId,
293
+ completionOwnerId: plan.status.completionOwnerId,
284
294
  timestamp: Date.now(),
285
295
  triggerTurn: true,
286
296
  });
@@ -0,0 +1,119 @@
1
+ import type { Message } from "@earendil-works/pi-ai";
2
+
3
+ export const ABORT_RECOVERY_PROMPT = "The prior run ended from a provider/transport abort after useful progress. Continue from the current files and transcript. Do not restart. Fix any validation failure or write the required report. Finish with final output.";
4
+
5
+ export type AbortRecoveryPlan =
6
+ | { action: "resume"; prompt: typeof ABORT_RECOVERY_PROMPT }
7
+ | { action: "settle"; reason: string; diagnostic?: string };
8
+
9
+ const PROVIDER_ABORT_PATTERN = /(?:provider|transport|connection|stream|socket|request).*(?:abort|closed|reset|ended|terminated|error|fail)|(?:abort|closed|reset|ended|terminated|error|fail).*(?:provider|transport|connection|stream|socket|request)/i;
10
+ const ABORT_ERROR_PATTERN = /\b(?:operation|request|response|stream|connection|transport|provider)?\s*(?:was\s+)?aborted\b/i;
11
+ const TOOL_FAILURE_PREFIX = /^[\w.:@/-]+ failed (?:(?:\(exit \d+\):)|(?:with exit code \d+))(?:\s|$)/i;
12
+
13
+ function isProviderAbortError(error: string): boolean {
14
+ return !TOOL_FAILURE_PREFIX.test(error.trim()) && (PROVIDER_ABORT_PATTERN.test(error) || ABORT_ERROR_PATTERN.test(error));
15
+ }
16
+
17
+ function record(value: unknown): Record<string, unknown> | undefined {
18
+ return value !== null && typeof value === "object" && !Array.isArray(value)
19
+ ? value as Record<string, unknown>
20
+ : undefined;
21
+ }
22
+
23
+ function contentParts(message: Record<string, unknown>): unknown[] {
24
+ return Array.isArray(message.content) ? message.content : [];
25
+ }
26
+
27
+ function terminalAssistant(messages: readonly Message[]): { message?: Record<string, unknown>; index: number } {
28
+ for (let index = messages.length - 1; index >= 0; index--) {
29
+ const message = record(messages[index]);
30
+ if (message?.role === "assistant") return { message, index };
31
+ }
32
+ return { index: messages.length };
33
+ }
34
+
35
+ function zeroOutputUsage(message: Record<string, unknown>): boolean {
36
+ const usage = record(message.usage);
37
+ return usage !== undefined && (usage.output ?? usage.outputTokens ?? 0) === 0;
38
+ }
39
+
40
+ function hasUsefulProgress(messages: readonly Message[], terminalIndex: number): boolean {
41
+ for (let index = 0; index < terminalIndex; index++) {
42
+ const message = record(messages[index]);
43
+ if (!message) continue;
44
+ if (message.role === "assistant" && contentParts(message).length > 0) return true;
45
+ if (message.role === "toolResult" && message.isError !== true) return true;
46
+ }
47
+ return false;
48
+ }
49
+
50
+ function hasUnresolvedToolCall(messages: readonly Message[]): boolean {
51
+ const pending = new Set<string>();
52
+ for (const rawMessage of messages) {
53
+ const message = record(rawMessage);
54
+ if (!message) continue;
55
+ if (message.role === "assistant") {
56
+ for (const rawPart of contentParts(message)) {
57
+ const part = record(rawPart);
58
+ if (part?.type === "toolCall" && typeof part.id === "string" && part.id) pending.add(part.id);
59
+ }
60
+ } else if (message.role === "toolResult" && typeof message.toolCallId === "string") {
61
+ pending.delete(message.toolCallId);
62
+ }
63
+ }
64
+ return pending.size > 0;
65
+ }
66
+
67
+ export function planAbortRecovery(input: {
68
+ messages: readonly Message[];
69
+ error?: string;
70
+ processSignal?: string | null;
71
+ sessionAvailable: boolean;
72
+ alreadyResumed: boolean;
73
+ stopped?: boolean;
74
+ interrupted?: boolean;
75
+ timedOut?: boolean;
76
+ toolBudgetExhausted?: boolean;
77
+ usageBudgetExhausted?: boolean;
78
+ structuredOutputFailed?: boolean;
79
+ acceptanceFailed?: boolean;
80
+ currentTool?: string;
81
+ afterCompactionSettlement?: boolean;
82
+ }): AbortRecoveryPlan {
83
+ const terminal = terminalAssistant(input.messages);
84
+ const message = terminal.message;
85
+ const terminalError = typeof message?.errorMessage === "string" ? message.errorMessage : undefined;
86
+ const emptyZeroUsageTerminal = message !== undefined
87
+ && contentParts(message).length === 0
88
+ && zeroOutputUsage(message);
89
+ const terminalAssistantAbort = message?.stopReason === "aborted"
90
+ || (message?.stopReason === "error" && terminalError !== undefined && isProviderAbortError(terminalError));
91
+ const abortCandidate = emptyZeroUsageTerminal && terminalAssistantAbort;
92
+ const compactionAbortCandidate = input.afterCompactionSettlement === true && abortCandidate;
93
+ const settle = (reason: string): AbortRecoveryPlan => ({
94
+ action: "settle",
95
+ reason,
96
+ ...(compactionAbortCandidate
97
+ ? { diagnostic: `Compaction-induced child abort could not be resumed safely: ${reason}.` }
98
+ : {}),
99
+ });
100
+
101
+ if (input.alreadyResumed) return settle("resume already attempted");
102
+ if (!input.sessionAvailable) return settle("retained session unavailable");
103
+ if (input.stopped || input.interrupted) return settle("explicit stop or interrupt");
104
+ // A compaction abort can leave the child process open until the runner's
105
+ // terminal drain sends SIGTERM. The compaction + settlement markers make
106
+ // that cleanup signal non-authoritative; explicit stop/interrupt still wins.
107
+ if (input.processSignal && !compactionAbortCandidate) return settle("process terminated by signal");
108
+ if (input.timedOut) return settle("elapsed timeout");
109
+ if (input.toolBudgetExhausted || input.usageBudgetExhausted) return settle("budget exhausted");
110
+ if (input.structuredOutputFailed) return settle("structured output failure");
111
+ if (input.acceptanceFailed) return settle("acceptance failure");
112
+ if (input.currentTool) return settle(`active tool '${input.currentTool.slice(0, 128)}' remains in flight`);
113
+ if (hasUnresolvedToolCall(input.messages)) return settle("unresolved tool call remains in transcript");
114
+ if (!input.afterCompactionSettlement) return settle("compaction settlement not verified");
115
+ if (!abortCandidate) return settle("terminal assistant abort evidence not verified");
116
+ const progressLimit = emptyZeroUsageTerminal ? terminal.index : input.messages.length;
117
+ if (!hasUsefulProgress(input.messages, progressLimit)) return settle("no useful prior progress");
118
+ return { action: "resume", prompt: ABORT_RECOVERY_PROMPT };
119
+ }