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.
- package/CHANGELOG.md +102 -0
- package/docs/agents.md +5 -3
- package/docs/configuration.md +4 -4
- package/docs/extension-api.md +1 -1
- package/docs/models.md +24 -1
- package/docs/observability.md +1 -1
- package/docs/tool-reference.md +93 -5
- package/docs/workflows.md +94 -3
- package/package.json +3 -1
- package/prompts/review-loop.md +2 -2
- package/skills/council-mode/SKILL.md +48 -243
- package/skills/council-mode/references/pass-contracts.md +150 -0
- package/skills/pi-subagents/SKILL.md +87 -37
- package/skills/pi-subagents/references/constraints-and-recipes.md +29 -233
- package/skills/pi-subagents/references/execution-controls.md +52 -7
- package/skills/pi-subagents/references/management-authoring-rpc.md +3 -4
- package/skills/pi-subagents/references/multi-lane-orchestration.md +13 -1
- package/skills/pi-subagents/references/prompting-and-roles.md +54 -26
- package/skills/pi-subagents/references/review-and-validation.md +73 -0
- package/src/agents/agent-management.ts +159 -42
- package/src/agents/agent-serializer.ts +4 -2
- package/src/agents/agents.ts +67 -26
- package/src/agents/runtime-agent-registry.ts +9 -16
- package/src/api/background-work.ts +5 -1
- package/src/api/delegation.ts +0 -7
- package/src/api/preflight.ts +6 -9
- package/src/api/shared-types.ts +2 -0
- package/src/extension/fanout-child.ts +5 -3
- package/src/extension/index.ts +51 -23
- package/src/extension/public-execution.ts +16 -2
- package/src/extension/schemas.ts +36 -10
- package/src/extension/tool-description.ts +24 -5
- package/src/intercom/result-intercom.ts +2 -0
- package/src/profiles/profiles.ts +5 -6
- package/src/runs/background/active-async-capacity.ts +2 -2
- package/src/runs/background/async-execution.ts +52 -37
- package/src/runs/background/async-job-tracker.ts +17 -12
- package/src/runs/background/async-resume.ts +23 -25
- package/src/runs/background/async-status-snapshot.ts +23 -261
- package/src/runs/background/async-status.ts +110 -8
- package/src/runs/background/chain-append.ts +6 -3
- package/src/runs/background/chain-root-attachment.ts +60 -8
- package/src/runs/background/control-channel.ts +3 -2
- package/src/runs/background/fleet-view.ts +21 -11
- package/src/runs/background/notify.ts +158 -6
- package/src/runs/background/result-files.ts +2 -1
- package/src/runs/background/result-watcher.ts +2 -0
- package/src/runs/background/resume-guidance.ts +1 -1
- package/src/runs/background/retained-children.ts +1 -1
- package/src/runs/background/run-status.ts +30 -9
- package/src/runs/background/scheduled-runs.ts +86 -7
- package/src/runs/background/stale-run-reconciler.ts +10 -4
- package/src/runs/background/steering.ts +4 -14
- package/src/runs/background/subagent-runner.ts +351 -358
- package/src/runs/background/subagent-wait.ts +68 -12
- package/src/runs/background/terminal-run-index.ts +1 -1
- package/src/runs/background/wait-completions.ts +25 -1
- package/src/runs/background/wait-config.ts +23 -9
- package/src/runs/background/wait-tool.ts +9 -2
- package/src/runs/foreground/async-steering-action.ts +2 -2
- package/src/runs/foreground/execution.ts +125 -122
- package/src/runs/foreground/foreground-control.ts +3 -0
- package/src/runs/foreground/foreground-history.ts +1 -0
- package/src/runs/foreground/subagent-executor.ts +576 -240
- package/src/runs/foreground/workflow-detach-reconcile.ts +99 -200
- package/src/runs/shared/abort-recovery.ts +119 -0
- package/src/runs/shared/async-status-projection.ts +597 -0
- package/src/runs/shared/background-process-options.ts +9 -0
- package/src/runs/shared/child-identity.ts +19 -4
- package/src/runs/shared/child-launch-plan.ts +151 -0
- package/src/runs/shared/completion-evidence.ts +89 -0
- package/src/runs/shared/completion-guard.ts +1 -1
- package/src/runs/shared/dynamic-fanout.ts +2 -2
- package/src/runs/shared/host-step-status.ts +230 -0
- package/src/runs/shared/lane-metadata.ts +105 -0
- package/src/runs/shared/mcp-config-sources.ts +42 -6
- package/src/runs/shared/mcp-direct-tool-allowlist.ts +93 -143
- package/src/runs/shared/mcp-direct-tool-grant.ts +194 -0
- package/src/runs/shared/model-fallback.ts +9 -2
- package/src/runs/shared/mutation-evidence.ts +52 -3
- package/src/runs/shared/nested-events.ts +6 -2
- package/src/runs/shared/nested-render.ts +7 -3
- package/src/runs/shared/parallel-handoff.ts +419 -7
- package/src/runs/shared/parallel-utils.ts +7 -0
- package/src/runs/shared/pi-args.ts +67 -3
- package/src/runs/shared/single-output.ts +72 -22
- package/src/runs/shared/subagent-prompt-runtime.ts +33 -6
- package/src/runs/shared/workflow-graph.ts +15 -0
- package/src/runs/shared/worktree-cleanup-plan.ts +847 -0
- package/src/runs/shared/worktree.ts +18 -0
- package/src/shared/child-session-name.ts +46 -0
- package/src/shared/extension-context.ts +24 -0
- package/src/shared/formatters.ts +5 -2
- package/src/shared/launch-contract.ts +1 -1
- package/src/shared/settings.ts +9 -103
- package/src/shared/types.ts +232 -30
- package/src/shared/utils.ts +35 -55
- package/src/slash/delegation-adapters.ts +1 -8
- package/src/slash/delegation-request.ts +0 -4
- package/src/slash/slash-bridge.ts +1 -2
- package/src/slash/slash-commands.ts +369 -90
- package/src/slash/slash-live-state.ts +22 -11
- package/src/tui/fleet-status.ts +134 -75
- package/src/tui/fleet.ts +11 -5
- package/src/tui/render-helpers.ts +31 -0
- package/src/tui/render.ts +895 -121
- package/src/watchdog/change-signature.ts +40 -1
- package/src/watchdog/turn-delta.ts +1 -1
- package/src/workflows/chat-progress.ts +6 -3
- package/src/workflows/host-command.ts +235 -0
- package/src/workflows/scripted-workflow.ts +503 -38
- package/src/workflows/workflow-child-summary.ts +9 -5
- package/src/workflows/workflow-preflight.ts +270 -0
- package/src/workflows/workflow-receipt.ts +43 -4
- package/src/workflows/workflow-settlement.ts +246 -0
- 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 {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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 =
|
|
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:
|
|
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
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
356
|
-
|
|
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 (
|
|
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
|
-
|
|
372
|
-
|
|
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:
|
|
386
|
-
state:
|
|
387
|
-
workflowResolution: resolution,
|
|
388
|
-
...(receipt?.terminalOutcome ? { terminalOutcome: receipt.terminalOutcome } : {}),
|
|
389
|
-
recovery:
|
|
390
|
-
summary: typeof
|
|
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
|
-
|
|
393
|
-
sessionId:
|
|
394
|
-
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
|
+
}
|