pi-subagents 0.31.0 → 0.32.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 +48 -0
- package/README.md +170 -8
- package/package.json +1 -1
- package/skills/pi-subagents/SKILL.md +6 -1
- package/src/agents/agent-management.ts +6 -1
- package/src/agents/agents.ts +55 -11
- package/src/extension/companion-suggestions.ts +359 -0
- package/src/extension/config.ts +27 -4
- package/src/extension/doctor.ts +2 -0
- package/src/extension/fanout-child.ts +1 -0
- package/src/extension/index.ts +69 -4
- package/src/extension/schemas.ts +2 -2
- package/src/intercom/intercom-bridge.ts +25 -1
- package/src/profiles/profiles.ts +637 -0
- package/src/runs/background/async-execution.ts +138 -33
- package/src/runs/background/async-job-tracker.ts +77 -1
- package/src/runs/background/async-resume.ts +11 -13
- package/src/runs/background/async-status.ts +41 -9
- package/src/runs/background/chain-root-attachment.ts +34 -4
- package/src/runs/background/control-channel.ts +227 -0
- package/src/runs/background/run-status.ts +1 -0
- package/src/runs/background/stale-run-reconciler.ts +28 -1
- package/src/runs/background/subagent-runner.ts +459 -113
- package/src/runs/foreground/chain-execution.ts +29 -7
- package/src/runs/foreground/execution.ts +24 -6
- package/src/runs/foreground/subagent-executor.ts +240 -44
- package/src/runs/shared/acceptance.ts +45 -22
- package/src/runs/shared/dynamic-fanout.ts +1 -1
- package/src/runs/shared/model-fallback.ts +4 -0
- package/src/runs/shared/nested-events.ts +58 -0
- package/src/runs/shared/parallel-utils.ts +49 -1
- package/src/runs/shared/pi-args.ts +5 -3
- package/src/runs/shared/pi-spawn.ts +52 -20
- package/src/runs/shared/single-output.ts +2 -0
- package/src/runs/shared/subagent-prompt-runtime.ts +3 -2
- package/src/runs/shared/worktree.ts +28 -5
- package/src/shared/artifacts.ts +15 -1
- package/src/shared/fork-context.ts +133 -22
- package/src/shared/types.ts +82 -3
- package/src/shared/utils.ts +99 -14
- package/src/slash/slash-commands.ts +726 -40
- package/src/tui/render.ts +16 -4
|
@@ -2,9 +2,9 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { formatDuration, formatModelThinking, formatTokens, shortenPath } from "../../shared/formatters.ts";
|
|
4
4
|
import { formatActivityLabel, formatParallelOutcome } from "../../shared/status-format.ts";
|
|
5
|
-
import { type ActivityState, type AsyncJobStep, type AsyncParallelGroupStatus, type AsyncStatus, type NestedRunSummary, type SubagentRunMode, type TokenUsage } from "../../shared/types.ts";
|
|
5
|
+
import { type ActivityState, type AsyncJobStep, type AsyncParallelGroupStatus, type AsyncStatus, type CostSummary, type NestedRunSummary, type SubagentRunMode, type TokenUsage } from "../../shared/types.ts";
|
|
6
6
|
import { readStatus } from "../../shared/utils.ts";
|
|
7
|
-
import { attachRootChildrenToSteps,
|
|
7
|
+
import { attachRootChildrenToSteps, buildNestedRouteIndex, type NestedRoute, projectNestedEvents } from "../shared/nested-events.ts";
|
|
8
8
|
import { formatNestedRunStatusLines } from "../shared/nested-render.ts";
|
|
9
9
|
import { flatToLogicalStepIndex, normalizeParallelGroups } from "./parallel-groups.ts";
|
|
10
10
|
import { reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-reconciler.ts";
|
|
@@ -29,11 +29,13 @@ interface AsyncRunStepSummary {
|
|
|
29
29
|
toolCount?: number;
|
|
30
30
|
durationMs?: number;
|
|
31
31
|
tokens?: TokenUsage;
|
|
32
|
+
totalCost?: CostSummary;
|
|
32
33
|
skills?: string[];
|
|
33
34
|
model?: string;
|
|
34
35
|
thinking?: string;
|
|
35
36
|
attemptedModels?: string[];
|
|
36
37
|
error?: string;
|
|
38
|
+
timedOut?: boolean;
|
|
37
39
|
children?: NestedRunSummary[];
|
|
38
40
|
}
|
|
39
41
|
|
|
@@ -42,6 +44,7 @@ export interface AsyncRunSummary {
|
|
|
42
44
|
asyncDir: string;
|
|
43
45
|
sessionId?: string;
|
|
44
46
|
state: "queued" | "running" | "complete" | "failed" | "paused";
|
|
47
|
+
error?: string;
|
|
45
48
|
activityState?: ActivityState;
|
|
46
49
|
lastActivityAt?: number;
|
|
47
50
|
currentTool?: string;
|
|
@@ -54,6 +57,9 @@ export interface AsyncRunSummary {
|
|
|
54
57
|
startedAt: number;
|
|
55
58
|
lastUpdate?: number;
|
|
56
59
|
endedAt?: number;
|
|
60
|
+
timeoutMs?: number;
|
|
61
|
+
deadlineAt?: number;
|
|
62
|
+
timedOut?: boolean;
|
|
57
63
|
currentStep?: number;
|
|
58
64
|
chainStepCount?: number;
|
|
59
65
|
pendingAppends?: number;
|
|
@@ -62,6 +68,7 @@ export interface AsyncRunSummary {
|
|
|
62
68
|
sessionDir?: string;
|
|
63
69
|
outputFile?: string;
|
|
64
70
|
totalTokens?: TokenUsage;
|
|
71
|
+
totalCost?: CostSummary;
|
|
65
72
|
sessionFile?: string;
|
|
66
73
|
nestedChildren?: NestedRunSummary[];
|
|
67
74
|
nestedWarnings?: string[];
|
|
@@ -122,7 +129,7 @@ function deriveAsyncActivityState(asyncDir: string, status: AsyncStatus): { acti
|
|
|
122
129
|
};
|
|
123
130
|
}
|
|
124
131
|
|
|
125
|
-
function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string }, nestedWarnings: string[] = []): AsyncRunSummary {
|
|
132
|
+
function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string }, nestedWarnings: string[] = [], nestedRoute?: NestedRoute): AsyncRunSummary {
|
|
126
133
|
if (status.sessionId !== undefined && typeof status.sessionId !== "string") {
|
|
127
134
|
throw new Error(`Invalid async status '${path.join(asyncDir, "status.json")}': sessionId must be a string.`);
|
|
128
135
|
}
|
|
@@ -131,9 +138,11 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
|
|
|
131
138
|
const chainStepCount = status.chainStepCount ?? steps.length;
|
|
132
139
|
const parallelGroups = normalizeParallelGroups(status.parallelGroups, steps.length, chainStepCount);
|
|
133
140
|
let nestedChildren: NestedRunSummary[] = [];
|
|
134
|
-
if (nestedWarnings.length === 0) {
|
|
141
|
+
if (nestedWarnings.length === 0 && nestedRoute) {
|
|
135
142
|
try {
|
|
136
|
-
|
|
143
|
+
// The route is resolved by the caller via buildNestedRouteIndex, so this
|
|
144
|
+
// avoids a fresh scan of the nested-events directory per run.
|
|
145
|
+
nestedChildren = projectNestedEvents(nestedRoute)?.children ?? [];
|
|
137
146
|
} catch (error) {
|
|
138
147
|
nestedWarnings.push(`Nested status unavailable: ${getErrorMessage(error)}`);
|
|
139
148
|
}
|
|
@@ -161,11 +170,13 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
|
|
|
161
170
|
...(step.toolCount !== undefined ? { toolCount: step.toolCount } : {}),
|
|
162
171
|
...(step.durationMs !== undefined ? { durationMs: step.durationMs } : {}),
|
|
163
172
|
...(step.tokens ? { tokens: step.tokens } : {}),
|
|
173
|
+
...(step.totalCost ? { totalCost: step.totalCost } : {}),
|
|
164
174
|
...(step.skills ? { skills: step.skills } : {}),
|
|
165
175
|
...(step.model ? { model: step.model } : {}),
|
|
166
176
|
...(step.thinking ? { thinking: step.thinking } : {}),
|
|
167
177
|
...(step.attemptedModels ? { attemptedModels: step.attemptedModels } : {}),
|
|
168
178
|
...(step.error ? { error: step.error } : {}),
|
|
179
|
+
...(step.timedOut !== undefined ? { timedOut: step.timedOut } : {}),
|
|
169
180
|
...(step.children?.length ? { children: step.children } : {}),
|
|
170
181
|
};
|
|
171
182
|
});
|
|
@@ -175,6 +186,7 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
|
|
|
175
186
|
asyncDir,
|
|
176
187
|
...(status.sessionId ? { sessionId: status.sessionId } : {}),
|
|
177
188
|
state: status.state,
|
|
189
|
+
...(status.error ? { error: status.error } : {}),
|
|
178
190
|
activityState,
|
|
179
191
|
lastActivityAt,
|
|
180
192
|
currentTool: status.currentTool,
|
|
@@ -187,6 +199,9 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
|
|
|
187
199
|
startedAt: status.startedAt,
|
|
188
200
|
lastUpdate: status.lastUpdate,
|
|
189
201
|
endedAt: status.endedAt,
|
|
202
|
+
...(status.timeoutMs !== undefined ? { timeoutMs: status.timeoutMs } : {}),
|
|
203
|
+
...(status.deadlineAt !== undefined ? { deadlineAt: status.deadlineAt } : {}),
|
|
204
|
+
...(status.timedOut !== undefined ? { timedOut: status.timedOut } : {}),
|
|
190
205
|
currentStep: status.currentStep,
|
|
191
206
|
...(status.chainStepCount !== undefined ? { chainStepCount: status.chainStepCount } : {}),
|
|
192
207
|
...(status.pendingAppends !== undefined ? { pendingAppends: status.pendingAppends } : {}),
|
|
@@ -197,6 +212,7 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
|
|
|
197
212
|
...(status.sessionDir ? { sessionDir: status.sessionDir } : {}),
|
|
198
213
|
...(status.outputFile ? { outputFile: status.outputFile } : {}),
|
|
199
214
|
...(status.totalTokens ? { totalTokens: status.totalTokens } : {}),
|
|
215
|
+
...(status.totalCost ? { totalCost: status.totalCost } : {}),
|
|
200
216
|
...(status.sessionFile ? { sessionFile: status.sessionFile } : {}),
|
|
201
217
|
};
|
|
202
218
|
}
|
|
@@ -233,6 +249,16 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
|
|
|
233
249
|
|
|
234
250
|
const allowedStates = options.states ? new Set(options.states) : undefined;
|
|
235
251
|
const runs: AsyncRunSummary[] = [];
|
|
252
|
+
// Route resolution for every run shares a single index built from the
|
|
253
|
+
// nested-events directory, so the per-run lookup is O(1) instead of scanning
|
|
254
|
+
// the directory once per run. The index is built lazily on first use, so
|
|
255
|
+
// load-time restoration (which only wants queued/running runs) skips it
|
|
256
|
+
// entirely when no active runs match.
|
|
257
|
+
let nestedRouteIndex: Map<string, NestedRoute> | undefined;
|
|
258
|
+
const resolveNestedRoute = (rootRunId: string): NestedRoute | undefined => {
|
|
259
|
+
if (!nestedRouteIndex) nestedRouteIndex = buildNestedRouteIndex();
|
|
260
|
+
return nestedRouteIndex.get(rootRunId);
|
|
261
|
+
};
|
|
236
262
|
for (const entry of entries) {
|
|
237
263
|
const asyncDir = path.join(asyncDirRoot, entry);
|
|
238
264
|
const reconciliation = options.reconcile === false
|
|
@@ -240,16 +266,21 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
|
|
|
240
266
|
: reconcileAsyncRun(asyncDir, { resultsDir: options.resultsDir, kill: options.kill, now: options.now });
|
|
241
267
|
const status = (reconciliation?.status ?? readStatus(asyncDir)) as (AsyncStatus & { cwd?: string }) | null;
|
|
242
268
|
if (!status) continue;
|
|
269
|
+
// Filter before the nested-route lookup: the lookup builds an index over
|
|
270
|
+
// the nested-events directory, so deferring it for filtered-out runs keeps
|
|
271
|
+
// restoration at load from scanning that directory when no active runs
|
|
272
|
+
// match.
|
|
273
|
+
if (allowedStates && !allowedStates.has(status.state)) continue;
|
|
274
|
+
if (options.sessionId && status.sessionId !== options.sessionId) continue;
|
|
243
275
|
const nestedWarnings: string[] = [];
|
|
276
|
+
let nestedRoute: NestedRoute | undefined;
|
|
244
277
|
try {
|
|
245
|
-
|
|
278
|
+
nestedRoute = resolveNestedRoute(status.runId || path.basename(asyncDir));
|
|
246
279
|
if (nestedRoute) reconcileNestedAsyncDescendants(nestedRoute, { resultsDir: options.resultsDir, kill: options.kill, now: options.now });
|
|
247
280
|
} catch (error) {
|
|
248
281
|
nestedWarnings.push(`Nested status unavailable: ${getErrorMessage(error)}`);
|
|
249
282
|
}
|
|
250
|
-
const summary = statusToSummary(asyncDir, status, nestedWarnings);
|
|
251
|
-
if (allowedStates && !allowedStates.has(summary.state)) continue;
|
|
252
|
-
if (options.sessionId && summary.sessionId !== options.sessionId) continue;
|
|
283
|
+
const summary = statusToSummary(asyncDir, status, nestedWarnings, nestedRoute);
|
|
253
284
|
runs.push(summary);
|
|
254
285
|
}
|
|
255
286
|
|
|
@@ -328,6 +359,7 @@ export function formatAsyncRunList(runs: AsyncRunSummary[], heading = "Active as
|
|
|
328
359
|
const attached = new Set(run.steps.flatMap((step) => step.children?.map((child) => child.id) ?? []));
|
|
329
360
|
const unattached = run.nestedChildren?.filter((child) => !attached.has(child.id)) ?? [];
|
|
330
361
|
lines.push(...formatNestedRunStatusLines(unattached, { indent: " ", maxLines: 12 }));
|
|
362
|
+
if (run.error) lines.push(` Error: ${run.error}`);
|
|
331
363
|
for (const warning of run.nestedWarnings ?? []) lines.push(` Warning: ${warning}`);
|
|
332
364
|
const outputPath = formatAsyncRunOutputPath(run);
|
|
333
365
|
if (outputPath) lines.push(` output: ${shortenPath(outputPath)}`);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import type { AcceptanceLedger, AsyncStatus, ModelAttempt } from "../../shared/types.ts";
|
|
3
|
+
import type { AcceptanceLedger, AsyncStatus, CostSummary, ModelAttempt } from "../../shared/types.ts";
|
|
4
4
|
import { readStatus } from "../../shared/utils.ts";
|
|
5
5
|
|
|
6
6
|
export interface ImportedAsyncRoot {
|
|
@@ -21,26 +21,32 @@ export interface ImportedAsyncRootResult {
|
|
|
21
21
|
model?: string;
|
|
22
22
|
attemptedModels?: string[];
|
|
23
23
|
modelAttempts?: ModelAttempt[];
|
|
24
|
+
totalCost?: CostSummary;
|
|
24
25
|
structuredOutput?: unknown;
|
|
25
26
|
structuredOutputPath?: string;
|
|
26
27
|
structuredOutputSchemaPath?: string;
|
|
27
28
|
acceptance?: AcceptanceLedger;
|
|
29
|
+
timedOut?: boolean;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
interface AsyncResultFile {
|
|
31
33
|
state?: string;
|
|
32
34
|
success?: boolean;
|
|
33
35
|
summary?: string;
|
|
36
|
+
error?: string;
|
|
37
|
+
timedOut?: boolean;
|
|
34
38
|
results?: Array<{
|
|
35
39
|
agent?: string;
|
|
36
40
|
output?: string;
|
|
37
41
|
error?: string;
|
|
38
42
|
success?: boolean;
|
|
43
|
+
timedOut?: boolean;
|
|
39
44
|
sessionFile?: string;
|
|
40
45
|
intercomTarget?: string;
|
|
41
46
|
model?: string;
|
|
42
47
|
attemptedModels?: string[];
|
|
43
48
|
modelAttempts?: ModelAttempt[];
|
|
49
|
+
totalCost?: CostSummary;
|
|
44
50
|
structuredOutput?: unknown;
|
|
45
51
|
structuredOutputPath?: string;
|
|
46
52
|
structuredOutputSchemaPath?: string;
|
|
@@ -85,6 +91,7 @@ function resultState(result: AsyncResultFile | undefined, child: NonNullable<Asy
|
|
|
85
91
|
|
|
86
92
|
function outputFromTerminalStatus(root: ImportedAsyncRoot, status: AsyncStatus, step: NonNullable<AsyncStatus["steps"]>[number] | undefined): ImportedAsyncRootResult {
|
|
87
93
|
const agent = step?.agent ?? status.steps?.[root.index]?.agent ?? "subagent";
|
|
94
|
+
const timedOut = step?.timedOut === true || status.timedOut === true;
|
|
88
95
|
const message = step?.error ?? status.error ?? `Attached async root ${root.runId} ended without a result file at ${root.resultPath}.`;
|
|
89
96
|
return {
|
|
90
97
|
agent,
|
|
@@ -92,10 +99,12 @@ function outputFromTerminalStatus(root: ImportedAsyncRoot, status: AsyncStatus,
|
|
|
92
99
|
success: false,
|
|
93
100
|
exitCode: 1,
|
|
94
101
|
error: message,
|
|
102
|
+
...(timedOut ? { timedOut: true } : {}),
|
|
95
103
|
...(step?.sessionFile ?? status.sessionFile ? { sessionFile: step?.sessionFile ?? status.sessionFile } : {}),
|
|
96
104
|
...(step?.model ? { model: step.model } : {}),
|
|
97
105
|
...(step?.attemptedModels ? { attemptedModels: step.attemptedModels } : {}),
|
|
98
106
|
...(step?.modelAttempts ? { modelAttempts: step.modelAttempts } : {}),
|
|
107
|
+
...(step?.totalCost ? { totalCost: step.totalCost } : {}),
|
|
99
108
|
...(step?.structuredOutput !== undefined ? { structuredOutput: step.structuredOutput } : {}),
|
|
100
109
|
...(step?.structuredOutputPath ? { structuredOutputPath: step.structuredOutputPath } : {}),
|
|
101
110
|
...(step?.structuredOutputSchemaPath ? { structuredOutputSchemaPath: step.structuredOutputSchemaPath } : {}),
|
|
@@ -103,25 +112,45 @@ function outputFromTerminalStatus(root: ImportedAsyncRoot, status: AsyncStatus,
|
|
|
103
112
|
};
|
|
104
113
|
}
|
|
105
114
|
|
|
115
|
+
function outputFromTimeout(root: ImportedAsyncRoot, status: AsyncStatus | null, message: string): ImportedAsyncRootResult {
|
|
116
|
+
const step = selectedStatusStep(status, root.index);
|
|
117
|
+
return {
|
|
118
|
+
agent: step?.agent ?? status?.steps?.[root.index]?.agent ?? "subagent",
|
|
119
|
+
output: message,
|
|
120
|
+
success: false,
|
|
121
|
+
exitCode: 1,
|
|
122
|
+
error: message,
|
|
123
|
+
timedOut: true,
|
|
124
|
+
...(step?.sessionFile ?? status?.sessionFile ? { sessionFile: step?.sessionFile ?? status?.sessionFile } : {}),
|
|
125
|
+
...(step?.model ? { model: step.model } : {}),
|
|
126
|
+
...(step?.attemptedModels ? { attemptedModels: step.attemptedModels } : {}),
|
|
127
|
+
...(step?.modelAttempts ? { modelAttempts: step.modelAttempts } : {}),
|
|
128
|
+
...(step?.totalCost ? { totalCost: step.totalCost } : {}),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
106
132
|
function buildImportedResult(root: ImportedAsyncRoot, status: AsyncStatus | null, result: AsyncResultFile): ImportedAsyncRootResult {
|
|
107
133
|
const child = result.results?.[root.index];
|
|
108
134
|
const step = selectedStatusStep(status, root.index);
|
|
109
135
|
const state = resultState(result, child);
|
|
110
136
|
const agent = child?.agent ?? step?.agent ?? status?.steps?.[root.index]?.agent ?? "subagent";
|
|
111
137
|
const output = child?.output ?? result.summary ?? "";
|
|
112
|
-
const
|
|
113
|
-
const
|
|
138
|
+
const timedOut = child?.timedOut === true || step?.timedOut === true || result.timedOut === true || status?.timedOut === true;
|
|
139
|
+
const success = state === "complete" && !timedOut;
|
|
140
|
+
const error = child?.error ?? (success ? undefined : result.error ?? result.summary ?? status?.error ?? `Attached async root ${root.runId} did not complete successfully.`);
|
|
114
141
|
return {
|
|
115
142
|
agent,
|
|
116
143
|
output: success ? output : (output || error || ""),
|
|
117
144
|
success,
|
|
118
145
|
exitCode: success ? 0 : 1,
|
|
119
146
|
...(error ? { error } : {}),
|
|
147
|
+
...(timedOut ? { timedOut: true } : {}),
|
|
120
148
|
...(child?.sessionFile ?? step?.sessionFile ?? status?.sessionFile ? { sessionFile: child?.sessionFile ?? step?.sessionFile ?? status?.sessionFile } : {}),
|
|
121
149
|
...(child?.intercomTarget ? { intercomTarget: child.intercomTarget } : {}),
|
|
122
150
|
...(child?.model ?? step?.model ? { model: child?.model ?? step?.model } : {}),
|
|
123
151
|
...(child?.attemptedModels ?? step?.attemptedModels ? { attemptedModels: child?.attemptedModels ?? step?.attemptedModels } : {}),
|
|
124
152
|
...(child?.modelAttempts ?? step?.modelAttempts ? { modelAttempts: child?.modelAttempts ?? step?.modelAttempts } : {}),
|
|
153
|
+
...(child?.totalCost ?? step?.totalCost ? { totalCost: child?.totalCost ?? step?.totalCost } : {}),
|
|
125
154
|
...(child?.structuredOutput !== undefined ? { structuredOutput: child.structuredOutput } : step?.structuredOutput !== undefined ? { structuredOutput: step.structuredOutput } : {}),
|
|
126
155
|
...(child?.structuredOutputPath ?? step?.structuredOutputPath ? { structuredOutputPath: child?.structuredOutputPath ?? step?.structuredOutputPath } : {}),
|
|
127
156
|
...(child?.structuredOutputSchemaPath ?? step?.structuredOutputSchemaPath ? { structuredOutputSchemaPath: child?.structuredOutputSchemaPath ?? step?.structuredOutputSchemaPath } : {}),
|
|
@@ -131,7 +160,7 @@ function buildImportedResult(root: ImportedAsyncRoot, status: AsyncStatus | null
|
|
|
131
160
|
|
|
132
161
|
export async function waitForImportedAsyncRoot(
|
|
133
162
|
root: ImportedAsyncRoot,
|
|
134
|
-
options: { pollIntervalMs?: number; terminalResultGraceMs?: number; now?: () => number } = {},
|
|
163
|
+
options: { pollIntervalMs?: number; terminalResultGraceMs?: number; now?: () => number; shouldAbort?: () => boolean; timeoutMessage?: string } = {},
|
|
135
164
|
): Promise<ImportedAsyncRootResult> {
|
|
136
165
|
const pollIntervalMs = options.pollIntervalMs ?? 500;
|
|
137
166
|
const terminalResultGraceMs = options.terminalResultGraceMs ?? 1_000;
|
|
@@ -139,6 +168,7 @@ export async function waitForImportedAsyncRoot(
|
|
|
139
168
|
let terminalSince: number | undefined;
|
|
140
169
|
for (;;) {
|
|
141
170
|
const status = readStatus(root.asyncDir);
|
|
171
|
+
if (options.shouldAbort?.()) return outputFromTimeout(root, status, options.timeoutMessage ?? "Subagent timed out.");
|
|
142
172
|
const result = readResultFile(root.resultPath);
|
|
143
173
|
if (result) return buildImportedResult(root, status, result);
|
|
144
174
|
if (isTerminalStatus(status, root.index)) {
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-OS control channel for async subagent runs.
|
|
3
|
+
*
|
|
4
|
+
* Background runs are detached OS processes. The original control path delivered
|
|
5
|
+
* an interrupt with `process.kill(pid, SIGUSR2|SIGBREAK)`, but Windows cannot
|
|
6
|
+
* deliver those signals cross-process via `process.kill` and throws `ENOSYS`,
|
|
7
|
+
* which left async runs uninterruptible (no stop, no live steer) on Windows.
|
|
8
|
+
*
|
|
9
|
+
* This module adds a portable, file-based control inbox inside the run directory.
|
|
10
|
+
* The parent drops an interrupt request file; the runner watches the inbox and
|
|
11
|
+
* routes the request into its existing graceful `interruptRunner()` (pause +
|
|
12
|
+
* resumable), identically on every platform. The OS signal is kept only as an
|
|
13
|
+
* opportunistic fast-path; its failure is non-fatal because the file inbox is
|
|
14
|
+
* authoritative.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import { writeAtomicJson } from "../../shared/atomic-json.ts";
|
|
20
|
+
import { POLL_INTERVAL_MS } from "../../shared/types.ts";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Opportunistic fast-path interrupt signal. On Unix `SIGUSR2` is trapped by the
|
|
24
|
+
* runner; on Windows `process.kill(pid, "SIGBREAK")` is not deliverable
|
|
25
|
+
* cross-process and throws `ENOSYS`, so the file inbox below is the real channel.
|
|
26
|
+
*/
|
|
27
|
+
export const INTERRUPT_SIGNAL: NodeJS.Signals = process.platform === "win32" ? "SIGBREAK" : "SIGUSR2";
|
|
28
|
+
|
|
29
|
+
export type ControlChannelFs = Pick<typeof fs, "mkdirSync" | "existsSync" | "rmSync" | "watch">;
|
|
30
|
+
export type ControlChannelTimers = { setInterval: typeof setInterval; clearInterval: typeof clearInterval };
|
|
31
|
+
type KillFn = (pid: number, signal?: NodeJS.Signals | 0) => unknown;
|
|
32
|
+
|
|
33
|
+
export interface InterruptRequest {
|
|
34
|
+
type: "interrupt";
|
|
35
|
+
ts?: number;
|
|
36
|
+
source?: string;
|
|
37
|
+
reason?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface TimeoutRequest {
|
|
41
|
+
type: "timeout";
|
|
42
|
+
ts?: number;
|
|
43
|
+
source?: string;
|
|
44
|
+
reason?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Control inbox directory inside an async run dir. */
|
|
48
|
+
export function controlInboxDir(asyncDir: string): string {
|
|
49
|
+
return path.join(asyncDir, "control");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Path of the portable interrupt request file. */
|
|
53
|
+
export function interruptRequestPath(asyncDir: string): string {
|
|
54
|
+
return path.join(controlInboxDir(asyncDir), "interrupt.json");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Path of the portable timeout request file. */
|
|
58
|
+
export function timeoutRequestPath(asyncDir: string): string {
|
|
59
|
+
return path.join(controlInboxDir(asyncDir), "timeout.json");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Parent side: drop a portable interrupt request the runner's inbox watcher will
|
|
64
|
+
* pick up regardless of OS. Written atomically (temp + rename), dir auto-created.
|
|
65
|
+
*/
|
|
66
|
+
export function requestAsyncInterrupt(
|
|
67
|
+
asyncDir: string,
|
|
68
|
+
payload: Omit<InterruptRequest, "type"> = {},
|
|
69
|
+
deps: { now?: () => number } = {},
|
|
70
|
+
): string {
|
|
71
|
+
const requestPath = interruptRequestPath(asyncDir);
|
|
72
|
+
const request: InterruptRequest = { ...payload, ts: payload.ts ?? deps.now?.() ?? Date.now(), type: "interrupt" };
|
|
73
|
+
writeAtomicJson(requestPath, request);
|
|
74
|
+
return requestPath;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function requestAsyncTimeout(
|
|
78
|
+
asyncDir: string,
|
|
79
|
+
payload: Omit<TimeoutRequest, "type"> = {},
|
|
80
|
+
deps: { now?: () => number } = {},
|
|
81
|
+
): string {
|
|
82
|
+
const requestPath = timeoutRequestPath(asyncDir);
|
|
83
|
+
const request: TimeoutRequest = { ...payload, ts: payload.ts ?? deps.now?.() ?? Date.now(), type: "timeout" };
|
|
84
|
+
writeAtomicJson(requestPath, request);
|
|
85
|
+
return requestPath;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Runner side: consume a pending interrupt request. Idempotent — removes the file
|
|
90
|
+
* so each distinct request fires exactly once. Returns whether one was pending.
|
|
91
|
+
*/
|
|
92
|
+
export function consumeInterruptRequest(
|
|
93
|
+
asyncDir: string,
|
|
94
|
+
fsImpl: Pick<typeof fs, "existsSync" | "rmSync"> = fs,
|
|
95
|
+
): boolean {
|
|
96
|
+
const requestPath = interruptRequestPath(asyncDir);
|
|
97
|
+
if (!fsImpl.existsSync(requestPath)) return false;
|
|
98
|
+
try {
|
|
99
|
+
fsImpl.rmSync(requestPath, { force: true, recursive: true });
|
|
100
|
+
} catch {
|
|
101
|
+
// Already removed by a concurrent check — still counts as consumed.
|
|
102
|
+
}
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function consumeTimeoutRequest(
|
|
107
|
+
asyncDir: string,
|
|
108
|
+
fsImpl: Pick<typeof fs, "existsSync" | "rmSync"> = fs,
|
|
109
|
+
): boolean {
|
|
110
|
+
const requestPath = timeoutRequestPath(asyncDir);
|
|
111
|
+
if (!fsImpl.existsSync(requestPath)) return false;
|
|
112
|
+
try {
|
|
113
|
+
fsImpl.rmSync(requestPath, { force: true, recursive: true });
|
|
114
|
+
} catch {
|
|
115
|
+
// Already removed by a concurrent check — still counts as consumed.
|
|
116
|
+
}
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Parent side: portable interrupt = authoritative file request + best-effort OS
|
|
122
|
+
* signal. The signal is only a latency optimization on Unix; ENOSYS on Windows
|
|
123
|
+
* is swallowed because the file inbox is authoritative there. Other signal
|
|
124
|
+
* failures are surfaced because they usually mean the runner is not alive to
|
|
125
|
+
* consume the request.
|
|
126
|
+
*/
|
|
127
|
+
export function deliverInterruptRequest(input: {
|
|
128
|
+
asyncDir: string;
|
|
129
|
+
pid?: number;
|
|
130
|
+
kill?: KillFn;
|
|
131
|
+
signal?: NodeJS.Signals;
|
|
132
|
+
now?: () => number;
|
|
133
|
+
source?: string;
|
|
134
|
+
}): void {
|
|
135
|
+
const requestPath = requestAsyncInterrupt(input.asyncDir, input.source ? { source: input.source } : {}, { now: input.now });
|
|
136
|
+
if (typeof input.pid === "number" && input.pid > 0) {
|
|
137
|
+
try {
|
|
138
|
+
(input.kill ?? process.kill)(input.pid, input.signal ?? INTERRUPT_SIGNAL);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOSYS") {
|
|
141
|
+
// File inbox is authoritative when custom cross-process signals are unavailable.
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
fs.rmSync(requestPath, { force: true });
|
|
146
|
+
} catch {
|
|
147
|
+
// Best effort cleanup; the caller still gets the signal failure.
|
|
148
|
+
}
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function deliverTimeoutRequest(input: {
|
|
155
|
+
asyncDir: string;
|
|
156
|
+
pid?: number;
|
|
157
|
+
kill?: KillFn;
|
|
158
|
+
signal?: NodeJS.Signals;
|
|
159
|
+
now?: () => number;
|
|
160
|
+
source?: string;
|
|
161
|
+
}): void {
|
|
162
|
+
requestAsyncTimeout(input.asyncDir, input.source ? { source: input.source } : {}, { now: input.now });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Runner side: watch the control inbox and route interrupt requests into
|
|
167
|
+
* `onInterrupt`. Uses `fs.watch` when available plus an interval poll as a
|
|
168
|
+
* portable safety net (covers filesystems/platforms where `fs.watch` is
|
|
169
|
+
* unreliable). Fires once per distinct request. Returns a disposer.
|
|
170
|
+
*/
|
|
171
|
+
export function watchAsyncControlInbox(
|
|
172
|
+
asyncDir: string,
|
|
173
|
+
opts: {
|
|
174
|
+
onInterrupt: () => void;
|
|
175
|
+
onTimeout?: () => void;
|
|
176
|
+
pollIntervalMs?: number;
|
|
177
|
+
fs?: ControlChannelFs;
|
|
178
|
+
timers?: ControlChannelTimers;
|
|
179
|
+
},
|
|
180
|
+
): () => void {
|
|
181
|
+
const fsImpl = opts.fs ?? fs;
|
|
182
|
+
const timers = opts.timers ?? { setInterval, clearInterval };
|
|
183
|
+
const dir = controlInboxDir(asyncDir);
|
|
184
|
+
try {
|
|
185
|
+
fsImpl.mkdirSync(dir, { recursive: true });
|
|
186
|
+
} catch {
|
|
187
|
+
// Best effort — the poll/watch below tolerates a missing dir.
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let disposed = false;
|
|
191
|
+
const check = (): void => {
|
|
192
|
+
if (disposed) return;
|
|
193
|
+
try {
|
|
194
|
+
if (consumeTimeoutRequest(asyncDir, fsImpl)) opts.onTimeout?.();
|
|
195
|
+
if (consumeInterruptRequest(asyncDir, fsImpl)) opts.onInterrupt();
|
|
196
|
+
} catch {
|
|
197
|
+
// Never let inbox errors crash the runner.
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// Handle a request that may have arrived before the watcher started.
|
|
202
|
+
check();
|
|
203
|
+
|
|
204
|
+
let watcher: fs.FSWatcher | undefined;
|
|
205
|
+
try {
|
|
206
|
+
watcher = fsImpl.watch(dir, () => check());
|
|
207
|
+
watcher.on?.("error", () => {
|
|
208
|
+
// fs.watch can emit on transient FS errors; the interval poll keeps us live.
|
|
209
|
+
});
|
|
210
|
+
} catch {
|
|
211
|
+
watcher = undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const interval = timers.setInterval(check, opts.pollIntervalMs ?? POLL_INTERVAL_MS);
|
|
215
|
+
interval.unref?.();
|
|
216
|
+
|
|
217
|
+
return () => {
|
|
218
|
+
if (disposed) return;
|
|
219
|
+
disposed = true;
|
|
220
|
+
try {
|
|
221
|
+
watcher?.close();
|
|
222
|
+
} catch {
|
|
223
|
+
// ignore
|
|
224
|
+
}
|
|
225
|
+
timers.clearInterval(interval);
|
|
226
|
+
};
|
|
227
|
+
}
|
|
@@ -202,6 +202,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
|
|
|
202
202
|
const lines = [
|
|
203
203
|
`Run: ${status.runId}`,
|
|
204
204
|
`State: ${status.state}`,
|
|
205
|
+
status.error ? `Error: ${status.error}` : undefined,
|
|
205
206
|
statusActivityText ? `Activity: ${statusActivityText}` : undefined,
|
|
206
207
|
`Mode: ${status.mode}`,
|
|
207
208
|
`Progress: ${progressLabel}`,
|
|
@@ -41,6 +41,31 @@ function getErrorMessage(error: unknown): string {
|
|
|
41
41
|
return error instanceof Error ? error.message : String(error);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
function readRunnerStartupDiagnostics(asyncDir: string): string | undefined {
|
|
45
|
+
const stderrPath = path.join(asyncDir, "runner.stderr.log");
|
|
46
|
+
const maxBytes = 64 * 1024;
|
|
47
|
+
let content: string;
|
|
48
|
+
try {
|
|
49
|
+
const stat = fs.statSync(stderrPath);
|
|
50
|
+
if (stat.size <= 0) return undefined;
|
|
51
|
+
const fd = fs.openSync(stderrPath, "r");
|
|
52
|
+
try {
|
|
53
|
+
const bytesToRead = Math.min(stat.size, maxBytes);
|
|
54
|
+
const start = Math.max(0, stat.size - bytesToRead);
|
|
55
|
+
const buffer = Buffer.alloc(bytesToRead);
|
|
56
|
+
fs.readSync(fd, buffer, 0, bytesToRead, start);
|
|
57
|
+
content = buffer.toString("utf-8").trim();
|
|
58
|
+
} finally {
|
|
59
|
+
fs.closeSync(fd);
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
if (!content) return undefined;
|
|
65
|
+
const lines = content.split(/\r?\n/).slice(-30).join("\n");
|
|
66
|
+
return lines.length > 4000 ? `${lines.slice(-4000)}\n[stderr tail truncated]` : lines;
|
|
67
|
+
}
|
|
68
|
+
|
|
44
69
|
function isNotFoundError(error: unknown): boolean {
|
|
45
70
|
return typeof error === "object"
|
|
46
71
|
&& error !== null
|
|
@@ -172,7 +197,9 @@ function buildStartedStatus(asyncDir: string, startedRun: StartedRunMetadata, no
|
|
|
172
197
|
function buildFailedRepair(status: AsyncStatus, asyncDir: string, now: number, reason?: string): { status: AsyncStatus; result: object; message: string } {
|
|
173
198
|
const runId = status.runId || path.basename(asyncDir);
|
|
174
199
|
const pid = typeof status.pid === "number" ? status.pid : "unknown";
|
|
175
|
-
const
|
|
200
|
+
const baseMessage = reason ?? `Async runner process ${pid} exited or disappeared before writing a result. Marked run failed by stale-run reconciliation.`;
|
|
201
|
+
const diagnostics = readRunnerStartupDiagnostics(asyncDir);
|
|
202
|
+
const message = diagnostics ? `${baseMessage}\n\nRunner stderr tail:\n${diagnostics}` : baseMessage;
|
|
176
203
|
const steps = status.steps?.length ? status.steps : [{ agent: "subagent", status: "running" as const }];
|
|
177
204
|
const repairedSteps = steps.map((step) => step.status === "running" || step.status === "pending"
|
|
178
205
|
? {
|