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.
- package/CHANGELOG.md +104 -0
- package/docs/agents.md +18 -9
- package/docs/configuration.md +4 -4
- package/docs/extension-api.md +40 -1
- package/docs/models.md +27 -4
- package/docs/observability.md +1 -1
- package/docs/tool-reference.md +92 -6
- package/docs/workflows.md +115 -0
- package/package.json +3 -1
- package/prompts/review-loop.md +2 -2
- package/skills/council-mode/SKILL.md +1 -1
- package/skills/pi-subagents/SKILL.md +3 -1
- package/skills/pi-subagents/references/execution-controls.md +6 -2
- package/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
- package/skills/pi-subagents/references/prompting-and-roles.md +26 -5
- package/src/agents/agent-management.ts +28 -17
- package/src/agents/agent-serializer.ts +9 -2
- package/src/agents/agents.ts +103 -27
- package/src/agents/runtime-agent-events.ts +70 -0
- package/src/agents/runtime-agent-registry.ts +19 -18
- package/src/api/agents.ts +10 -5
- package/src/api/background-work.ts +5 -1
- package/src/api/delegation.ts +0 -7
- package/src/api/preflight.ts +32 -10
- package/src/extension/fanout-child.ts +5 -3
- package/src/extension/index.ts +66 -27
- package/src/extension/public-execution.ts +15 -2
- package/src/extension/rpc.ts +35 -14
- package/src/extension/schemas.ts +37 -12
- package/src/extension/tool-description.ts +25 -6
- package/src/integrations/herdr-status.ts +5 -0
- 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 +78 -46
- package/src/runs/background/async-job-tracker.ts +14 -12
- package/src/runs/background/async-resume.ts +29 -27
- package/src/runs/background/async-status-snapshot.ts +23 -261
- package/src/runs/background/async-status.ts +68 -7
- package/src/runs/background/chain-append.ts +8 -3
- package/src/runs/background/chain-root-attachment.ts +60 -8
- package/src/runs/background/fleet-view.ts +21 -11
- package/src/runs/background/notify.ts +184 -9
- package/src/runs/background/result-delivery-ownership.ts +45 -0
- package/src/runs/background/result-files.ts +2 -1
- package/src/runs/background/result-watcher.ts +29 -10
- 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 +18 -8
- 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 +389 -350
- package/src/runs/background/subagent-wait.ts +58 -10
- package/src/runs/background/terminal-run-index.ts +1 -1
- package/src/runs/background/wait-completions.ts +22 -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 +134 -121
- 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 +527 -257
- package/src/runs/foreground/workflow-detach-reconcile.ts +130 -120
- package/src/runs/shared/abort-recovery.ts +119 -0
- package/src/runs/shared/async-status-projection.ts +463 -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 +5 -4
- package/src/runs/shared/dynamic-fanout.ts +3 -3
- package/src/runs/shared/fast-mode-extension.ts +5 -5
- package/src/runs/shared/host-step-status.ts +230 -0
- package/src/runs/shared/lane-metadata.ts +105 -0
- package/src/runs/shared/launch-cwd.ts +16 -0
- package/src/runs/shared/long-running-guard.ts +2 -1
- package/src/runs/shared/mcp-config-sources.ts +422 -0
- package/src/runs/shared/mcp-direct-tool-allowlist.ts +221 -158
- package/src/runs/shared/mcp-direct-tool-grant.ts +197 -0
- package/src/runs/shared/model-exclusions.ts +12 -0
- package/src/runs/shared/model-fallback.ts +25 -6
- 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 +15 -1
- package/src/runs/shared/pi-args.ts +81 -8
- package/src/runs/shared/single-output.ts +44 -4
- package/src/runs/shared/subagent-prompt-runtime.ts +98 -13
- 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 +11 -2
- package/src/shared/launch-contract.ts +5 -1
- package/src/shared/settings.ts +9 -103
- package/src/shared/types.ts +240 -30
- package/src/shared/utils.ts +41 -84
- 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/slash/subagents-admin.ts +3 -0
- package/src/tui/fleet-status.ts +125 -74
- package/src/tui/fleet.ts +11 -5
- package/src/tui/render.ts +330 -33
- package/src/watchdog/turn-delta.ts +1 -1
- package/src/workflows/chat-progress.ts +6 -3
- package/src/workflows/host-command.ts +230 -0
- package/src/workflows/scripted-workflow.ts +496 -35
- package/src/workflows/workflow-child-summary.ts +9 -5
- package/src/workflows/workflow-preflight.ts +270 -0
- package/src/workflows/workflow-receipt.ts +79 -5
- package/src/workflows/workflow-settlement.ts +246 -0
- 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 {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
63
|
+
success: result.exitCode === 0 && !result.error && !result.interrupted,
|
|
95
64
|
output,
|
|
96
65
|
outputState: output.trim() ? "present" : "absent",
|
|
97
66
|
detached: undefined,
|
|
98
|
-
...(
|
|
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
|
|
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
|
|
116
|
-
const
|
|
117
|
-
const
|
|
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(
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
252
|
-
|
|
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 (
|
|
271
|
+
if (plan.completionEvent) {
|
|
263
272
|
appendDetachedWorkflowEvent(asyncDir, {
|
|
264
273
|
ts: Date.now(),
|
|
265
274
|
runId: input.workflowRunId,
|
|
266
|
-
|
|
267
|
-
|
|
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:
|
|
278
|
-
state:
|
|
279
|
-
|
|
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
|
-
|
|
282
|
-
sessionId:
|
|
283
|
-
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
|
+
}
|