pi-subagents 0.29.0 → 0.31.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 +43 -0
- package/README.md +125 -19
- package/agents/context-builder.md +3 -3
- package/agents/planner.md +1 -1
- package/agents/researcher.md +1 -1
- package/agents/scout.md +1 -1
- package/package.json +7 -7
- package/skills/pi-subagents/SKILL.md +30 -0
- package/src/agents/agent-management.ts +189 -8
- package/src/agents/agent-serializer.ts +35 -12
- package/src/agents/agents.ts +243 -24
- package/src/agents/frontmatter.ts +66 -2
- package/src/agents/proactive-skills.ts +191 -0
- package/src/agents/skills.ts +117 -20
- package/src/extension/doctor.ts +20 -0
- package/src/extension/fanout-child.ts +2 -1
- package/src/extension/index.ts +50 -5
- package/src/extension/schemas.ts +40 -79
- package/src/intercom/intercom-bridge.ts +2 -3
- package/src/runs/background/async-execution.ts +180 -67
- package/src/runs/background/async-job-tracker.ts +56 -11
- package/src/runs/background/async-resume.ts +53 -5
- package/src/runs/background/async-status.ts +4 -1
- package/src/runs/background/chain-append.ts +282 -0
- package/src/runs/background/chain-root-attachment.ts +161 -0
- package/src/runs/background/result-watcher.ts +11 -2
- package/src/runs/background/run-status.ts +1 -0
- package/src/runs/background/stale-run-reconciler.ts +9 -4
- package/src/runs/background/subagent-runner.ts +158 -11
- package/src/runs/foreground/chain-execution.ts +26 -2
- package/src/runs/foreground/execution.ts +114 -8
- package/src/runs/foreground/subagent-executor.ts +611 -87
- package/src/runs/shared/acceptance.ts +285 -34
- package/src/runs/shared/chain-outputs.ts +23 -8
- package/src/runs/shared/completion-guard.ts +1 -1
- package/src/runs/shared/dynamic-fanout.ts +5 -3
- package/src/runs/shared/mcp-direct-tool-allowlist.ts +2 -2
- package/src/runs/shared/parallel-utils.ts +13 -1
- package/src/runs/shared/pi-args.ts +12 -3
- package/src/runs/shared/single-output.ts +15 -1
- package/src/runs/shared/subagent-control.ts +8 -11
- package/src/shared/settings.ts +1 -0
- package/src/shared/types.ts +17 -2
- package/src/shared/utils.ts +19 -1
- package/src/slash/prompt-template-bridge.ts +26 -3
- package/src/slash/slash-bridge.ts +3 -1
- package/src/slash/slash-commands.ts +34 -4
- package/src/tui/render.ts +265 -13
|
@@ -26,6 +26,10 @@ interface AsyncJobTrackerOptions {
|
|
|
26
26
|
now?: () => number;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
const CONTROL_EVENT_READ_CHUNK_BYTES = 64 * 1024;
|
|
30
|
+
const MAX_CONTROL_EVENT_LINE_BYTES = 1024 * 1024;
|
|
31
|
+
const CONTROL_EVENT_SCAN_WINDOW_BYTES = 2 * 1024 * 1024;
|
|
32
|
+
|
|
29
33
|
export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: SubagentState, asyncDirRoot: string, options: AsyncJobTrackerOptions = {}): {
|
|
30
34
|
ensurePoller: () => void;
|
|
31
35
|
handleStarted: (data: unknown) => void;
|
|
@@ -68,25 +72,24 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
|
|
|
68
72
|
}
|
|
69
73
|
try {
|
|
70
74
|
const stat = fs.fstatSync(fd);
|
|
71
|
-
const
|
|
75
|
+
const savedCursor = job.controlEventCursor;
|
|
76
|
+
let cursor = stat.size < (savedCursor ?? 0) ? 0 : (savedCursor ?? 0);
|
|
77
|
+
const startedFromTail = savedCursor === undefined && stat.size > CONTROL_EVENT_SCAN_WINDOW_BYTES;
|
|
78
|
+
if (startedFromTail) cursor = stat.size - CONTROL_EVENT_SCAN_WINDOW_BYTES;
|
|
72
79
|
if (stat.size <= cursor) return;
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (lastNewline === -1) return;
|
|
77
|
-
job.controlEventCursor = cursor + lastNewline + 1;
|
|
78
|
-
for (const line of buffer.subarray(0, lastNewline).toString("utf-8").split("\n")) {
|
|
79
|
-
if (!line.trim()) continue;
|
|
80
|
+
const scanEnd = Math.min(stat.size, cursor + CONTROL_EVENT_SCAN_WINDOW_BYTES);
|
|
81
|
+
const handleLine = (line: string) => {
|
|
82
|
+
if (!line.trim()) return;
|
|
80
83
|
let parsed: unknown;
|
|
81
84
|
try {
|
|
82
85
|
parsed = JSON.parse(line);
|
|
83
86
|
} catch (error) {
|
|
84
87
|
console.error(`Ignoring malformed async control event in '${eventsPath}':`, error);
|
|
85
|
-
|
|
88
|
+
return;
|
|
86
89
|
}
|
|
87
|
-
if (!parsed || typeof parsed !== "object" || (parsed as { type?: unknown }).type !== "subagent.control")
|
|
90
|
+
if (!parsed || typeof parsed !== "object" || (parsed as { type?: unknown }).type !== "subagent.control") return;
|
|
88
91
|
const record = parsed as { event?: ControlEvent; channels?: string[]; childIntercomTarget?: string; noticeText?: string; intercom?: { to?: string; message?: string } };
|
|
89
|
-
if (!record.event || !Array.isArray(record.channels))
|
|
92
|
+
if (!record.event || !Array.isArray(record.channels)) return;
|
|
90
93
|
const payload = {
|
|
91
94
|
event: record.event,
|
|
92
95
|
source: "async" as const,
|
|
@@ -104,7 +107,48 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
|
|
|
104
107
|
message: record.intercom.message,
|
|
105
108
|
});
|
|
106
109
|
}
|
|
110
|
+
};
|
|
111
|
+
let readCursor = cursor;
|
|
112
|
+
let lastCompleteCursor = cursor;
|
|
113
|
+
let lineParts: Buffer[] = [];
|
|
114
|
+
let lineBytes = 0;
|
|
115
|
+
let skippingOversizedLine = startedFromTail;
|
|
116
|
+
const appendLineSegment = (segment: Buffer) => {
|
|
117
|
+
if (segment.length === 0 || skippingOversizedLine) return;
|
|
118
|
+
if (lineBytes + segment.length > MAX_CONTROL_EVENT_LINE_BYTES) {
|
|
119
|
+
lineParts = [];
|
|
120
|
+
lineBytes = 0;
|
|
121
|
+
skippingOversizedLine = true;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
lineParts.push(segment);
|
|
125
|
+
lineBytes += segment.length;
|
|
126
|
+
};
|
|
127
|
+
while (readCursor < scanEnd) {
|
|
128
|
+
const toRead = Math.min(CONTROL_EVENT_READ_CHUNK_BYTES, scanEnd - readCursor);
|
|
129
|
+
const buffer = Buffer.alloc(toRead);
|
|
130
|
+
const bytesRead = fs.readSync(fd, buffer, 0, toRead, readCursor);
|
|
131
|
+
if (bytesRead <= 0) break;
|
|
132
|
+
const chunk = bytesRead === buffer.length ? buffer : buffer.subarray(0, bytesRead);
|
|
133
|
+
let lineStart = 0;
|
|
134
|
+
for (let index = 0; index < chunk.length; index++) {
|
|
135
|
+
if (chunk[index] !== 0x0a) continue;
|
|
136
|
+
appendLineSegment(chunk.subarray(lineStart, index));
|
|
137
|
+
if (!skippingOversizedLine && lineBytes > 0) {
|
|
138
|
+
handleLine(Buffer.concat(lineParts, lineBytes).toString("utf-8"));
|
|
139
|
+
}
|
|
140
|
+
lineParts = [];
|
|
141
|
+
lineBytes = 0;
|
|
142
|
+
skippingOversizedLine = false;
|
|
143
|
+
lastCompleteCursor = readCursor + index + 1;
|
|
144
|
+
lineStart = index + 1;
|
|
145
|
+
}
|
|
146
|
+
appendLineSegment(chunk.subarray(lineStart));
|
|
147
|
+
readCursor += bytesRead;
|
|
148
|
+
if (skippingOversizedLine) job.controlEventCursor = readCursor;
|
|
107
149
|
}
|
|
150
|
+
if (lastCompleteCursor > cursor) job.controlEventCursor = lastCompleteCursor;
|
|
151
|
+
else if (scanEnd < stat.size || startedFromTail) job.controlEventCursor = scanEnd;
|
|
108
152
|
} catch (error) {
|
|
109
153
|
console.error(`Failed to read async control events for '${job.asyncDir}':`, error);
|
|
110
154
|
} finally {
|
|
@@ -261,6 +305,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
|
|
|
261
305
|
activeParallelGroup: Boolean(firstGroupCount && firstGroupCount > 0),
|
|
262
306
|
startedAt: now,
|
|
263
307
|
updatedAt: now,
|
|
308
|
+
controlEventCursor: 0,
|
|
264
309
|
});
|
|
265
310
|
ensurePoller();
|
|
266
311
|
if (state.lastUiContext) {
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { ASYNC_DIR, RESULTS_DIR, type AsyncStatus } from "../../shared/types.ts";
|
|
3
|
+
import { ASYNC_DIR, RESULTS_DIR, type AsyncStatus, type SubagentState } from "../../shared/types.ts";
|
|
4
4
|
import { resolveSubagentIntercomTarget } from "../../intercom/intercom-bridge.ts";
|
|
5
5
|
import { reconcileAsyncRun } from "./stale-run-reconciler.ts";
|
|
6
6
|
|
|
7
|
+
export const ASYNC_RESUME_INTERRUPT_SIGNAL: NodeJS.Signals = process.platform === "win32" ? "SIGBREAK" : "SIGUSR2";
|
|
8
|
+
|
|
7
9
|
export interface AsyncResumeParams {
|
|
8
10
|
id?: string;
|
|
9
11
|
runId?: string;
|
|
@@ -18,6 +20,10 @@ export interface AsyncResumeDeps {
|
|
|
18
20
|
now?: () => number;
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
export interface AsyncResumeOptions {
|
|
24
|
+
requireSessionFile?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
21
27
|
export type AsyncResumeTarget = {
|
|
22
28
|
kind: "live" | "revive";
|
|
23
29
|
runId: string;
|
|
@@ -30,6 +36,47 @@ export type AsyncResumeTarget = {
|
|
|
30
36
|
sessionFile?: string;
|
|
31
37
|
};
|
|
32
38
|
|
|
39
|
+
type KillFn = (pid: number, signal?: NodeJS.Signals | 0) => boolean;
|
|
40
|
+
|
|
41
|
+
function readAsyncStatus(asyncDir: string): AsyncStatus | null {
|
|
42
|
+
const statusPath = path.join(asyncDir, "status.json");
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatus;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
const code = error && typeof error === "object" && "code" in error ? (error as NodeJS.ErrnoException).code : undefined;
|
|
47
|
+
if (code === "ENOENT") return null;
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function interruptLiveAsyncResumeTarget(input: {
|
|
53
|
+
target: AsyncResumeTarget & { kind: "live" };
|
|
54
|
+
state?: Pick<SubagentState, "asyncJobs">;
|
|
55
|
+
kill?: KillFn;
|
|
56
|
+
now?: () => number;
|
|
57
|
+
}): { ok: true; asyncId: string } | { ok: false; message: string } {
|
|
58
|
+
const asyncId = input.target.runId;
|
|
59
|
+
if (!input.target.asyncDir) {
|
|
60
|
+
return { ok: false, message: `Async run ${asyncId} is live but does not have an async directory to interrupt.` };
|
|
61
|
+
}
|
|
62
|
+
const status = readAsyncStatus(input.target.asyncDir);
|
|
63
|
+
if (!status || status.state !== "running" || typeof status.pid !== "number") {
|
|
64
|
+
return { ok: false, message: `Async run ${asyncId} is live but no interrupt-capable runner pid was found.` };
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
(input.kill ?? process.kill)(status.pid, ASYNC_RESUME_INTERRUPT_SIGNAL);
|
|
68
|
+
const tracked = input.state?.asyncJobs.get(asyncId);
|
|
69
|
+
if (tracked) {
|
|
70
|
+
tracked.activityState = undefined;
|
|
71
|
+
tracked.updatedAt = input.now?.() ?? Date.now();
|
|
72
|
+
}
|
|
73
|
+
return { ok: true, asyncId };
|
|
74
|
+
} catch (error) {
|
|
75
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
76
|
+
return { ok: false, message: `Failed to interrupt async run ${asyncId}: ${message}` };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
33
80
|
interface AsyncResultFile {
|
|
34
81
|
id?: string;
|
|
35
82
|
runId?: string;
|
|
@@ -236,9 +283,10 @@ function validateResumeSessionFile(runId: string, sessionFile: string): string {
|
|
|
236
283
|
return resolved;
|
|
237
284
|
}
|
|
238
285
|
|
|
239
|
-
export function resolveAsyncResumeTarget(params: AsyncResumeParams, deps: AsyncResumeDeps = {}): AsyncResumeTarget {
|
|
286
|
+
export function resolveAsyncResumeTarget(params: AsyncResumeParams, deps: AsyncResumeDeps = {}, options: AsyncResumeOptions = {}): AsyncResumeTarget {
|
|
240
287
|
const asyncDirRoot = deps.asyncDirRoot ?? ASYNC_DIR;
|
|
241
288
|
const resultsDir = deps.resultsDir ?? RESULTS_DIR;
|
|
289
|
+
const requireSessionFile = options.requireSessionFile ?? true;
|
|
242
290
|
const location = resolveAsyncRunLocation(params, asyncDirRoot, resultsDir);
|
|
243
291
|
if (!location.asyncDir && !location.resultPath) {
|
|
244
292
|
throw new Error("Async run not found. Provide id or dir.");
|
|
@@ -313,8 +361,8 @@ export function resolveAsyncResumeTarget(params: AsyncResumeParams, deps: AsyncR
|
|
|
313
361
|
const sessionFile = statusSteps[index]?.sessionFile
|
|
314
362
|
?? resultSteps[index]?.sessionFile
|
|
315
363
|
?? (stepCount === 1 ? status?.sessionFile ?? result?.sessionFile : undefined);
|
|
316
|
-
if (!sessionFile) throw new Error(`Async run '${runId}' child ${index} does not have a persisted session file to resume from.`);
|
|
317
|
-
const resolvedSessionFile = validateResumeSessionFile(runId, sessionFile);
|
|
364
|
+
if (!sessionFile && requireSessionFile) throw new Error(`Async run '${runId}' child ${index} does not have a persisted session file to resume from.`);
|
|
365
|
+
const resolvedSessionFile = sessionFile ? validateResumeSessionFile(runId, sessionFile) : undefined;
|
|
318
366
|
|
|
319
367
|
return {
|
|
320
368
|
kind: "revive",
|
|
@@ -325,7 +373,7 @@ export function resolveAsyncResumeTarget(params: AsyncResumeParams, deps: AsyncR
|
|
|
325
373
|
index,
|
|
326
374
|
intercomTarget: resolveSubagentIntercomTarget(runId, agent, index),
|
|
327
375
|
cwd: status?.cwd ?? result?.cwd,
|
|
328
|
-
sessionFile: resolvedSessionFile,
|
|
376
|
+
...(resolvedSessionFile ? { sessionFile: resolvedSessionFile } : {}),
|
|
329
377
|
};
|
|
330
378
|
}
|
|
331
379
|
|
|
@@ -56,6 +56,7 @@ export interface AsyncRunSummary {
|
|
|
56
56
|
endedAt?: number;
|
|
57
57
|
currentStep?: number;
|
|
58
58
|
chainStepCount?: number;
|
|
59
|
+
pendingAppends?: number;
|
|
59
60
|
parallelGroups?: AsyncParallelGroupStatus[];
|
|
60
61
|
steps: AsyncRunStepSummary[];
|
|
61
62
|
sessionDir?: string;
|
|
@@ -188,6 +189,7 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
|
|
|
188
189
|
endedAt: status.endedAt,
|
|
189
190
|
currentStep: status.currentStep,
|
|
190
191
|
...(status.chainStepCount !== undefined ? { chainStepCount: status.chainStepCount } : {}),
|
|
192
|
+
...(status.pendingAppends !== undefined ? { pendingAppends: status.pendingAppends } : {}),
|
|
191
193
|
...(parallelGroups.length ? { parallelGroups } : {}),
|
|
192
194
|
steps: summarizedSteps,
|
|
193
195
|
...(nestedChildren.length ? { nestedChildren } : {}),
|
|
@@ -309,7 +311,8 @@ function formatRunHeader(run: AsyncRunSummary): string {
|
|
|
309
311
|
const stepLabel = formatAsyncRunProgressLabel(run);
|
|
310
312
|
const cwd = run.cwd ? shortenPath(run.cwd) : shortenPath(run.asyncDir);
|
|
311
313
|
const activity = formatActivityFacts(run);
|
|
312
|
-
|
|
314
|
+
const pending = run.pendingAppends ? ` | ${run.pendingAppends} pending append${run.pendingAppends === 1 ? "" : "s"}` : "";
|
|
315
|
+
return `${run.id} | ${run.state}${activity ? ` | ${activity}` : ""} | ${run.mode} | ${stepLabel}${pending} | ${cwd}`;
|
|
313
316
|
}
|
|
314
317
|
|
|
315
318
|
export function formatAsyncRunList(runs: AsyncRunSummary[], heading = "Active async runs"): string {
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { writeAtomicJson } from "../../shared/atomic-json.ts";
|
|
5
|
+
import { appendJsonl } from "../../shared/artifacts.ts";
|
|
6
|
+
import type { AsyncParallelGroupStatus, AsyncStatus, WorkflowGraphNode, WorkflowGraphSnapshot } from "../../shared/types.ts";
|
|
7
|
+
import { readStatus } from "../../shared/utils.ts";
|
|
8
|
+
import type { DynamicRunnerGroup, ParallelStepGroup, RunnerStep, RunnerSubagentStep } from "../shared/parallel-utils.ts";
|
|
9
|
+
import { isDynamicRunnerGroup, isParallelGroup } from "../shared/parallel-utils.ts";
|
|
10
|
+
|
|
11
|
+
const APPEND_REQUESTS_DIR = "append-requests";
|
|
12
|
+
|
|
13
|
+
export interface ChainAppendRequest {
|
|
14
|
+
id: string;
|
|
15
|
+
createdAt: number;
|
|
16
|
+
steps: RunnerStep[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ChainAppendResult {
|
|
20
|
+
request: ChainAppendRequest;
|
|
21
|
+
pendingCount: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type StatusStep = NonNullable<AsyncStatus["steps"]>[number];
|
|
25
|
+
|
|
26
|
+
function appendDir(asyncDir: string): string {
|
|
27
|
+
return path.join(asyncDir, APPEND_REQUESTS_DIR);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function appendRequestPath(asyncDir: string, request: ChainAppendRequest): string {
|
|
31
|
+
return path.join(appendDir(asyncDir), `${request.createdAt}-${request.id}.json`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function listAppendRequestFiles(asyncDir: string): string[] {
|
|
35
|
+
const dir = appendDir(asyncDir);
|
|
36
|
+
try {
|
|
37
|
+
return fs.readdirSync(dir)
|
|
38
|
+
.filter((entry) => entry.endsWith(".json"))
|
|
39
|
+
.map((entry) => path.join(dir, entry))
|
|
40
|
+
.sort();
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function countPendingChainAppendRequests(asyncDir: string): number {
|
|
48
|
+
return listAppendRequestFiles(asyncDir).length;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function runnerStepOutputNames(steps: RunnerStep[]): string[] {
|
|
52
|
+
const names: string[] = [];
|
|
53
|
+
for (const step of steps) {
|
|
54
|
+
if (isParallelGroup(step)) {
|
|
55
|
+
names.push(...step.parallel.map((task) => task.outputName).filter((name): name is string => Boolean(name)));
|
|
56
|
+
} else if (isDynamicRunnerGroup(step)) {
|
|
57
|
+
if (step.collect.as) names.push(step.collect.as);
|
|
58
|
+
} else if (step.outputName) {
|
|
59
|
+
names.push(step.outputName);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return names;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function enqueueChainAppendRequest(input: {
|
|
66
|
+
asyncDir: string;
|
|
67
|
+
runId: string;
|
|
68
|
+
steps: RunnerStep[];
|
|
69
|
+
now?: number;
|
|
70
|
+
}): ChainAppendResult {
|
|
71
|
+
const status = readStatus(input.asyncDir);
|
|
72
|
+
if (!status) throw new Error(`No async run status found for '${input.runId}'.`);
|
|
73
|
+
if (status.runId !== input.runId) throw new Error(`Async run id mismatch: expected '${input.runId}', found '${status.runId}'.`);
|
|
74
|
+
if (status.mode !== "chain") throw new Error(`Run '${input.runId}' is ${status.mode}; only active chain runs accept appended steps.`);
|
|
75
|
+
if (status.state !== "running") throw new Error(`Run '${input.runId}' is ${status.state}; only running chain runs accept appended steps.`);
|
|
76
|
+
const stillInProgress = (status.steps ?? []).some((step) => step.status === "running" || step.status === "pending") || (status.pendingAppends ?? 0) > 0;
|
|
77
|
+
if (!stillInProgress) throw new Error(`Run '${input.runId}' has no running or pending chain steps left; append-step must target an in-progress chain.`);
|
|
78
|
+
if (input.steps.length === 0) throw new Error("append-step requires one chain step.");
|
|
79
|
+
|
|
80
|
+
const request: ChainAppendRequest = {
|
|
81
|
+
id: randomUUID(),
|
|
82
|
+
createdAt: input.now ?? Date.now(),
|
|
83
|
+
steps: input.steps,
|
|
84
|
+
};
|
|
85
|
+
fs.mkdirSync(appendDir(input.asyncDir), { recursive: true });
|
|
86
|
+
writeAtomicJson(appendRequestPath(input.asyncDir, request), request);
|
|
87
|
+
const pendingCount = countPendingChainAppendRequests(input.asyncDir);
|
|
88
|
+
const statusPath = path.join(input.asyncDir, "status.json");
|
|
89
|
+
const updatedStatus = { ...status, pendingAppends: pendingCount, lastUpdate: request.createdAt };
|
|
90
|
+
writeAtomicJson(statusPath, updatedStatus);
|
|
91
|
+
appendJsonl(path.join(input.asyncDir, "events.jsonl"), JSON.stringify({
|
|
92
|
+
type: "subagent.chain.append.requested",
|
|
93
|
+
ts: request.createdAt,
|
|
94
|
+
runId: input.runId,
|
|
95
|
+
requestId: request.id,
|
|
96
|
+
stepCount: input.steps.length,
|
|
97
|
+
pendingAppends: pendingCount,
|
|
98
|
+
}));
|
|
99
|
+
return { request, pendingCount };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function readAppendRequest(filePath: string): ChainAppendRequest | undefined {
|
|
103
|
+
const raw = JSON.parse(fs.readFileSync(filePath, "utf-8")) as Partial<ChainAppendRequest>;
|
|
104
|
+
if (!raw.id || typeof raw.id !== "string") return undefined;
|
|
105
|
+
if (!Number.isFinite(raw.createdAt)) return undefined;
|
|
106
|
+
if (!Array.isArray(raw.steps) || raw.steps.length === 0) return undefined;
|
|
107
|
+
return { id: raw.id, createdAt: raw.createdAt, steps: raw.steps as RunnerStep[] };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function readPendingChainAppendRequests(asyncDir: string): ChainAppendRequest[] {
|
|
111
|
+
return listAppendRequestFiles(asyncDir)
|
|
112
|
+
.map((filePath) => readAppendRequest(filePath))
|
|
113
|
+
.filter((request): request is ChainAppendRequest => Boolean(request))
|
|
114
|
+
.sort((left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function consumeChainAppendRequests(asyncDir: string): ChainAppendRequest[] {
|
|
118
|
+
const requests: ChainAppendRequest[] = [];
|
|
119
|
+
for (const filePath of listAppendRequestFiles(asyncDir)) {
|
|
120
|
+
const request = readAppendRequest(filePath);
|
|
121
|
+
try {
|
|
122
|
+
fs.unlinkSync(filePath);
|
|
123
|
+
} catch {
|
|
124
|
+
// The runner should not execute a consumed request twice.
|
|
125
|
+
}
|
|
126
|
+
if (request) requests.push(request);
|
|
127
|
+
}
|
|
128
|
+
return requests.sort((left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function statusStepForTask(task: RunnerSubagentStep): StatusStep {
|
|
132
|
+
return {
|
|
133
|
+
agent: task.agent,
|
|
134
|
+
phase: task.phase,
|
|
135
|
+
label: task.label,
|
|
136
|
+
outputName: task.outputName,
|
|
137
|
+
structured: task.structured,
|
|
138
|
+
status: "pending",
|
|
139
|
+
...(task.sessionFile ? { sessionFile: task.sessionFile } : {}),
|
|
140
|
+
skills: task.skills,
|
|
141
|
+
model: task.model,
|
|
142
|
+
thinking: task.thinking,
|
|
143
|
+
attemptedModels: task.modelCandidates && task.modelCandidates.length > 0 ? task.modelCandidates : task.model ? [task.model] : undefined,
|
|
144
|
+
recentTools: [],
|
|
145
|
+
recentOutput: [],
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function statusStepsForRunnerStep(step: RunnerStep): StatusStep[] {
|
|
150
|
+
if (isParallelGroup(step)) return step.parallel.map(statusStepForTask);
|
|
151
|
+
if (isDynamicRunnerGroup(step)) {
|
|
152
|
+
return [{
|
|
153
|
+
agent: `expand:${step.parallel.agent}`,
|
|
154
|
+
phase: step.phase ?? step.parallel.phase,
|
|
155
|
+
label: step.label ?? step.parallel.label ?? `Dynamic fanout (${step.collect.as})`,
|
|
156
|
+
outputName: step.collect.as,
|
|
157
|
+
structured: Boolean(step.collect.outputSchema),
|
|
158
|
+
status: "pending",
|
|
159
|
+
recentTools: [],
|
|
160
|
+
recentOutput: [],
|
|
161
|
+
}];
|
|
162
|
+
}
|
|
163
|
+
return [statusStepForTask(step)];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function pushPhase(graph: WorkflowGraphSnapshot, phase: string | undefined, nodeId: string): void {
|
|
167
|
+
if (!phase) return;
|
|
168
|
+
let group = graph.phases.find((candidate) => candidate.title === phase);
|
|
169
|
+
if (!group) {
|
|
170
|
+
group = { title: phase, nodeIds: [] };
|
|
171
|
+
graph.phases.push(group);
|
|
172
|
+
}
|
|
173
|
+
group.nodeIds.push(nodeId);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function graphNodeForSequential(step: RunnerSubagentStep, stepIndex: number, flatIndex: number): WorkflowGraphNode {
|
|
177
|
+
return {
|
|
178
|
+
id: `step-${stepIndex}`,
|
|
179
|
+
kind: "step",
|
|
180
|
+
agent: step.agent,
|
|
181
|
+
phase: step.phase,
|
|
182
|
+
label: step.label?.trim() || step.agent || `Step ${stepIndex + 1}`,
|
|
183
|
+
status: "pending",
|
|
184
|
+
flatIndex,
|
|
185
|
+
stepIndex,
|
|
186
|
+
outputName: step.outputName,
|
|
187
|
+
structured: step.structured,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function graphNodeForParallel(step: ParallelStepGroup, stepIndex: number, flatIndex: number, graph: WorkflowGraphSnapshot): WorkflowGraphNode {
|
|
192
|
+
const children = step.parallel.map((task, taskIndex) => {
|
|
193
|
+
const childId = `step-${stepIndex}-agent-${taskIndex}`;
|
|
194
|
+
pushPhase(graph, task.phase, childId);
|
|
195
|
+
return {
|
|
196
|
+
id: childId,
|
|
197
|
+
kind: "agent" as const,
|
|
198
|
+
agent: task.agent,
|
|
199
|
+
phase: task.phase,
|
|
200
|
+
label: task.label?.trim() || task.agent || `Agent ${taskIndex + 1}`,
|
|
201
|
+
status: "pending" as const,
|
|
202
|
+
flatIndex: flatIndex + taskIndex,
|
|
203
|
+
stepIndex,
|
|
204
|
+
outputName: task.outputName,
|
|
205
|
+
structured: task.structured,
|
|
206
|
+
};
|
|
207
|
+
});
|
|
208
|
+
return {
|
|
209
|
+
id: `step-${stepIndex}`,
|
|
210
|
+
kind: "parallel-group",
|
|
211
|
+
label: step.parallel.length === 1 ? "Parallel task" : `Parallel group (${step.parallel.length})`,
|
|
212
|
+
status: "pending",
|
|
213
|
+
stepIndex,
|
|
214
|
+
children,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function graphNodeForDynamic(step: DynamicRunnerGroup, stepIndex: number): WorkflowGraphNode {
|
|
219
|
+
return {
|
|
220
|
+
id: `step-${stepIndex}`,
|
|
221
|
+
kind: "dynamic-parallel-group",
|
|
222
|
+
label: step.label?.trim() || step.parallel.label?.trim() || `Dynamic fanout (${step.collect.as})`,
|
|
223
|
+
status: "pending",
|
|
224
|
+
stepIndex,
|
|
225
|
+
outputName: step.collect.as,
|
|
226
|
+
structured: Boolean(step.collect.outputSchema),
|
|
227
|
+
dynamic: {
|
|
228
|
+
sourceOutput: step.expand.from.output,
|
|
229
|
+
sourcePath: step.expand.from.path,
|
|
230
|
+
itemName: step.expand.item ?? "item",
|
|
231
|
+
maxItems: step.expand.maxItems,
|
|
232
|
+
collectAs: step.collect.as,
|
|
233
|
+
},
|
|
234
|
+
children: [],
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function appendWorkflowNode(graph: WorkflowGraphSnapshot | undefined, step: RunnerStep, stepIndex: number, flatIndex: number): void {
|
|
239
|
+
if (!graph) return;
|
|
240
|
+
if (isParallelGroup(step)) {
|
|
241
|
+
graph.nodes.push(graphNodeForParallel(step, stepIndex, flatIndex, graph));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (isDynamicRunnerGroup(step)) {
|
|
245
|
+
graph.nodes.push(graphNodeForDynamic(step, stepIndex));
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const node = graphNodeForSequential(step, stepIndex, flatIndex);
|
|
249
|
+
graph.nodes.push(node);
|
|
250
|
+
pushPhase(graph, step.phase, node.id);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function appendRunnerStepsToStatus(input: {
|
|
254
|
+
status: AsyncStatus;
|
|
255
|
+
steps: RunnerStep[];
|
|
256
|
+
now?: number;
|
|
257
|
+
pendingAppends?: number;
|
|
258
|
+
}): { addedChainSteps: number; addedFlatSteps: number } {
|
|
259
|
+
let addedChainSteps = 0;
|
|
260
|
+
let addedFlatSteps = 0;
|
|
261
|
+
for (const step of input.steps) {
|
|
262
|
+
const stepIndex = input.status.chainStepCount ?? input.status.steps?.length ?? 0;
|
|
263
|
+
const flatIndex = input.status.steps?.length ?? 0;
|
|
264
|
+
const statusSteps = statusStepsForRunnerStep(step);
|
|
265
|
+
input.status.steps ??= [];
|
|
266
|
+
input.status.steps.push(...statusSteps);
|
|
267
|
+
if (isParallelGroup(step)) {
|
|
268
|
+
input.status.parallelGroups ??= [];
|
|
269
|
+
input.status.parallelGroups.push({ start: flatIndex, count: step.parallel.length, stepIndex });
|
|
270
|
+
} else if (isDynamicRunnerGroup(step)) {
|
|
271
|
+
input.status.parallelGroups ??= [];
|
|
272
|
+
input.status.parallelGroups.push({ start: flatIndex, count: 1, stepIndex });
|
|
273
|
+
}
|
|
274
|
+
appendWorkflowNode(input.status.workflowGraph, step, stepIndex, flatIndex);
|
|
275
|
+
input.status.chainStepCount = stepIndex + 1;
|
|
276
|
+
addedChainSteps++;
|
|
277
|
+
addedFlatSteps += statusSteps.length;
|
|
278
|
+
}
|
|
279
|
+
input.status.pendingAppends = input.pendingAppends ?? 0;
|
|
280
|
+
input.status.lastUpdate = input.now ?? Date.now();
|
|
281
|
+
return { addedChainSteps, addedFlatSteps };
|
|
282
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { AcceptanceLedger, AsyncStatus, ModelAttempt } from "../../shared/types.ts";
|
|
4
|
+
import { readStatus } from "../../shared/utils.ts";
|
|
5
|
+
|
|
6
|
+
export interface ImportedAsyncRoot {
|
|
7
|
+
runId: string;
|
|
8
|
+
asyncDir: string;
|
|
9
|
+
resultPath: string;
|
|
10
|
+
index: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ImportedAsyncRootResult {
|
|
14
|
+
agent: string;
|
|
15
|
+
output: string;
|
|
16
|
+
success: boolean;
|
|
17
|
+
exitCode: number;
|
|
18
|
+
error?: string;
|
|
19
|
+
sessionFile?: string;
|
|
20
|
+
intercomTarget?: string;
|
|
21
|
+
model?: string;
|
|
22
|
+
attemptedModels?: string[];
|
|
23
|
+
modelAttempts?: ModelAttempt[];
|
|
24
|
+
structuredOutput?: unknown;
|
|
25
|
+
structuredOutputPath?: string;
|
|
26
|
+
structuredOutputSchemaPath?: string;
|
|
27
|
+
acceptance?: AcceptanceLedger;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface AsyncResultFile {
|
|
31
|
+
state?: string;
|
|
32
|
+
success?: boolean;
|
|
33
|
+
summary?: string;
|
|
34
|
+
results?: Array<{
|
|
35
|
+
agent?: string;
|
|
36
|
+
output?: string;
|
|
37
|
+
error?: string;
|
|
38
|
+
success?: boolean;
|
|
39
|
+
sessionFile?: string;
|
|
40
|
+
intercomTarget?: string;
|
|
41
|
+
model?: string;
|
|
42
|
+
attemptedModels?: string[];
|
|
43
|
+
modelAttempts?: ModelAttempt[];
|
|
44
|
+
structuredOutput?: unknown;
|
|
45
|
+
structuredOutputPath?: string;
|
|
46
|
+
structuredOutputSchemaPath?: string;
|
|
47
|
+
acceptance?: AcceptanceLedger;
|
|
48
|
+
}>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const TERMINAL_STATES = new Set(["complete", "failed", "paused"]);
|
|
52
|
+
const TERMINAL_STEP_STATUSES = new Set(["complete", "completed", "failed", "paused"]);
|
|
53
|
+
|
|
54
|
+
function readResultFile(resultPath: string): AsyncResultFile | undefined {
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultFile;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (typeof error === "object" && error !== null && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function selectedStatusStep(status: AsyncStatus | null, index: number): NonNullable<AsyncStatus["steps"]>[number] | undefined {
|
|
66
|
+
return status?.steps?.[index];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isTerminalStatus(status: AsyncStatus | null, index: number): boolean {
|
|
70
|
+
if (!status) return false;
|
|
71
|
+
const step = selectedStatusStep(status, index);
|
|
72
|
+
if (step && TERMINAL_STEP_STATUSES.has(step.status)) return true;
|
|
73
|
+
return TERMINAL_STATES.has(status.state);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function resultState(result: AsyncResultFile | undefined, child: NonNullable<AsyncResultFile["results"]>[number] | undefined): "complete" | "failed" | "paused" | undefined {
|
|
77
|
+
if (!result) return undefined;
|
|
78
|
+
if (child?.success === true) return "complete";
|
|
79
|
+
if (child?.success === false) return result.state === "paused" ? "paused" : "failed";
|
|
80
|
+
if (result.state === "complete" || result.state === "failed" || result.state === "paused") return result.state;
|
|
81
|
+
if (result.success === true) return "complete";
|
|
82
|
+
if (result.success === false) return "failed";
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function outputFromTerminalStatus(root: ImportedAsyncRoot, status: AsyncStatus, step: NonNullable<AsyncStatus["steps"]>[number] | undefined): ImportedAsyncRootResult {
|
|
87
|
+
const agent = step?.agent ?? status.steps?.[root.index]?.agent ?? "subagent";
|
|
88
|
+
const message = step?.error ?? status.error ?? `Attached async root ${root.runId} ended without a result file at ${root.resultPath}.`;
|
|
89
|
+
return {
|
|
90
|
+
agent,
|
|
91
|
+
output: message,
|
|
92
|
+
success: false,
|
|
93
|
+
exitCode: 1,
|
|
94
|
+
error: message,
|
|
95
|
+
...(step?.sessionFile ?? status.sessionFile ? { sessionFile: step?.sessionFile ?? status.sessionFile } : {}),
|
|
96
|
+
...(step?.model ? { model: step.model } : {}),
|
|
97
|
+
...(step?.attemptedModels ? { attemptedModels: step.attemptedModels } : {}),
|
|
98
|
+
...(step?.modelAttempts ? { modelAttempts: step.modelAttempts } : {}),
|
|
99
|
+
...(step?.structuredOutput !== undefined ? { structuredOutput: step.structuredOutput } : {}),
|
|
100
|
+
...(step?.structuredOutputPath ? { structuredOutputPath: step.structuredOutputPath } : {}),
|
|
101
|
+
...(step?.structuredOutputSchemaPath ? { structuredOutputSchemaPath: step.structuredOutputSchemaPath } : {}),
|
|
102
|
+
...(step?.acceptance ? { acceptance: step.acceptance } : {}),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function buildImportedResult(root: ImportedAsyncRoot, status: AsyncStatus | null, result: AsyncResultFile): ImportedAsyncRootResult {
|
|
107
|
+
const child = result.results?.[root.index];
|
|
108
|
+
const step = selectedStatusStep(status, root.index);
|
|
109
|
+
const state = resultState(result, child);
|
|
110
|
+
const agent = child?.agent ?? step?.agent ?? status?.steps?.[root.index]?.agent ?? "subagent";
|
|
111
|
+
const output = child?.output ?? result.summary ?? "";
|
|
112
|
+
const success = state === "complete";
|
|
113
|
+
const error = child?.error ?? (success ? undefined : result.summary ?? status?.error ?? `Attached async root ${root.runId} did not complete successfully.`);
|
|
114
|
+
return {
|
|
115
|
+
agent,
|
|
116
|
+
output: success ? output : (output || error || ""),
|
|
117
|
+
success,
|
|
118
|
+
exitCode: success ? 0 : 1,
|
|
119
|
+
...(error ? { error } : {}),
|
|
120
|
+
...(child?.sessionFile ?? step?.sessionFile ?? status?.sessionFile ? { sessionFile: child?.sessionFile ?? step?.sessionFile ?? status?.sessionFile } : {}),
|
|
121
|
+
...(child?.intercomTarget ? { intercomTarget: child.intercomTarget } : {}),
|
|
122
|
+
...(child?.model ?? step?.model ? { model: child?.model ?? step?.model } : {}),
|
|
123
|
+
...(child?.attemptedModels ?? step?.attemptedModels ? { attemptedModels: child?.attemptedModels ?? step?.attemptedModels } : {}),
|
|
124
|
+
...(child?.modelAttempts ?? step?.modelAttempts ? { modelAttempts: child?.modelAttempts ?? step?.modelAttempts } : {}),
|
|
125
|
+
...(child?.structuredOutput !== undefined ? { structuredOutput: child.structuredOutput } : step?.structuredOutput !== undefined ? { structuredOutput: step.structuredOutput } : {}),
|
|
126
|
+
...(child?.structuredOutputPath ?? step?.structuredOutputPath ? { structuredOutputPath: child?.structuredOutputPath ?? step?.structuredOutputPath } : {}),
|
|
127
|
+
...(child?.structuredOutputSchemaPath ?? step?.structuredOutputSchemaPath ? { structuredOutputSchemaPath: child?.structuredOutputSchemaPath ?? step?.structuredOutputSchemaPath } : {}),
|
|
128
|
+
...(child?.acceptance ?? step?.acceptance ? { acceptance: child?.acceptance ?? step?.acceptance } : {}),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function waitForImportedAsyncRoot(
|
|
133
|
+
root: ImportedAsyncRoot,
|
|
134
|
+
options: { pollIntervalMs?: number; terminalResultGraceMs?: number; now?: () => number } = {},
|
|
135
|
+
): Promise<ImportedAsyncRootResult> {
|
|
136
|
+
const pollIntervalMs = options.pollIntervalMs ?? 500;
|
|
137
|
+
const terminalResultGraceMs = options.terminalResultGraceMs ?? 1_000;
|
|
138
|
+
const now = options.now ?? Date.now;
|
|
139
|
+
let terminalSince: number | undefined;
|
|
140
|
+
for (;;) {
|
|
141
|
+
const status = readStatus(root.asyncDir);
|
|
142
|
+
const result = readResultFile(root.resultPath);
|
|
143
|
+
if (result) return buildImportedResult(root, status, result);
|
|
144
|
+
if (isTerminalStatus(status, root.index)) {
|
|
145
|
+
terminalSince ??= now();
|
|
146
|
+
if (now() - terminalSince >= terminalResultGraceMs) {
|
|
147
|
+
return outputFromTerminalStatus(root, status!, selectedStatusStep(status, root.index));
|
|
148
|
+
}
|
|
149
|
+
} else {
|
|
150
|
+
terminalSince = undefined;
|
|
151
|
+
}
|
|
152
|
+
if (!status && !fs.existsSync(root.asyncDir)) {
|
|
153
|
+
throw new Error(`Attached async root '${root.runId}' directory does not exist: ${root.asyncDir}`);
|
|
154
|
+
}
|
|
155
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function resolveAsyncRootResultPath(resultsDir: string, runId: string): string {
|
|
160
|
+
return path.join(resultsDir, `${runId}.json`);
|
|
161
|
+
}
|