pi-subagents 0.58.0 → 0.60.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 (116) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/docs/agents.md +5 -3
  3. package/docs/configuration.md +4 -4
  4. package/docs/extension-api.md +1 -1
  5. package/docs/models.md +24 -1
  6. package/docs/observability.md +1 -1
  7. package/docs/tool-reference.md +93 -5
  8. package/docs/workflows.md +94 -3
  9. package/package.json +3 -1
  10. package/prompts/review-loop.md +2 -2
  11. package/skills/council-mode/SKILL.md +48 -243
  12. package/skills/council-mode/references/pass-contracts.md +150 -0
  13. package/skills/pi-subagents/SKILL.md +87 -37
  14. package/skills/pi-subagents/references/constraints-and-recipes.md +29 -233
  15. package/skills/pi-subagents/references/execution-controls.md +52 -7
  16. package/skills/pi-subagents/references/management-authoring-rpc.md +3 -4
  17. package/skills/pi-subagents/references/multi-lane-orchestration.md +13 -1
  18. package/skills/pi-subagents/references/prompting-and-roles.md +54 -26
  19. package/skills/pi-subagents/references/review-and-validation.md +73 -0
  20. package/src/agents/agent-management.ts +159 -42
  21. package/src/agents/agent-serializer.ts +4 -2
  22. package/src/agents/agents.ts +67 -26
  23. package/src/agents/runtime-agent-registry.ts +9 -16
  24. package/src/api/background-work.ts +5 -1
  25. package/src/api/delegation.ts +0 -7
  26. package/src/api/preflight.ts +6 -9
  27. package/src/api/shared-types.ts +2 -0
  28. package/src/extension/fanout-child.ts +5 -3
  29. package/src/extension/index.ts +51 -23
  30. package/src/extension/public-execution.ts +16 -2
  31. package/src/extension/schemas.ts +36 -10
  32. package/src/extension/tool-description.ts +24 -5
  33. package/src/intercom/result-intercom.ts +2 -0
  34. package/src/profiles/profiles.ts +5 -6
  35. package/src/runs/background/active-async-capacity.ts +2 -2
  36. package/src/runs/background/async-execution.ts +52 -37
  37. package/src/runs/background/async-job-tracker.ts +17 -12
  38. package/src/runs/background/async-resume.ts +23 -25
  39. package/src/runs/background/async-status-snapshot.ts +23 -261
  40. package/src/runs/background/async-status.ts +110 -8
  41. package/src/runs/background/chain-append.ts +6 -3
  42. package/src/runs/background/chain-root-attachment.ts +60 -8
  43. package/src/runs/background/control-channel.ts +3 -2
  44. package/src/runs/background/fleet-view.ts +21 -11
  45. package/src/runs/background/notify.ts +158 -6
  46. package/src/runs/background/result-files.ts +2 -1
  47. package/src/runs/background/result-watcher.ts +2 -0
  48. package/src/runs/background/resume-guidance.ts +1 -1
  49. package/src/runs/background/retained-children.ts +1 -1
  50. package/src/runs/background/run-status.ts +30 -9
  51. package/src/runs/background/scheduled-runs.ts +86 -7
  52. package/src/runs/background/stale-run-reconciler.ts +10 -4
  53. package/src/runs/background/steering.ts +4 -14
  54. package/src/runs/background/subagent-runner.ts +351 -358
  55. package/src/runs/background/subagent-wait.ts +68 -12
  56. package/src/runs/background/terminal-run-index.ts +1 -1
  57. package/src/runs/background/wait-completions.ts +25 -1
  58. package/src/runs/background/wait-config.ts +23 -9
  59. package/src/runs/background/wait-tool.ts +9 -2
  60. package/src/runs/foreground/async-steering-action.ts +2 -2
  61. package/src/runs/foreground/execution.ts +125 -122
  62. package/src/runs/foreground/foreground-control.ts +3 -0
  63. package/src/runs/foreground/foreground-history.ts +1 -0
  64. package/src/runs/foreground/subagent-executor.ts +576 -240
  65. package/src/runs/foreground/workflow-detach-reconcile.ts +99 -200
  66. package/src/runs/shared/abort-recovery.ts +119 -0
  67. package/src/runs/shared/async-status-projection.ts +597 -0
  68. package/src/runs/shared/background-process-options.ts +9 -0
  69. package/src/runs/shared/child-identity.ts +19 -4
  70. package/src/runs/shared/child-launch-plan.ts +151 -0
  71. package/src/runs/shared/completion-evidence.ts +89 -0
  72. package/src/runs/shared/completion-guard.ts +1 -1
  73. package/src/runs/shared/dynamic-fanout.ts +2 -2
  74. package/src/runs/shared/host-step-status.ts +230 -0
  75. package/src/runs/shared/lane-metadata.ts +105 -0
  76. package/src/runs/shared/mcp-config-sources.ts +42 -6
  77. package/src/runs/shared/mcp-direct-tool-allowlist.ts +93 -143
  78. package/src/runs/shared/mcp-direct-tool-grant.ts +194 -0
  79. package/src/runs/shared/model-fallback.ts +9 -2
  80. package/src/runs/shared/mutation-evidence.ts +52 -3
  81. package/src/runs/shared/nested-events.ts +6 -2
  82. package/src/runs/shared/nested-render.ts +7 -3
  83. package/src/runs/shared/parallel-handoff.ts +419 -7
  84. package/src/runs/shared/parallel-utils.ts +7 -0
  85. package/src/runs/shared/pi-args.ts +67 -3
  86. package/src/runs/shared/single-output.ts +72 -22
  87. package/src/runs/shared/subagent-prompt-runtime.ts +33 -6
  88. package/src/runs/shared/workflow-graph.ts +15 -0
  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 +5 -2
  94. package/src/shared/launch-contract.ts +1 -1
  95. package/src/shared/settings.ts +9 -103
  96. package/src/shared/types.ts +232 -30
  97. package/src/shared/utils.ts +35 -55
  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/tui/fleet-status.ts +134 -75
  104. package/src/tui/fleet.ts +11 -5
  105. package/src/tui/render-helpers.ts +31 -0
  106. package/src/tui/render.ts +895 -121
  107. package/src/watchdog/change-signature.ts +40 -1
  108. package/src/watchdog/turn-delta.ts +1 -1
  109. package/src/workflows/chat-progress.ts +6 -3
  110. package/src/workflows/host-command.ts +235 -0
  111. package/src/workflows/scripted-workflow.ts +503 -38
  112. package/src/workflows/workflow-child-summary.ts +9 -5
  113. package/src/workflows/workflow-preflight.ts +270 -0
  114. package/src/workflows/workflow-receipt.ts +43 -4
  115. package/src/workflows/workflow-settlement.ts +246 -0
  116. package/src/runs/shared/turn-budget.ts +0 -98
@@ -9,8 +9,6 @@ import {
9
9
  type IntercomEventBus,
10
10
  type SingleResult,
11
11
  type SubagentState,
12
- type WorkflowRecoveryAction,
13
- type WorkflowTerminalOutcome,
14
12
  type WorkflowTerminalResolution,
15
13
  } from "../../shared/types.ts";
16
14
  import { updateActiveRunIndex } from "../background/active-run-index.ts";
@@ -19,130 +17,42 @@ import { resolveAsyncResumeTarget } from "../background/async-resume.ts";
19
17
  import { externalCliReceiptMetadata, normalizeExternalCliRunnerStatus } from "../shared/external-cli-contract.ts";
20
18
  import { outputPathMappingFromTask } from "../shared/single-output.ts";
21
19
  import { readWorkflowReceipt, workflowReceiptPath, writeWorkflowReceipt, type WorkflowReceipt } from "../../workflows/workflow-receipt.ts";
22
- import { workflowChildSummary } from "../../workflows/workflow-child-summary.ts";
23
-
24
- function cloneWorkflowStatus(status: AsyncStatus): AsyncStatus {
25
- return {
26
- ...status,
27
- steps: status.steps?.map((step) => ({ ...step })),
28
- workflow: status.workflow ? { ...status.workflow, trace: [...(status.workflow.trace ?? [])] } : status.workflow,
29
- };
30
- }
31
-
32
- function childSucceeded(result: Pick<SingleResult, "exitCode" | "error" | "interrupted">): boolean {
33
- return result.exitCode === 0 && !result.error && !result.interrupted;
34
- }
35
-
36
- 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.";
37
- const INTERRUPTED_DETACHED_CHILD = "Interrupted. Waiting for explicit next action.";
38
- type WorkflowStatusStep = NonNullable<AsyncStatus["steps"]>[number] & {
39
- outputPathMapping?: { requestedPath: string; savedPath: string };
40
- interrupted?: boolean;
41
- };
42
-
43
- function findWorkflowStep(status: AsyncStatus, childRunId: string, workflowKey?: string): WorkflowStatusStep | undefined {
44
- return status.steps?.find((candidate) => candidate.runId === childRunId)
45
- ?? (workflowKey ? status.steps?.find((candidate) => candidate.workflowKey === workflowKey) : undefined) as WorkflowStatusStep | undefined;
46
- }
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";
47
32
 
48
33
  export function applyDetachedChildToPausedWorkflow(
49
34
  status: AsyncStatus,
50
- input: { childRunId: string; result: Pick<SingleResult, "exitCode" | "error" | "interrupted" | "sessionFile" | "stopped">; workflowKey?: string },
35
+ input: { childRunId: string; result: Pick<SingleResult, "exitCode" | "error" | "interrupted" | "sessionFile" | "sessionName" | "stopped">; workflowKey?: string },
51
36
  ): AsyncStatus | undefined {
52
- if (status.mode !== "workflow" || status.state !== "paused") return undefined;
53
- const next = cloneWorkflowStatus(status);
54
- const step = findWorkflowStep(next, input.childRunId, input.workflowKey);
55
- if (!step) return undefined;
56
- const succeeded = childSucceeded(input.result);
57
- const failedSiblingError = next.steps?.find((candidate) => {
58
- const candidateStep = candidate as WorkflowStatusStep;
59
- return candidateStep !== step && candidateStep.status === "failed" && !candidateStep.interrupted && candidateStep.error;
60
- })?.error;
61
- const interruptedChildError = next.steps?.find((candidate) => {
62
- const candidateStep = candidate as WorkflowStatusStep;
63
- return candidateStep.status === "failed" && candidateStep.interrupted && candidateStep.error;
64
- })?.error;
65
- const updatedAt = Date.now();
66
- step.status = succeeded ? "completed" : "failed";
67
- step.endedAt = updatedAt;
68
- delete step.activityState;
69
- delete step.currentTool;
70
- delete step.currentToolStartedAt;
71
- if (input.result.sessionFile) step.sessionFile = input.result.sessionFile;
72
- if (succeeded) {
73
- delete step.error;
74
- delete step.interrupted;
75
- } else if (input.result.interrupted || input.result.stopped) {
76
- step.error = input.result.error ?? INTERRUPTED_DETACHED_CHILD;
77
- step.interrupted = true;
78
- if (input.result.stopped) step.stopped = true;
79
- } else {
80
- delete step.interrupted;
81
- delete step.stopped;
82
- if (input.result.error) step.error = input.result.error;
83
- }
84
- next.lastUpdate = updatedAt;
85
- const promoted = promotePausedWorkflowIfSettled(next);
86
- if (promoted?.state === "failed" && failedSiblingError) promoted.error = failedSiblingError;
87
- else if (promoted?.state === "failed" && (input.result.interrupted || input.result.stopped)) promoted.error = input.result.error ?? INTERRUPTED_DETACHED_CHILD;
88
- else if (promoted?.state === "failed" && input.result.error) promoted.error = input.result.error;
89
- else if (promoted?.state === "failed" && interruptedChildError) promoted.error = interruptedChildError;
90
- const resolved = promoted ?? next;
91
- const workflowState = resolved.state === "complete" ? "completed" : resolved.state === "paused" ? "paused" : resolved.state === "stopped" ? "stopped" : "failed";
92
- resolved.workflowChildren = workflowChildSummary({ parentToolCallId: resolved.toolCallId ?? resolved.runId, workflowRunId: resolved.runId, workflowState, inventoryComplete: true, trace: resolved.workflow?.trace, steps: resolved.steps });
93
- return resolved;
37
+ return applyDetachedChildSettlement(status, input);
94
38
  }
95
39
 
96
40
  export function promotePausedWorkflowIfSettled(status: AsyncStatus): AsyncStatus | undefined {
97
- if (status.mode !== "workflow" || status.state !== "paused") return undefined;
98
- const next = cloneWorkflowStatus(status);
99
- const stillOpen = next.steps?.some((candidate) =>
100
- candidate.status === "running"
101
- || (candidate.status === "paused" && candidate.activityState === "needs_attention")
102
- ) === true;
103
- if (stillOpen || !next.steps?.length) return undefined;
104
- const failed = next.steps.some((candidate) => candidate.status === "failed");
105
- const updatedAt = Date.now();
106
- next.lastUpdate = updatedAt;
107
- next.state = "failed";
108
- next.endedAt = updatedAt;
109
- delete next.activityState;
110
- if (failed) return next;
111
- next.error = UNSUPPORTED_DETACHED_WORKFLOW_CONTINUATION;
112
- return next;
113
- }
114
-
115
- function workflowResolution(status: AsyncStatus, result: Pick<SingleResult, "interrupted">): WorkflowTerminalResolution | undefined {
116
- if (status.state !== "complete" && status.state !== "failed") return undefined;
117
- if (status.steps?.some((step) => {
118
- const candidate = step as WorkflowStatusStep;
119
- return candidate.status === "failed" && !candidate.interrupted;
120
- })) return "failed-child";
121
- if (result.interrupted || status.steps?.some((step) => {
122
- const candidate = step as WorkflowStatusStep;
123
- return candidate.status === "stopped" || candidate.stopped || candidate.interrupted || candidate.error === INTERRUPTED_DETACHED_CHILD;
124
- })) return "interrupted-child";
125
- return "settled-awaiting-resume";
126
- }
127
-
128
- function workflowRecovery(receipt: WorkflowReceipt | undefined): WorkflowRecoveryAction[] {
129
- if (!receipt) return [];
130
- return Object.values(receipt.entries).flatMap((entry) => entry.resumability.state === "resumable"
131
- ? [{ key: entry.key, call: "runs.run" as const, resume: { workflowRunId: receipt.workflowRunId, key: entry.key, latest: true as const }, taskRequired: true as const }]
132
- : []);
41
+ return promoteSettledPausedWorkflow(status);
133
42
  }
134
43
 
135
- function resultTerminalOutcome(result: Pick<SingleResult, "timedOut" | "turnBudgetExceeded" | "toolBudgetBlocked">): WorkflowTerminalOutcome | undefined {
136
- if (result.timedOut) return { state: "partial", reason: "timeout" };
137
- if (result.turnBudgetExceeded || result.toolBudgetBlocked) return { state: "partial", reason: "budget_exhausted" };
138
- return undefined;
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;
139
48
  }
140
49
 
141
50
  function workflowResultChildren(status: AsyncStatus, childRunId: string, result: SingleResult, existingResults: unknown, receipt?: WorkflowReceipt): unknown {
142
51
  const output = getSingleResultOutput(result);
143
52
  const outputReference = result.savedOutputPath ?? result.outputReference?.path;
144
53
  const outputPathMapping = outputPathMappingFromTask(result.task, outputReference);
145
- const terminalOutcome = resultTerminalOutcome(result);
54
+ const terminalOutcome = workflowTerminalOutcomeForResult(result);
55
+ const usage = usageWithValue(result.usage);
146
56
  if (Array.isArray(existingResults)) {
147
57
  return existingResults.map((entry) => {
148
58
  if (!entry || typeof entry !== "object" || Array.isArray(entry)) return entry;
@@ -150,15 +60,18 @@ function workflowResultChildren(status: AsyncStatus, childRunId: string, result:
150
60
  if (child.runId !== childRunId) return child;
151
61
  return {
152
62
  ...child,
153
- success: childSucceeded(result),
63
+ success: result.exitCode === 0 && !result.error && !result.interrupted,
154
64
  output,
155
65
  outputState: output.trim() ? "present" : "absent",
156
66
  detached: undefined,
67
+ ...(usage ? { usage } : {}),
157
68
  ...(outputReference ? { outputReference } : {}),
158
69
  ...(outputPathMapping ? { outputPathMapping } : {}),
159
70
  ...(result.interrupted || result.stopped ? { interrupted: true } : {}),
160
71
  ...(result.stopped ? { stopped: true } : {}),
161
72
  ...(terminalOutcome ? { terminalOutcome } : {}),
73
+ ...(result.sessionName ? { sessionName: result.sessionName } : {}),
74
+ ...(result.sessionFile ? { sessionFile: result.sessionFile } : {}),
162
75
  ...(result.error ? { error: result.error } : {}),
163
76
  };
164
77
  });
@@ -166,10 +79,12 @@ function workflowResultChildren(status: AsyncStatus, childRunId: string, result:
166
79
  return status.steps?.map((step: WorkflowStatusStep) => ({
167
80
  workflowKey: step.workflowKey,
168
81
  agent: step.agent,
82
+ ...(step.sessionName ? { sessionName: step.sessionName } : {}),
169
83
  runId: step.runId,
170
84
  success: step.status === "completed" || step.status === "complete",
171
85
  output: step.runId === childRunId ? output : "",
172
86
  outputState: step.runId === childRunId && output.trim() ? "present" : "absent",
87
+ ...(step.runId === childRunId ? { ...(usage ? { usage } : {}), ...(result.sessionFile ? { sessionFile: result.sessionFile } : {}) } : {}),
173
88
  ...(step.runId === childRunId && outputReference ? { outputReference } : step.workflowKey && receipt?.entries[step.workflowKey]?.outputReference ? { outputReference: receipt.entries[step.workflowKey]!.outputReference } : {}),
174
89
  ...(step.runId === childRunId && outputPathMapping ? { outputPathMapping } : step.outputPathMapping ? { outputPathMapping: step.outputPathMapping } : {}),
175
90
  ...(step.interrupted ? { interrupted: true } : {}),
@@ -179,63 +94,11 @@ function workflowResultChildren(status: AsyncStatus, childRunId: string, result:
179
94
  }));
180
95
  }
181
96
 
182
- function workflowResultOutputPathMappingSummary(results: unknown): string {
183
- if (!Array.isArray(results)) return "";
184
- const mappings = results.flatMap((entry) => {
185
- if (!entry || typeof entry !== "object" || Array.isArray(entry)) return [];
186
- const child = entry as Record<string, unknown>;
187
- const mapping = child.outputPathMapping;
188
- if (!mapping || typeof mapping !== "object" || Array.isArray(mapping)) return [];
189
- const { requestedPath, savedPath } = mapping as Record<string, unknown>;
190
- if (typeof requestedPath !== "string" || typeof savedPath !== "string") return [];
191
- const key = typeof child.workflowKey === "string" ? child.workflowKey : "child";
192
- return [`'${key}': requested ${requestedPath} -> saved ${savedPath}`];
193
- });
194
- return mappings.length > 0 ? ` Output path mappings: ${mappings.join("; ")}.` : "";
195
- }
196
-
197
- function publishedWorkflowResult(status: AsyncStatus, childRunId: string, result: SingleResult, asyncDir: string, existing?: Record<string, unknown>, receipt?: WorkflowReceipt): Record<string, unknown> {
198
- const sessionId = status.sessionId ?? (typeof existing?.sessionId === "string" ? existing.sessionId : undefined);
199
- const resolution = workflowResolution(status, result);
200
- const recovery = workflowRecovery(receipt);
201
- const results = workflowResultChildren(status, childRunId, result, existing?.results, receipt);
202
- const summary = `${resolution === "settled-awaiting-resume"
203
- ? `Workflow lanes settled after detached child ${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."}`
204
- : status.state === "complete"
205
- ? `Workflow completed after detached child ${childRunId} finished.`
206
- : status.error ?? (typeof existing?.summary === "string" ? existing.summary : "Workflow failed.")}${workflowResultOutputPathMappingSummary(results)}`;
207
- return {
208
- ...(existing ?? {}),
209
- id: status.runId,
210
- runId: status.runId,
211
- toolCallId: status.toolCallId,
212
- agent: "workflow",
213
- mode: "workflow",
214
- success: status.state === "complete",
215
- state: status.state,
216
- summary,
217
- error: status.error,
218
- activityState: status.activityState,
219
- endedAt: status.endedAt,
220
- timestamp: Date.now(),
221
- results,
222
- workflow: status.workflow,
223
- workflowChildren: status.workflowChildren,
224
- ...(receipt?.terminalOutcome ? { terminalOutcome: receipt.terminalOutcome } : {}),
225
- ...(resolution ? { workflowResolution: resolution, recovery } : {}),
226
- reconciledFromDetachedChild: childRunId,
227
- ...(receipt ? { workflowReceipt: { path: path.join(asyncDir, "workflow-receipt.json"), receipt } } : {}),
228
- asyncDir,
229
- cwd: status.cwd,
230
- sessionId,
231
- completionOwnerId: status.completionOwnerId,
232
- };
233
- }
234
-
235
97
  function reconcileWorkflowReceipt(status: AsyncStatus, childRunId: string, result: SingleResult, asyncDir: string, resolution: WorkflowTerminalResolution | undefined): WorkflowReceipt | undefined {
236
- const receiptPath = workflowReceiptPath(DIRS.async, status.runId);
98
+ const receiptRoot = path.dirname(asyncDir);
99
+ const receiptPath = workflowReceiptPath(receiptRoot, status.runId);
237
100
  if (!fs.existsSync(receiptPath)) return undefined;
238
- const receipt = readWorkflowReceipt(DIRS.async, status.runId);
101
+ const receipt = readWorkflowReceipt(receiptRoot, status.runId);
239
102
  const step = status.steps?.find((candidate) => candidate.runId === childRunId);
240
103
  const key = step?.workflowKey;
241
104
  if (!key) throw new Error(`Workflow receipt '${status.runId}' cannot identify detached child '${childRunId}' by stable key.`);
@@ -254,7 +117,7 @@ function reconcileWorkflowReceipt(status: AsyncStatus, childRunId: string, resul
254
117
  const externalRunner = normalizeExternalCliRunnerStatus(result.runner?.type === "external-cli" ? result.runner : externalStep?.runner);
255
118
  const externalProcess = result.externalProcess ?? externalStep?.externalProcess;
256
119
  const externalAdapter = externalRunner ? externalCliReceiptMetadata({ runner: externalRunner, externalProcess, outputReference }) : entry.externalAdapter;
257
- const childTerminalOutcome = resultTerminalOutcome(result) ?? entry.terminalOutcome;
120
+ const childTerminalOutcome = workflowTerminalOutcomeForResult(result) ?? entry.terminalOutcome;
258
121
  if (externalAdapter) resumability = { state: "not-resumable", reason: externalAdapter.nonResumableReason };
259
122
  const updatedEntry: WorkflowReceipt["entries"][string] = resumability.state === "resumable"
260
123
  ? {
@@ -287,7 +150,7 @@ function reconcileWorkflowReceipt(status: AsyncStatus, childRunId: string, resul
287
150
  ...(receipt.terminalOutcome ? { terminalOutcome: receipt.terminalOutcome } : {}),
288
151
  ...(resolution ? { workflowResolution: resolution } : {}),
289
152
  };
290
- if (resolution) next.recovery = workflowRecovery(next);
153
+ if (resolution) next.recovery = workflowRecoveryActions(next);
291
154
  else {
292
155
  delete next.workflowResolution;
293
156
  delete next.recovery;
@@ -317,6 +180,16 @@ export function reconcileDetachedWorkflowChildCompletion(input: {
317
180
  const asyncDir = job?.asyncDir ?? path.join(DIRS.async, input.workflowRunId);
318
181
  const status = readStatus(asyncDir);
319
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
+ }
320
193
  const next = applyDetachedChildToPausedWorkflow(status, {
321
194
  childRunId: input.childRunId,
322
195
  result: input.result,
@@ -325,17 +198,8 @@ export function reconcileDetachedWorkflowChildCompletion(input: {
325
198
  if (!next) return false;
326
199
  const outputReference = input.result.savedOutputPath ?? input.result.outputReference?.path;
327
200
  const outputPathMapping = outputPathMappingFromTask(input.result.task, outputReference);
328
- const settledStep = findWorkflowStep(next, input.childRunId, input.workflowKey);
201
+ const settledStep = findWorkflowSettlementStep(next, input.childRunId);
329
202
  if (settledStep && outputPathMapping) settledStep.outputPathMapping = outputPathMapping;
330
- writeAtomicJson(path.join(asyncDir, "status.json"), next);
331
- updateActiveRunIndex(asyncDir, next.state, next.toolCallId);
332
- if (job) {
333
- job.status = next.state;
334
- job.updatedAt = next.lastUpdate;
335
- job.activityState = next.activityState;
336
- job.steps = next.steps?.map((step, index) => ({ ...step, index }));
337
- job.workflow = next.workflow;
338
- }
339
203
  const resultPath = resultFilePath(DIRS.results, input.workflowRunId);
340
204
  let existing: Record<string, unknown> | undefined;
341
205
  try {
@@ -346,14 +210,55 @@ export function reconcileDetachedWorkflowChildCompletion(input: {
346
210
  }
347
211
  let receipt: WorkflowReceipt | undefined;
348
212
  let receiptError: string | undefined;
349
- const resolution = workflowResolution(next, input.result);
213
+ const resolution = classifyWorkflowSettlement(next, input.result.interrupted);
350
214
  try {
351
215
  receipt = reconcileWorkflowReceipt(next, input.childRunId, input.result, asyncDir, resolution);
352
216
  } catch (error) {
353
217
  receiptError = `Failed to reconcile async workflow receipt: ${error instanceof Error ? error.message : String(error)}`;
354
218
  }
355
- const published = publishedWorkflowResult(next, input.childRunId, input.result, asyncDir, existing, receipt);
356
- 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);
357
262
  if (receiptError) {
358
263
  appendDetachedWorkflowEvent(asyncDir, {
359
264
  ts: Date.now(),
@@ -363,18 +268,12 @@ export function reconcileDetachedWorkflowChildCompletion(input: {
363
268
  reconciledFromDetachedChild: input.childRunId,
364
269
  });
365
270
  }
366
- if (next.state === "complete" || next.state === "failed") {
367
- if (!resolution) throw new Error(`Terminal detached workflow '${input.workflowRunId}' has no resolution classification.`);
271
+ if (plan.completionEvent) {
368
272
  appendDetachedWorkflowEvent(asyncDir, {
369
273
  ts: Date.now(),
370
274
  runId: input.workflowRunId,
371
- type: "subagent.workflow.completed",
372
- state: next.state,
373
- workflowResolution: resolution,
374
- ...(receipt?.terminalOutcome ? { terminalOutcome: receipt.terminalOutcome } : {}),
375
- recovery: workflowRecovery(receipt),
376
- ...(next.error ? { error: next.error } : {}),
377
- reconciledFromDetachedChild: input.childRunId,
275
+ ...plan.completionEvent,
276
+ recovery: plan.recovery,
378
277
  });
379
278
  input.events?.emit(SUBAGENT_ASYNC_COMPLETE_EVENT, {
380
279
  id: input.workflowRunId,
@@ -382,16 +281,16 @@ export function reconcileDetachedWorkflowChildCompletion(input: {
382
281
  source: "async",
383
282
  mode: "workflow",
384
283
  agent: "workflow",
385
- success: next.state === "complete",
386
- state: next.state,
387
- workflowResolution: resolution,
388
- ...(receipt?.terminalOutcome ? { terminalOutcome: receipt.terminalOutcome } : {}),
389
- recovery: workflowRecovery(receipt),
390
- 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,
391
290
  reconciledFromDetachedChild: input.childRunId,
392
- ...(Array.isArray(published.results) ? { results: published.results } : {}),
393
- sessionId: next.sessionId,
394
- completionOwnerId: next.completionOwnerId,
291
+ results,
292
+ sessionId: plan.status.sessionId,
293
+ completionOwnerId: plan.status.completionOwnerId,
395
294
  timestamp: Date.now(),
396
295
  triggerTurn: true,
397
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
+ }