pi-subagents 0.30.0 → 0.31.1
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 +38 -0
- package/README.md +189 -18
- 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 +5 -0
- package/src/agents/agent-management.ts +170 -6
- package/src/agents/agent-serializer.ts +31 -13
- package/src/agents/agents.ts +207 -23
- package/src/agents/frontmatter.ts +66 -2
- package/src/agents/skills.ts +117 -20
- package/src/extension/doctor.ts +20 -0
- package/src/extension/fanout-child.ts +1 -0
- package/src/extension/index.ts +58 -4
- package/src/extension/schemas.ts +10 -76
- package/src/intercom/intercom-bridge.ts +27 -4
- package/src/profiles/profiles.ts +637 -0
- package/src/runs/background/async-execution.ts +14 -4
- package/src/runs/background/async-job-tracker.ts +56 -11
- package/src/runs/background/async-resume.ts +11 -13
- package/src/runs/background/control-channel.ts +177 -0
- package/src/runs/background/result-watcher.ts +11 -2
- package/src/runs/background/stale-run-reconciler.ts +9 -4
- package/src/runs/background/subagent-runner.ts +86 -3
- package/src/runs/foreground/chain-execution.ts +26 -2
- package/src/runs/foreground/execution.ts +113 -8
- package/src/runs/foreground/subagent-executor.ts +356 -86
- package/src/runs/shared/acceptance.ts +285 -34
- package/src/runs/shared/completion-guard.ts +1 -1
- package/src/runs/shared/dynamic-fanout.ts +4 -2
- package/src/runs/shared/mcp-direct-tool-allowlist.ts +2 -2
- package/src/runs/shared/parallel-utils.ts +6 -1
- package/src/runs/shared/pi-args.ts +9 -1
- package/src/runs/shared/single-output.ts +15 -1
- package/src/runs/shared/subagent-prompt-runtime.ts +1 -0
- package/src/shared/settings.ts +1 -0
- package/src/shared/types.ts +9 -2
- package/src/shared/utils.ts +19 -1
- package/src/slash/prompt-template-bridge.ts +26 -3
- package/src/slash/slash-commands.ts +642 -43
- package/src/tui/render.ts +265 -13
|
@@ -11,7 +11,7 @@ import { createRequire } from "node:module";
|
|
|
11
11
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import type { AgentConfig } from "../../agents/agents.ts";
|
|
13
13
|
import { applyThinkingSuffix } from "../shared/pi-args.ts";
|
|
14
|
-
import { injectSingleOutputInstruction, normalizeSingleOutputOverride, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
|
|
14
|
+
import { injectOutputPathSystemPrompt, injectSingleOutputInstruction, normalizeSingleOutputOverride, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
|
|
15
15
|
import { buildChainInstructions, isDynamicParallelStep, isParallelStep, resolveStepBehavior, suppressProgressForReadOnlyTask, writeInitialProgressFile, type ChainStep, type ResolvedStepBehavior, type SequentialStep, type StepOverrides } from "../../shared/settings.ts";
|
|
16
16
|
import type { RunnerStep } from "../shared/parallel-utils.ts";
|
|
17
17
|
import { resolvePiPackageRoot } from "../shared/pi-spawn.ts";
|
|
@@ -95,6 +95,8 @@ interface AsyncExecutionContext {
|
|
|
95
95
|
pi: ExtensionAPI;
|
|
96
96
|
cwd: string;
|
|
97
97
|
currentSessionId: string;
|
|
98
|
+
/** Parent session id used by permission-system ask forwarding. */
|
|
99
|
+
parentSessionId?: string;
|
|
98
100
|
currentModelProvider?: string;
|
|
99
101
|
currentModel?: ParentModel;
|
|
100
102
|
}
|
|
@@ -115,6 +117,7 @@ interface AsyncChainParams {
|
|
|
115
117
|
sessionRoot?: string;
|
|
116
118
|
chainSkills?: string[];
|
|
117
119
|
sessionFilesByFlatIndex?: (string | undefined)[];
|
|
120
|
+
progressDir?: string;
|
|
118
121
|
dynamicFanoutMaxItems?: number;
|
|
119
122
|
maxSubagentDepth: number;
|
|
120
123
|
worktreeSetupHook?: string;
|
|
@@ -170,6 +173,7 @@ export interface AsyncRunnerStepBuildParams {
|
|
|
170
173
|
cwd?: string;
|
|
171
174
|
chainSkills?: string[];
|
|
172
175
|
sessionFilesByFlatIndex?: (string | undefined)[];
|
|
176
|
+
progressDir?: string;
|
|
173
177
|
dynamicFanoutMaxItems?: number;
|
|
174
178
|
maxSubagentDepth: number;
|
|
175
179
|
asyncDir: string;
|
|
@@ -277,6 +281,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
|
|
|
277
281
|
const chainSkills = params.chainSkills ?? [];
|
|
278
282
|
const availableModels = params.availableModels;
|
|
279
283
|
const runnerCwd = resolveChildCwd(ctx.cwd, cwd);
|
|
284
|
+
const progressDir = params.progressDir ?? runnerCwd;
|
|
280
285
|
const graphChain: ChainStep[] = params.attachRoot
|
|
281
286
|
? [{
|
|
282
287
|
agent: params.attachRoot.agent,
|
|
@@ -346,8 +351,9 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
|
|
|
346
351
|
const readInstructions = buildChainInstructions({ ...behavior, output: false, progress: false }, instructionCwd, false);
|
|
347
352
|
const isFirstProgressAgent = behavior.progress && !progressPrecreated && !progressInstructionCreated;
|
|
348
353
|
if (behavior.progress) progressInstructionCreated = true;
|
|
349
|
-
const progressInstructions = buildChainInstructions({ ...behavior, output: false, reads: false },
|
|
354
|
+
const progressInstructions = buildChainInstructions({ ...behavior, output: false, reads: false }, progressDir, isFirstProgressAgent);
|
|
350
355
|
const outputPath = resolveSingleOutputPath(behavior.output, ctx.cwd, instructionCwd);
|
|
356
|
+
systemPrompt = injectOutputPathSystemPrompt(systemPrompt, outputPath);
|
|
351
357
|
const validationError = validateFileOnlyOutputMode(behavior.outputMode, outputPath, `Async step (${s.agent})`);
|
|
352
358
|
if (validationError) throw new AsyncStartValidationError(validationError);
|
|
353
359
|
let taskTemplate = s.task ?? "{previous}";
|
|
@@ -359,6 +365,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
|
|
|
359
365
|
const primaryModel = resolveSubagentModelOverride(requestedModel, ctx.currentModel, availableModels, ctx.currentModelProvider);
|
|
360
366
|
const model = applyThinkingSuffix(primaryModel, a.thinking);
|
|
361
367
|
return {
|
|
368
|
+
parentSessionId: ctx.parentSessionId ?? ctx.currentSessionId,
|
|
362
369
|
agent: s.agent,
|
|
363
370
|
task,
|
|
364
371
|
phase: s.phase,
|
|
@@ -414,7 +421,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
|
|
|
414
421
|
});
|
|
415
422
|
const progressPrecreated = parallelBehaviors.some((behavior) => behavior.progress);
|
|
416
423
|
if (progressPrecreated) {
|
|
417
|
-
if (!s.worktree) writeInitialProgressFile(
|
|
424
|
+
if (!s.worktree || params.progressDir) writeInitialProgressFile(progressDir);
|
|
418
425
|
progressInstructionCreated = true;
|
|
419
426
|
}
|
|
420
427
|
return {
|
|
@@ -439,7 +446,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
|
|
|
439
446
|
const behavior = suppressProgressForReadOnlyTask(resolveStepBehavior(agent, buildStepOverrides(s.parallel), chainSkills), s.parallel.task, originalTask);
|
|
440
447
|
const progressPrecreated = behavior.progress;
|
|
441
448
|
if (progressPrecreated) {
|
|
442
|
-
writeInitialProgressFile(
|
|
449
|
+
writeInitialProgressFile(progressDir);
|
|
443
450
|
progressInstructionCreated = true;
|
|
444
451
|
}
|
|
445
452
|
return {
|
|
@@ -539,6 +546,7 @@ export function executeAsyncChain(
|
|
|
539
546
|
cwd,
|
|
540
547
|
chainSkills: params.chainSkills,
|
|
541
548
|
sessionFilesByFlatIndex,
|
|
549
|
+
progressDir: params.progressDir ?? (resultMode === "parallel" ? path.join(asyncDir, "progress") : undefined),
|
|
542
550
|
dynamicFanoutMaxItems: params.dynamicFanoutMaxItems,
|
|
543
551
|
maxSubagentDepth,
|
|
544
552
|
asyncDir,
|
|
@@ -765,6 +773,7 @@ export function executeAsyncSingle(
|
|
|
765
773
|
|
|
766
774
|
const effectiveOutput = normalizeSingleOutputOverride(params.output, agentConfig.output);
|
|
767
775
|
const outputPath = resolveSingleOutputPath(effectiveOutput, ctx.cwd, runnerCwd);
|
|
776
|
+
systemPrompt = injectOutputPathSystemPrompt(systemPrompt, outputPath);
|
|
768
777
|
const outputMode = params.outputMode ?? "inline";
|
|
769
778
|
const validationError = validateFileOnlyOutputMode(outputMode, outputPath, `Async single run (${agent})`);
|
|
770
779
|
if (validationError) return formatAsyncStartError("single", validationError);
|
|
@@ -783,6 +792,7 @@ export function executeAsyncSingle(
|
|
|
783
792
|
id,
|
|
784
793
|
steps: [
|
|
785
794
|
{
|
|
795
|
+
parentSessionId: ctx.parentSessionId ?? ctx.currentSessionId,
|
|
786
796
|
agent,
|
|
787
797
|
task: taskWithOutputInstruction,
|
|
788
798
|
cwd: runnerCwd,
|
|
@@ -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) {
|
|
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { ASYNC_DIR, RESULTS_DIR, type AsyncStatus, type SubagentState } from "../../shared/types.ts";
|
|
4
4
|
import { resolveSubagentIntercomTarget } from "../../intercom/intercom-bridge.ts";
|
|
5
|
+
import { deliverInterruptRequest } from "./control-channel.ts";
|
|
5
6
|
import { reconcileAsyncRun } from "./stale-run-reconciler.ts";
|
|
6
7
|
|
|
7
8
|
export const ASYNC_RESUME_INTERRUPT_SIGNAL: NodeJS.Signals = process.platform === "win32" ? "SIGBREAK" : "SIGUSR2";
|
|
@@ -38,33 +39,30 @@ export type AsyncResumeTarget = {
|
|
|
38
39
|
|
|
39
40
|
type KillFn = (pid: number, signal?: NodeJS.Signals | 0) => boolean;
|
|
40
41
|
|
|
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
42
|
export function interruptLiveAsyncResumeTarget(input: {
|
|
53
43
|
target: AsyncResumeTarget & { kind: "live" };
|
|
54
44
|
state?: Pick<SubagentState, "asyncJobs">;
|
|
55
45
|
kill?: KillFn;
|
|
56
46
|
now?: () => number;
|
|
47
|
+
resultsDir?: string;
|
|
57
48
|
}): { ok: true; asyncId: string } | { ok: false; message: string } {
|
|
58
49
|
const asyncId = input.target.runId;
|
|
59
50
|
if (!input.target.asyncDir) {
|
|
60
51
|
return { ok: false, message: `Async run ${asyncId} is live but does not have an async directory to interrupt.` };
|
|
61
52
|
}
|
|
62
|
-
const status =
|
|
53
|
+
const status = reconcileAsyncRun(input.target.asyncDir, { resultsDir: input.resultsDir, kill: input.kill, now: input.now }).status;
|
|
63
54
|
if (!status || status.state !== "running" || typeof status.pid !== "number") {
|
|
64
55
|
return { ok: false, message: `Async run ${asyncId} is live but no interrupt-capable runner pid was found.` };
|
|
65
56
|
}
|
|
66
57
|
try {
|
|
67
|
-
(
|
|
58
|
+
deliverInterruptRequest({
|
|
59
|
+
asyncDir: input.target.asyncDir,
|
|
60
|
+
pid: status.pid,
|
|
61
|
+
kill: input.kill,
|
|
62
|
+
signal: ASYNC_RESUME_INTERRUPT_SIGNAL,
|
|
63
|
+
now: input.now,
|
|
64
|
+
source: "async-resume",
|
|
65
|
+
});
|
|
68
66
|
const tracked = input.state?.asyncJobs.get(asyncId);
|
|
69
67
|
if (tracked) {
|
|
70
68
|
tracked.activityState = undefined;
|
|
@@ -0,0 +1,177 @@
|
|
|
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
|
+
/** Control inbox directory inside an async run dir. */
|
|
41
|
+
export function controlInboxDir(asyncDir: string): string {
|
|
42
|
+
return path.join(asyncDir, "control");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Path of the portable interrupt request file. */
|
|
46
|
+
export function interruptRequestPath(asyncDir: string): string {
|
|
47
|
+
return path.join(controlInboxDir(asyncDir), "interrupt.json");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Parent side: drop a portable interrupt request the runner's inbox watcher will
|
|
52
|
+
* pick up regardless of OS. Written atomically (temp + rename), dir auto-created.
|
|
53
|
+
*/
|
|
54
|
+
export function requestAsyncInterrupt(
|
|
55
|
+
asyncDir: string,
|
|
56
|
+
payload: Omit<InterruptRequest, "type"> = {},
|
|
57
|
+
deps: { now?: () => number } = {},
|
|
58
|
+
): string {
|
|
59
|
+
const requestPath = interruptRequestPath(asyncDir);
|
|
60
|
+
const request: InterruptRequest = { ...payload, ts: payload.ts ?? deps.now?.() ?? Date.now(), type: "interrupt" };
|
|
61
|
+
writeAtomicJson(requestPath, request);
|
|
62
|
+
return requestPath;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Runner side: consume a pending interrupt request. Idempotent — removes the file
|
|
67
|
+
* so each distinct request fires exactly once. Returns whether one was pending.
|
|
68
|
+
*/
|
|
69
|
+
export function consumeInterruptRequest(
|
|
70
|
+
asyncDir: string,
|
|
71
|
+
fsImpl: Pick<typeof fs, "existsSync" | "rmSync"> = fs,
|
|
72
|
+
): boolean {
|
|
73
|
+
const requestPath = interruptRequestPath(asyncDir);
|
|
74
|
+
if (!fsImpl.existsSync(requestPath)) return false;
|
|
75
|
+
try {
|
|
76
|
+
fsImpl.rmSync(requestPath, { force: true, recursive: true });
|
|
77
|
+
} catch {
|
|
78
|
+
// Already removed by a concurrent check — still counts as consumed.
|
|
79
|
+
}
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Parent side: portable interrupt = authoritative file request + best-effort OS
|
|
85
|
+
* signal. The signal is only a latency optimization on Unix; ENOSYS on Windows
|
|
86
|
+
* is swallowed because the file inbox is authoritative there. Other signal
|
|
87
|
+
* failures are surfaced because they usually mean the runner is not alive to
|
|
88
|
+
* consume the request.
|
|
89
|
+
*/
|
|
90
|
+
export function deliverInterruptRequest(input: {
|
|
91
|
+
asyncDir: string;
|
|
92
|
+
pid?: number;
|
|
93
|
+
kill?: KillFn;
|
|
94
|
+
signal?: NodeJS.Signals;
|
|
95
|
+
now?: () => number;
|
|
96
|
+
source?: string;
|
|
97
|
+
}): void {
|
|
98
|
+
const requestPath = requestAsyncInterrupt(input.asyncDir, input.source ? { source: input.source } : {}, { now: input.now });
|
|
99
|
+
if (typeof input.pid === "number" && input.pid > 0) {
|
|
100
|
+
try {
|
|
101
|
+
(input.kill ?? process.kill)(input.pid, input.signal ?? INTERRUPT_SIGNAL);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOSYS") {
|
|
104
|
+
// File inbox is authoritative when custom cross-process signals are unavailable.
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
fs.rmSync(requestPath, { force: true });
|
|
109
|
+
} catch {
|
|
110
|
+
// Best effort cleanup; the caller still gets the signal failure.
|
|
111
|
+
}
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Runner side: watch the control inbox and route interrupt requests into
|
|
119
|
+
* `onInterrupt`. Uses `fs.watch` when available plus an interval poll as a
|
|
120
|
+
* portable safety net (covers filesystems/platforms where `fs.watch` is
|
|
121
|
+
* unreliable). Fires once per distinct request. Returns a disposer.
|
|
122
|
+
*/
|
|
123
|
+
export function watchAsyncControlInbox(
|
|
124
|
+
asyncDir: string,
|
|
125
|
+
opts: {
|
|
126
|
+
onInterrupt: () => void;
|
|
127
|
+
pollIntervalMs?: number;
|
|
128
|
+
fs?: ControlChannelFs;
|
|
129
|
+
timers?: ControlChannelTimers;
|
|
130
|
+
},
|
|
131
|
+
): () => void {
|
|
132
|
+
const fsImpl = opts.fs ?? fs;
|
|
133
|
+
const timers = opts.timers ?? { setInterval, clearInterval };
|
|
134
|
+
const dir = controlInboxDir(asyncDir);
|
|
135
|
+
try {
|
|
136
|
+
fsImpl.mkdirSync(dir, { recursive: true });
|
|
137
|
+
} catch {
|
|
138
|
+
// Best effort — the poll/watch below tolerates a missing dir.
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let disposed = false;
|
|
142
|
+
const check = (): void => {
|
|
143
|
+
if (disposed) return;
|
|
144
|
+
try {
|
|
145
|
+
if (consumeInterruptRequest(asyncDir, fsImpl)) opts.onInterrupt();
|
|
146
|
+
} catch {
|
|
147
|
+
// Never let inbox errors crash the runner.
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// Handle a request that may have arrived before the watcher started.
|
|
152
|
+
check();
|
|
153
|
+
|
|
154
|
+
let watcher: fs.FSWatcher | undefined;
|
|
155
|
+
try {
|
|
156
|
+
watcher = fsImpl.watch(dir, () => check());
|
|
157
|
+
watcher.on?.("error", () => {
|
|
158
|
+
// fs.watch can emit on transient FS errors; the interval poll keeps us live.
|
|
159
|
+
});
|
|
160
|
+
} catch {
|
|
161
|
+
watcher = undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const interval = timers.setInterval(check, opts.pollIntervalMs ?? POLL_INTERVAL_MS);
|
|
165
|
+
interval.unref?.();
|
|
166
|
+
|
|
167
|
+
return () => {
|
|
168
|
+
if (disposed) return;
|
|
169
|
+
disposed = true;
|
|
170
|
+
try {
|
|
171
|
+
watcher?.close();
|
|
172
|
+
} catch {
|
|
173
|
+
// ignore
|
|
174
|
+
}
|
|
175
|
+
timers.clearInterval(interval);
|
|
176
|
+
};
|
|
177
|
+
}
|
|
@@ -21,7 +21,7 @@ import { projectNestedRegistryForRoot, sanitizeSummary } from "../shared/nested-
|
|
|
21
21
|
const WATCHER_RESTART_DELAY_MS = 3000;
|
|
22
22
|
const POLL_INTERVAL_MS = 3000;
|
|
23
23
|
|
|
24
|
-
type ResultWatcherFs = Pick<typeof fs, "existsSync" | "readFileSync" | "unlinkSync" | "readdirSync" | "mkdirSync" | "watch">;
|
|
24
|
+
type ResultWatcherFs = Pick<typeof fs, "existsSync" | "readFileSync" | "unlinkSync" | "readdirSync" | "mkdirSync" | "realpathSync" | "watch">;
|
|
25
25
|
|
|
26
26
|
type ResultWatcherTimers = {
|
|
27
27
|
setTimeout: typeof setTimeout;
|
|
@@ -91,6 +91,14 @@ function shouldFallBackToPolling(error: unknown): boolean {
|
|
|
91
91
|
return code === "EMFILE" || code === "ENOSPC";
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
function resolveNativeWatchDir(fsApi: ResultWatcherFs, resultsDir: string): string {
|
|
95
|
+
try {
|
|
96
|
+
return fsApi.realpathSync.native(resultsDir);
|
|
97
|
+
} catch {
|
|
98
|
+
return resultsDir;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
94
102
|
export function createResultWatcher(
|
|
95
103
|
pi: { events: IntercomEventBus },
|
|
96
104
|
state: SubagentState,
|
|
@@ -264,7 +272,8 @@ export function createResultWatcher(
|
|
|
264
272
|
state.watcherRestartTimer = null;
|
|
265
273
|
}
|
|
266
274
|
try {
|
|
267
|
-
|
|
275
|
+
const watchDir = resolveNativeWatchDir(fsApi, resultsDir);
|
|
276
|
+
state.watcher = fsApi.watch(watchDir, (ev, file) => {
|
|
268
277
|
if (ev !== "rename" || !file) return;
|
|
269
278
|
const fileName = file.toString();
|
|
270
279
|
if (!fileName.endsWith(".json")) return;
|
|
@@ -48,9 +48,14 @@ function isNotFoundError(error: unknown): boolean {
|
|
|
48
48
|
&& (error as NodeJS.ErrnoException).code === "ENOENT";
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
function
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
function appendJsonlBestEffort(filePath: string, payload: object): void {
|
|
52
|
+
try {
|
|
53
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
54
|
+
fs.appendFileSync(filePath, `${JSON.stringify(payload)}\n`, "utf-8");
|
|
55
|
+
} catch {
|
|
56
|
+
// Repair status/result writes are the important path. A broken or full
|
|
57
|
+
// diagnostic event log must not make stale-run reconciliation fail.
|
|
58
|
+
}
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
function readStatusFile(asyncDir: string): AsyncStatus | null {
|
|
@@ -223,7 +228,7 @@ function writeFailedRepair(asyncDir: string, status: AsyncStatus, resultPath: st
|
|
|
223
228
|
const repair = buildFailedRepair(status, asyncDir, now, reason);
|
|
224
229
|
writeAtomicJson(resultPath, repair.result);
|
|
225
230
|
writeAtomicJson(path.join(asyncDir, "status.json"), repair.status);
|
|
226
|
-
|
|
231
|
+
appendJsonlBestEffort(path.join(asyncDir, "events.jsonl"), {
|
|
227
232
|
type: "subagent.run.repaired_stale",
|
|
228
233
|
ts: now,
|
|
229
234
|
runId: repair.status.runId,
|
|
@@ -4,7 +4,8 @@ import * as path from "node:path";
|
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
5
5
|
import type { Message } from "@earendil-works/pi-ai";
|
|
6
6
|
import { writeAtomicJson } from "../../shared/atomic-json.ts";
|
|
7
|
-
import {
|
|
7
|
+
import { consumeInterruptRequest, watchAsyncControlInbox } from "./control-channel.ts";
|
|
8
|
+
import { appendJsonl as appendRawJsonl, getArtifactPaths } from "../../shared/artifacts.ts";
|
|
8
9
|
import { PI_CODING_AGENT_PACKAGE, getPiSpawnCommand, resolveInstalledPiPackageRoot } from "../shared/pi-spawn.ts";
|
|
9
10
|
import { captureSingleOutputSnapshot, finalizeSingleOutput, formatSavedOutputReference, resolveSingleOutput, type SingleOutputSnapshot } from "../shared/single-output.ts";
|
|
10
11
|
import {
|
|
@@ -131,6 +132,79 @@ interface StepResult {
|
|
|
131
132
|
}
|
|
132
133
|
|
|
133
134
|
const ASYNC_INTERRUPT_SIGNAL: NodeJS.Signals = process.platform === "win32" ? "SIGBREAK" : "SIGUSR2";
|
|
135
|
+
const DEFAULT_MAX_ASYNC_EVENTS_BYTES = 50 * 1024 * 1024;
|
|
136
|
+
const ASYNC_EVENTS_MAX_BYTES_ENV = "PI_SUBAGENT_ASYNC_EVENTS_MAX_BYTES";
|
|
137
|
+
const TRUNCATED_EVENT_TYPE = "subagent.events.truncated";
|
|
138
|
+
const TRUNCATION_MARKER_RESERVE_BYTES = 512;
|
|
139
|
+
|
|
140
|
+
interface AsyncEventLogState {
|
|
141
|
+
bytes: number;
|
|
142
|
+
diagnosticsTruncated: boolean;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const asyncEventLogStates = new Map<string, AsyncEventLogState>();
|
|
146
|
+
|
|
147
|
+
function maxAsyncEventsBytes(): number {
|
|
148
|
+
const raw = process.env[ASYNC_EVENTS_MAX_BYTES_ENV];
|
|
149
|
+
if (!raw) return DEFAULT_MAX_ASYNC_EVENTS_BYTES;
|
|
150
|
+
const parsed = Number(raw);
|
|
151
|
+
if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_MAX_ASYNC_EVENTS_BYTES;
|
|
152
|
+
return Math.floor(parsed);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function eventLogState(filePath: string): AsyncEventLogState {
|
|
156
|
+
let state = asyncEventLogStates.get(filePath);
|
|
157
|
+
if (state) return state;
|
|
158
|
+
let bytes = 0;
|
|
159
|
+
try {
|
|
160
|
+
bytes = fs.statSync(filePath).size;
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
163
|
+
// Diagnostic event accounting is best-effort; writes below are also safe.
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
state = { bytes, diagnosticsTruncated: false };
|
|
167
|
+
asyncEventLogStates.set(filePath, state);
|
|
168
|
+
return state;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function appendJsonl(filePath: string, line: string): void {
|
|
172
|
+
try {
|
|
173
|
+
appendRawJsonl(filePath, line);
|
|
174
|
+
const state = asyncEventLogStates.get(filePath);
|
|
175
|
+
if (state) state.bytes += Buffer.byteLength(`${line}\n`, "utf-8");
|
|
176
|
+
} catch {
|
|
177
|
+
// Async event logging is diagnostic and must not fail the run.
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function appendDiagnosticJsonl(filePath: string, line: string, droppedEventType?: string): void {
|
|
182
|
+
if (!line.trim()) return;
|
|
183
|
+
const state = eventLogState(filePath);
|
|
184
|
+
if (state.diagnosticsTruncated) return;
|
|
185
|
+
const maxBytes = maxAsyncEventsBytes();
|
|
186
|
+
const chunkBytes = Buffer.byteLength(`${line}\n`, "utf-8");
|
|
187
|
+
const diagnosticBudget = Math.max(0, maxBytes - TRUNCATION_MARKER_RESERVE_BYTES);
|
|
188
|
+
if (state.bytes + chunkBytes <= diagnosticBudget) {
|
|
189
|
+
appendJsonl(filePath, line);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const marker = JSON.stringify({
|
|
194
|
+
type: TRUNCATED_EVENT_TYPE,
|
|
195
|
+
ts: Date.now(),
|
|
196
|
+
maxBytes,
|
|
197
|
+
droppedEventType,
|
|
198
|
+
});
|
|
199
|
+
if (state.bytes + Buffer.byteLength(`${marker}\n`, "utf-8") <= maxBytes) {
|
|
200
|
+
appendJsonl(filePath, marker);
|
|
201
|
+
}
|
|
202
|
+
state.diagnosticsTruncated = true;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function shouldPersistChildEvent(event: Record<string, unknown>): boolean {
|
|
206
|
+
return event.type !== "message_update";
|
|
207
|
+
}
|
|
134
208
|
|
|
135
209
|
function findLatestSessionFile(sessionDir: string): string | null {
|
|
136
210
|
try {
|
|
@@ -274,14 +348,15 @@ function runPiStreaming(
|
|
|
274
348
|
|
|
275
349
|
const appendChildEvent = (event: Record<string, unknown>) => {
|
|
276
350
|
if (!childEventContext) return;
|
|
277
|
-
|
|
351
|
+
if (!shouldPersistChildEvent(event)) return;
|
|
352
|
+
appendDiagnosticJsonl(childEventContext.eventsPath, JSON.stringify({
|
|
278
353
|
...event,
|
|
279
354
|
subagentSource: "child",
|
|
280
355
|
subagentRunId: childEventContext.runId,
|
|
281
356
|
subagentStepIndex: childEventContext.stepIndex,
|
|
282
357
|
subagentAgent: childEventContext.agent,
|
|
283
358
|
observedAt: Date.now(),
|
|
284
|
-
}));
|
|
359
|
+
}), typeof event.type === "string" ? event.type : undefined);
|
|
285
360
|
};
|
|
286
361
|
|
|
287
362
|
const appendChildLine = (type: "subagent.child.stdout" | "subagent.child.stderr", line: string) => {
|
|
@@ -676,6 +751,7 @@ async function runSingleStep(
|
|
|
676
751
|
}
|
|
677
752
|
}
|
|
678
753
|
const { args, env, tempDir } = buildPiArgs({
|
|
754
|
+
parentSessionId: step.parentSessionId,
|
|
679
755
|
baseArgs: ["--mode", "json", "-p"],
|
|
680
756
|
task,
|
|
681
757
|
sessionEnabled,
|
|
@@ -684,6 +760,7 @@ async function runSingleStep(
|
|
|
684
760
|
model: candidate,
|
|
685
761
|
inheritProjectContext: step.inheritProjectContext,
|
|
686
762
|
inheritSkills: step.inheritSkills,
|
|
763
|
+
requireReadTool: Boolean(step.skills?.length),
|
|
687
764
|
tools: step.tools,
|
|
688
765
|
extensions: step.extensions,
|
|
689
766
|
subagentOnlyExtensions: step.subagentOnlyExtensions,
|
|
@@ -1431,6 +1508,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
|
|
|
1431
1508
|
}
|
|
1432
1509
|
|
|
1433
1510
|
const interruptRunner = () => {
|
|
1511
|
+
consumeInterruptRequest(asyncDir);
|
|
1434
1512
|
if (interrupted || statusPayload.state !== "running") return;
|
|
1435
1513
|
interrupted = true;
|
|
1436
1514
|
const now = Date.now();
|
|
@@ -1456,6 +1534,10 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
|
|
|
1456
1534
|
activeChildInterrupt?.();
|
|
1457
1535
|
};
|
|
1458
1536
|
process.on(ASYNC_INTERRUPT_SIGNAL, interruptRunner);
|
|
1537
|
+
// Portable control inbox: the parent drops an interrupt request file here when
|
|
1538
|
+
// it cannot deliver the OS signal (e.g. ENOSYS on Windows). Routes into the
|
|
1539
|
+
// same graceful interruptRunner() so stop/steer work on every platform.
|
|
1540
|
+
const disposeControlInbox = watchAsyncControlInbox(asyncDir, { onInterrupt: interruptRunner });
|
|
1459
1541
|
appendJsonl(
|
|
1460
1542
|
eventsPath,
|
|
1461
1543
|
JSON.stringify({
|
|
@@ -2241,6 +2323,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
|
|
|
2241
2323
|
clearInterval(activityTimer);
|
|
2242
2324
|
activityTimer = undefined;
|
|
2243
2325
|
}
|
|
2326
|
+
disposeControlInbox();
|
|
2244
2327
|
const effectiveSessionFile = sessionFile ?? latestSessionFile;
|
|
2245
2328
|
const runEndedAt = Date.now();
|
|
2246
2329
|
statusPayload.state = interrupted ? "paused" : results.every((r) => r.success) ? "complete" : "failed";
|