pi-cohort 5.1.3 → 5.3.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 +45 -0
- package/README.md +5 -1
- package/agents/monitor.md +26 -0
- package/package.json +1 -1
- package/skills/pi-cohort/SKILL.md +13 -0
- package/src/runs/background/async-execution.ts +96 -23
- package/src/runs/background/async-status.ts +31 -2
- package/src/runs/background/run-status.ts +15 -1
- package/src/runs/background/stale-run-reconciler.ts +30 -5
- package/src/runs/background/subagent-runner.ts +2 -1
- package/src/runs/foreground/subagent-executor.ts +70 -15
- package/src/runs/shared/pi-args.ts +5 -0
- package/src/runs/shared/single-output.ts +31 -0
- package/src/runs/shared/worktree.ts +19 -10
- package/src/shared/artifacts.ts +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [5.3.0] - 2026-09-01
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- Foreground parallel `worktree: true` patches now write to a run-scoped
|
|
8
|
+
directory (`<session artifacts>/<runId>/worktree-diffs`) instead of a
|
|
9
|
+
session-wide `worktree-diffs` directory shared by every dispatch, so
|
|
10
|
+
concurrent or sequential worktree runs no longer overwrite each other's
|
|
11
|
+
patches. Background (`<asyncDir>/worktree-diffs/step-<i>`) and chain
|
|
12
|
+
(`<chainDir>/worktree-diffs/step-<i>`) paths are unchanged. A relative
|
|
13
|
+
`output:` on a `worktree: true` task now resolves to the run's per-task
|
|
14
|
+
directory (`<session artifacts>/<runId>/task-<i>/` foreground,
|
|
15
|
+
`<asyncDir>/step-<i>/task-<j>/` async) instead of inside the throwaway
|
|
16
|
+
checkout, so the report survives worktree teardown and no longer pollutes
|
|
17
|
+
the captured patch; an absolute `output:` is unaffected, and `reads:` plus
|
|
18
|
+
the task's working directory still resolve inside the checkout. This is a
|
|
19
|
+
behavior change for anyone relying on the old patch/output paths, not a
|
|
20
|
+
breaking API change - no parameter, schema, or return shape changed.
|
|
21
|
+
- Stale artifact directories are now pruned recursively by the artifact
|
|
22
|
+
cleanup sweep, alongside files (previously only files were pruned, since
|
|
23
|
+
`unlinkSync` throws on a directory).
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- A relative `output:` that escapes its per-task directory (`../report.md`)
|
|
28
|
+
or normalizes to the directory itself (`.`) is now rejected at dispatch
|
|
29
|
+
with a tool error instead of writing outside the intended location.
|
|
30
|
+
- Worktree diffs are now captured before every post-execution return
|
|
31
|
+
(normal, interrupted, detached, intercom-receipt), so the diff summary is
|
|
32
|
+
attached even when a run ends early; a patch-capture failure is now
|
|
33
|
+
surfaced in the summary instead of silently reporting an empty patch.
|
|
34
|
+
|
|
35
|
+
## [5.2.0] - 2026-09-01
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- `runner.log` diagnostics for the detached async runner: captures the
|
|
40
|
+
runner's stdout/stderr, spawn-time jiti revalidation, and enriched failure
|
|
41
|
+
messages that append the log's tail; the run dir is also exported to the
|
|
42
|
+
child as `PI_SUBAGENT_RUN_DIR` and echoed on an `Async dir:` start line.
|
|
43
|
+
- Builtin `monitor` persona: watches an already-started job (async run dir,
|
|
44
|
+
PID, log, or probe command), reports progress deltas on a cadence, and
|
|
45
|
+
flags stalls, plus SKILL.md guidance to pair it with long-running async
|
|
46
|
+
jobs.
|
|
47
|
+
|
|
3
48
|
## [5.1.3] - 2026-08-18
|
|
4
49
|
|
|
5
50
|
### Fixed
|
package/README.md
CHANGED
|
@@ -101,7 +101,7 @@ Full reference: agent/chain authoring in [doc/agents-and-chains.md](doc/agents-a
|
|
|
101
101
|
| Term | Meaning |
|
|
102
102
|
|---|---|
|
|
103
103
|
| Subagent | A focused child Pi session with one job and (by default) a fresh context. |
|
|
104
|
-
| Agent (persona) | A markdown file with frontmatter defining a specialist: `scout`, `planner`, `worker`, `reviewer`, `context-builder`, `oracle`, `delegate`. Full table: [doc/agents-and-chains.md](doc/agents-and-chains.md#builtin-agents-in-plain-english). |
|
|
104
|
+
| Agent (persona) | A markdown file with frontmatter defining a specialist: `scout`, `planner`, `worker`, `reviewer`, `context-builder`, `oracle`, `delegate`, `monitor`. Full table: [doc/agents-and-chains.md](doc/agents-and-chains.md#builtin-agents-in-plain-english). |
|
|
105
105
|
| Chain | A saved or inline sequence of agent steps, with fan-out/fan-in support. |
|
|
106
106
|
| Fresh vs. forked context | Fresh = clean slate; forked = a real branch of the parent's session history. |
|
|
107
107
|
| Recursion guard | Depth cap on nested delegation so a child can only fan out if explicitly allowed. |
|
|
@@ -142,6 +142,10 @@ Optional companions:
|
|
|
142
142
|
|
|
143
143
|
See [CHANGELOG.md](CHANGELOG.md) for shipped work in progress.
|
|
144
144
|
|
|
145
|
+
## Contributing
|
|
146
|
+
|
|
147
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) - issues follow a Context / Problem / Idea / Acceptance Criteria template; PRs run the [pi-gauntlet](https://github.com/jjuraszek/pi-gauntlet) workflow (one-liners exempt from ceremony, never from keeping docs truthful).
|
|
148
|
+
|
|
145
149
|
## Support
|
|
146
150
|
|
|
147
151
|
If `pi-cohort` is useful, consider [buying me a coffee](https://buymeacoffee.com/jjurasszek).
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: monitor
|
|
3
|
+
description: Watches a job you already started - not for doing the work. Reports progress deltas on a cadence, flags stalls, exits when the job ends.
|
|
4
|
+
tools: read, bash
|
|
5
|
+
thinking: low
|
|
6
|
+
completionGuard: false
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
You observe a job someone else runs. Never execute, restart, or modify it.
|
|
10
|
+
|
|
11
|
+
The task names your target: an absolute async run dir (preferred - read its status.json, tail output logs and runner.log), a PID (POSIX: ps -p <pid>; Windows: tasklist /FI "PID eq <pid>"), a log file (tail), or a probe command. If the target is already terminated at your first check, report that and exit. If it is unreadable, report "cannot observe target: <reason>" and exit - never guess, never loop.
|
|
12
|
+
|
|
13
|
+
Write your trail to $PI_SUBAGENT_RUN_DIR/trail.md. If that variable is unset, create a directory with mktemp -d and write there. Never write under the workspace.
|
|
14
|
+
|
|
15
|
+
Loop:
|
|
16
|
+
1. Record your start time. Run the first check immediately, before any sleep.
|
|
17
|
+
2. Each cycle: check the target is alive, collect progress (new log lines, counts, phases, best-effort ETA), and compose a one-line delta vs the previous cycle.
|
|
18
|
+
3. Append the delta with a timestamp to the trail.
|
|
19
|
+
4. If contact_supervisor is available, send the delta with reason "progress_update".
|
|
20
|
+
5. Sleep the cadence interval in chunks of <= 5 minutes, then repeat.
|
|
21
|
+
|
|
22
|
+
Cadence: every 15 minutes unless the task sets another interval.
|
|
23
|
+
|
|
24
|
+
Stall: no growth in the watched log and no change in status.json lastUpdate since the previous cycle. Judge stall only by the signals your target has; a signal that does not exist never counts as change. Report it as "no output for <interval>, possible stall". After two consecutive silent cycles, escalate with contact_supervisor reason "need_decision". Never report "still working" without evidence.
|
|
25
|
+
|
|
26
|
+
Exit: when the target reaches a terminal state, send a final summary and end - the summary is your run result. Stop 24h after your recorded start time even if the target lives. Without contact_supervisor, the trail and final summary are the record; behave identically otherwise.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-cohort",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.3.0",
|
|
4
4
|
"description": "Delegate Pi work to focused child agents: code review, scouting, implementation, parallel audits, saved chains, and background jobs.",
|
|
5
5
|
"author": "Jacek Juraszek",
|
|
6
6
|
"license": "MIT",
|
|
@@ -351,6 +351,19 @@ subagent({ action: "doctor" })
|
|
|
351
351
|
|
|
352
352
|
Humans can use `/cohort-doctor` for the same read-only report. It checks runtime paths, discovery counts, async support, current session context, and intercom bridge state.
|
|
353
353
|
|
|
354
|
+
Long-running job hygiene:
|
|
355
|
+
|
|
356
|
+
- The job's task must instruct it to emit observable progress as it works (log lines, counts, phase names; best-effort ETA). A silent long job is a defect.
|
|
357
|
+
- Pair the job with a monitor dispatch, giving it the absolute async dir from the `Async dir:` line of the start message:
|
|
358
|
+
|
|
359
|
+
```typescript
|
|
360
|
+
subagent({ agent: "worker", async: true, task: "<long job - emit progress lines as you work>" })
|
|
361
|
+
-> Async: worker [R] Async dir: <D>
|
|
362
|
+
subagent({ agent: "monitor", async: true, task: "Watch async run R at <D>. Report every 15m. Stop when it ends." })
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
- Live 15m reports require the pi-intercom bridge; without it the monitor's trail and final summary are post-hoc records.
|
|
366
|
+
|
|
354
367
|
### Subagent control
|
|
355
368
|
|
|
356
369
|
Subagent control is the runtime visibility and intervention layer for delegated runs. It is separate from lifecycle status. Lifecycle status says whether a child is `queued`, `running`, `paused`, `complete`, or `failed`. Activity reporting is factual: it tracks the last observed activity time and the current tool when known. It does not pretend to know that a child is truly stuck.
|
|
@@ -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 { injectSingleOutputInstruction, normalizeSingleOutputOverride, resolveParallelTaskOutputPath, 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";
|
|
@@ -88,7 +88,20 @@ function resolveJitiCliPath(): string | undefined {
|
|
|
88
88
|
return undefined;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
|
|
91
|
+
export function createJitiCliResolver(deps: { resolve?: () => string | undefined; exists?: (p: string) => boolean } = {}): () => string | undefined {
|
|
92
|
+
const resolve = deps.resolve ?? resolveJitiCliPath;
|
|
93
|
+
const exists = deps.exists ?? ((p: string) => fs.existsSync(p));
|
|
94
|
+
let cached = resolve();
|
|
95
|
+
return () => {
|
|
96
|
+
if (cached && exists(cached)) return cached;
|
|
97
|
+
cached = resolve();
|
|
98
|
+
if (cached && exists(cached)) return cached;
|
|
99
|
+
cached = undefined;
|
|
100
|
+
return undefined;
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const ensureJitiCliPath = createJitiCliResolver();
|
|
92
105
|
|
|
93
106
|
interface AsyncExecutionContext {
|
|
94
107
|
pi: ExtensionAPI;
|
|
@@ -172,13 +185,60 @@ export function formatAsyncStartedMessage(headline: string): string {
|
|
|
172
185
|
* Check if jiti is available for async execution
|
|
173
186
|
*/
|
|
174
187
|
export function isAsyncAvailable(): boolean {
|
|
175
|
-
return
|
|
188
|
+
return ensureJitiCliPath() !== undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Spawn a detached process with stderr/stdout captured to <asyncDir>/runner.log.
|
|
193
|
+
* Falls back to ignored stdio if the log file cannot be opened; the parent always
|
|
194
|
+
* closes its copy of the fd, on every exit path.
|
|
195
|
+
*/
|
|
196
|
+
export function spawnDetachedWithLog(command: string, args: string[], cwd: string, asyncDir: string, spawnImpl: typeof spawn = spawn): { pid?: number; error?: string } {
|
|
197
|
+
let fd: number | undefined;
|
|
198
|
+
try {
|
|
199
|
+
try {
|
|
200
|
+
fd = fs.openSync(path.join(asyncDir, "runner.log"), "a");
|
|
201
|
+
} catch (error) {
|
|
202
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
203
|
+
console.error(`[pi-cohort] could not open runner.log in ${asyncDir}: ${message}`);
|
|
204
|
+
}
|
|
205
|
+
const proc = spawnImpl(command, args, {
|
|
206
|
+
cwd,
|
|
207
|
+
detached: true,
|
|
208
|
+
stdio: fd === undefined ? "ignore" : ["ignore", fd, fd],
|
|
209
|
+
windowsHide: true,
|
|
210
|
+
});
|
|
211
|
+
proc.on("error", (error) => {
|
|
212
|
+
console.error(`[pi-cohort] async spawn failed: ${error.message}`);
|
|
213
|
+
});
|
|
214
|
+
if (typeof proc.pid !== "number") {
|
|
215
|
+
return { error: `async runner did not produce a pid for cwd: ${cwd}` };
|
|
216
|
+
}
|
|
217
|
+
proc.unref();
|
|
218
|
+
return { pid: proc.pid };
|
|
219
|
+
} finally {
|
|
220
|
+
if (fd !== undefined) {
|
|
221
|
+
try {
|
|
222
|
+
fs.closeSync(fd);
|
|
223
|
+
} catch {
|
|
224
|
+
// parent-side close failure never misreports a launched child
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
176
228
|
}
|
|
177
229
|
|
|
178
230
|
/**
|
|
179
231
|
* Spawn the async runner process
|
|
180
232
|
*/
|
|
181
|
-
function spawnRunner(
|
|
233
|
+
export function spawnRunner(
|
|
234
|
+
cfg: object,
|
|
235
|
+
suffix: string,
|
|
236
|
+
cwd: string,
|
|
237
|
+
asyncDir: string,
|
|
238
|
+
deps: { ensureJiti?: () => string | undefined; spawnImpl?: typeof spawn } = {},
|
|
239
|
+
): { pid?: number; error?: string } {
|
|
240
|
+
const ensureJiti = deps.ensureJiti ?? ensureJitiCliPath;
|
|
241
|
+
const jitiCliPath = ensureJiti();
|
|
182
242
|
if (!jitiCliPath) {
|
|
183
243
|
return { error: "upstream jiti for TypeScript execution could not be found; ensure package dependencies are installed" };
|
|
184
244
|
}
|
|
@@ -197,20 +257,15 @@ function spawnRunner(cfg: object, suffix: string, cwd: string): { pid?: number;
|
|
|
197
257
|
fs.writeFileSync(cfgPath, JSON.stringify(cfg));
|
|
198
258
|
const runner = path.join(path.dirname(fileURLToPath(import.meta.url)), "subagent-runner.ts");
|
|
199
259
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
}
|
|
209
|
-
if (typeof proc.pid !== "number") {
|
|
210
|
-
return { error: `async runner did not produce a pid for cwd: ${cwd}` };
|
|
211
|
-
}
|
|
212
|
-
proc.unref();
|
|
213
|
-
return { pid: proc.pid };
|
|
260
|
+
return spawnDetachedWithLog(process.execPath, [jitiCliPath, runner, cfgPath], cwd, asyncDir, deps.spawnImpl ?? spawn);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Build the headline shown at the top of an async start message, e.g.
|
|
265
|
+
* "Async single: my-agent [run-id]\nAsync dir: /tmp/...".
|
|
266
|
+
*/
|
|
267
|
+
export function asyncStartHeadline(prefix: string, id: string, asyncDir: string): string {
|
|
268
|
+
return `${prefix} [${id}]\nAsync dir: ${asyncDir}`;
|
|
214
269
|
}
|
|
215
270
|
|
|
216
271
|
function formatAsyncStartError(mode: SubagentRunMode, message: string): AsyncExecutionResult {
|
|
@@ -318,7 +373,7 @@ export function executeAsyncChain(
|
|
|
318
373
|
...(s.model ? { model: s.model } : {}),
|
|
319
374
|
};
|
|
320
375
|
};
|
|
321
|
-
const buildSeqStep = (s: SequentialStep, sessionFile?: string, behaviorCwd?: string, progressPrecreated = false, resolvedBehavior?: ResolvedStepBehavior) => {
|
|
376
|
+
const buildSeqStep = (s: SequentialStep, sessionFile?: string, behaviorCwd?: string, progressPrecreated = false, resolvedBehavior?: ResolvedStepBehavior, isolatedLeaf?: { runDir: string; index: number }) => {
|
|
322
377
|
const a = agents.find((x) => x.name === s.agent)!;
|
|
323
378
|
const stepCwd = resolveChildCwd(runnerCwd, s.cwd);
|
|
324
379
|
const instructionCwd = behaviorCwd ?? stepCwd;
|
|
@@ -337,7 +392,16 @@ export function executeAsyncChain(
|
|
|
337
392
|
const isFirstProgressAgent = behavior.progress && !progressPrecreated && !progressInstructionCreated;
|
|
338
393
|
if (behavior.progress) progressInstructionCreated = true;
|
|
339
394
|
const progressInstructions = buildChainInstructions({ ...behavior, output: false, reads: false }, runnerCwd, isFirstProgressAgent);
|
|
340
|
-
const
|
|
395
|
+
const resolvedOutput = resolveParallelTaskOutputPath({
|
|
396
|
+
output: behavior.output,
|
|
397
|
+
ctxCwd: ctx.cwd,
|
|
398
|
+
taskCwd: instructionCwd,
|
|
399
|
+
isolated: Boolean(isolatedLeaf),
|
|
400
|
+
runDir: isolatedLeaf?.runDir,
|
|
401
|
+
index: isolatedLeaf?.index ?? 0,
|
|
402
|
+
});
|
|
403
|
+
if ("error" in resolvedOutput) throw new AsyncStartValidationError(resolvedOutput.error);
|
|
404
|
+
const outputPath = resolvedOutput.path;
|
|
341
405
|
const validationError = validateFileOnlyOutputMode(behavior.outputMode, outputPath, `Async step (${s.agent})`);
|
|
342
406
|
if (validationError) throw new AsyncStartValidationError(validationError);
|
|
343
407
|
let taskTemplate = s.task ?? "{previous}";
|
|
@@ -415,7 +479,14 @@ export function executeAsyncChain(
|
|
|
415
479
|
behaviorCwd = undefined;
|
|
416
480
|
}
|
|
417
481
|
}
|
|
418
|
-
return buildSeqStep(
|
|
482
|
+
return buildSeqStep(
|
|
483
|
+
t,
|
|
484
|
+
nextSessionFile(),
|
|
485
|
+
behaviorCwd,
|
|
486
|
+
progressPrecreated,
|
|
487
|
+
parallelBehaviors[taskIndex],
|
|
488
|
+
s.worktree ? { runDir: path.join(asyncDir, `step-${stepIndex}`), index: taskIndex } : undefined,
|
|
489
|
+
);
|
|
419
490
|
}),
|
|
420
491
|
concurrency: s.concurrency,
|
|
421
492
|
failFast: s.failFast,
|
|
@@ -503,6 +574,7 @@ export function executeAsyncChain(
|
|
|
503
574
|
},
|
|
504
575
|
id,
|
|
505
576
|
runnerCwd,
|
|
577
|
+
asyncDir,
|
|
506
578
|
);
|
|
507
579
|
} catch (error) {
|
|
508
580
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -603,7 +675,7 @@ export function executeAsyncChain(
|
|
|
603
675
|
.join(" -> ");
|
|
604
676
|
|
|
605
677
|
return {
|
|
606
|
-
content: [{ type: "text", text: formatAsyncStartedMessage(`Async ${resultMode}: ${chainDesc}
|
|
678
|
+
content: [{ type: "text", text: formatAsyncStartedMessage(asyncStartHeadline(`Async ${resultMode}: ${chainDesc}`, id, asyncDir)) }],
|
|
607
679
|
details: { mode: resultMode, runId: id, results: [], asyncId: id, asyncDir, workflowGraph },
|
|
608
680
|
};
|
|
609
681
|
}
|
|
@@ -738,6 +810,7 @@ export function executeAsyncSingle(
|
|
|
738
810
|
},
|
|
739
811
|
id,
|
|
740
812
|
runnerCwd,
|
|
813
|
+
asyncDir,
|
|
741
814
|
);
|
|
742
815
|
} catch (error) {
|
|
743
816
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -796,7 +869,7 @@ export function executeAsyncSingle(
|
|
|
796
869
|
}
|
|
797
870
|
|
|
798
871
|
return {
|
|
799
|
-
content: [{ type: "text", text: formatAsyncStartedMessage(`Async: ${agent}
|
|
872
|
+
content: [{ type: "text", text: formatAsyncStartedMessage(asyncStartHeadline(`Async: ${agent}`, id, asyncDir)) }],
|
|
800
873
|
details: { mode: "single", runId: id, results: [], asyncId: id, asyncDir },
|
|
801
874
|
};
|
|
802
875
|
}
|
|
@@ -7,7 +7,7 @@ import { readStatus } from "../../shared/utils.ts";
|
|
|
7
7
|
import { attachRootChildrenToSteps, findNestedRouteForRootId, projectNestedRegistryForRoot } from "../shared/nested-events.ts";
|
|
8
8
|
import { formatNestedRunStatusLines } from "../shared/nested-render.ts";
|
|
9
9
|
import { flatToLogicalStepIndex, normalizeParallelGroups } from "./parallel-groups.ts";
|
|
10
|
-
import { reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-reconciler.ts";
|
|
10
|
+
import { readRunnerLogTail, reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-reconciler.ts";
|
|
11
11
|
|
|
12
12
|
interface AsyncRunStepSummary {
|
|
13
13
|
index: number;
|
|
@@ -64,6 +64,7 @@ export interface AsyncRunSummary {
|
|
|
64
64
|
sessionFile?: string;
|
|
65
65
|
nestedChildren?: NestedRunSummary[];
|
|
66
66
|
nestedWarnings?: string[];
|
|
67
|
+
diagnostic?: string;
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
interface AsyncRunListOptions {
|
|
@@ -218,6 +219,29 @@ function sortRuns(runs: AsyncRunSummary[]): AsyncRunSummary[] {
|
|
|
218
219
|
});
|
|
219
220
|
}
|
|
220
221
|
|
|
222
|
+
const PRE_STATUS_CRASH_GRACE_MS = 10_000;
|
|
223
|
+
|
|
224
|
+
function summarizePreStatusCrash(asyncDir: string, now: number): AsyncRunSummary | undefined {
|
|
225
|
+
const log = readRunnerLogTail(asyncDir);
|
|
226
|
+
if (!log.tail) return undefined;
|
|
227
|
+
let startedAt: number;
|
|
228
|
+
try {
|
|
229
|
+
startedAt = fs.statSync(asyncDir).mtimeMs;
|
|
230
|
+
} catch {
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
if (now - startedAt < PRE_STATUS_CRASH_GRACE_MS) return undefined;
|
|
234
|
+
return {
|
|
235
|
+
id: path.basename(asyncDir),
|
|
236
|
+
asyncDir,
|
|
237
|
+
state: "failed",
|
|
238
|
+
mode: "single",
|
|
239
|
+
startedAt,
|
|
240
|
+
steps: [],
|
|
241
|
+
diagnostic: `Runner log tail (${log.path}):\n${log.tail}`,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
221
245
|
export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions = {}): AsyncRunSummary[] {
|
|
222
246
|
let entries: string[];
|
|
223
247
|
try {
|
|
@@ -237,7 +261,11 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
|
|
|
237
261
|
? undefined
|
|
238
262
|
: reconcileAsyncRun(asyncDir, { resultsDir: options.resultsDir, kill: options.kill, now: options.now });
|
|
239
263
|
const status = (reconciliation?.status ?? readStatus(asyncDir)) as (AsyncStatus & { cwd?: string }) | null;
|
|
240
|
-
if (!status)
|
|
264
|
+
if (!status) {
|
|
265
|
+
const crashed = summarizePreStatusCrash(asyncDir, options.now?.() ?? Date.now());
|
|
266
|
+
if (crashed && (!allowedStates || allowedStates.has(crashed.state))) runs.push(crashed);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
241
269
|
const nestedWarnings: string[] = [];
|
|
242
270
|
try {
|
|
243
271
|
const nestedRoute = findNestedRouteForRootId(status.runId || path.basename(asyncDir));
|
|
@@ -329,6 +357,7 @@ export function formatAsyncRunList(runs: AsyncRunSummary[], heading = "Active as
|
|
|
329
357
|
const outputPath = formatAsyncRunOutputPath(run);
|
|
330
358
|
if (outputPath) lines.push(` output: ${shortenPath(outputPath)}`);
|
|
331
359
|
if (run.sessionFile) lines.push(` session: ${shortenPath(run.sessionFile)}`);
|
|
360
|
+
if (run.diagnostic) for (const diagLine of run.diagnostic.split("\n")) lines.push(` ${diagLine}`);
|
|
332
361
|
lines.push("");
|
|
333
362
|
}
|
|
334
363
|
return lines.join("\n").trimEnd();
|
|
@@ -10,7 +10,7 @@ import { resolveSubagentIntercomTarget } from "../../intercom/intercom-bridge.ts
|
|
|
10
10
|
import { resolveAsyncRunLocation } from "./async-resume.ts";
|
|
11
11
|
import { resolveSubagentRunId } from "./run-id-resolver.ts";
|
|
12
12
|
import { flatToLogicalStepIndex, normalizeParallelGroups } from "./parallel-groups.ts";
|
|
13
|
-
import { reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-reconciler.ts";
|
|
13
|
+
import { readRunnerLogTail, reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-reconciler.ts";
|
|
14
14
|
import { attachRootChildrenToSteps, findNestedRouteForRootId, projectNestedRegistryForRoot, type NestedRunResolutionScope } from "../shared/nested-events.ts";
|
|
15
15
|
|
|
16
16
|
interface RunStatusParams {
|
|
@@ -238,6 +238,8 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
|
|
|
238
238
|
}
|
|
239
239
|
if (fs.existsSync(logPath)) lines.push(`Log: ${logPath}`);
|
|
240
240
|
if (fs.existsSync(eventsPath)) lines.push(`Events: ${eventsPath}`);
|
|
241
|
+
const runnerLogPath = path.join(asyncDir, "runner.log");
|
|
242
|
+
if (fs.existsSync(runnerLogPath)) lines.push(`Runner log: ${runnerLogPath}`);
|
|
241
243
|
|
|
242
244
|
return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [] } };
|
|
243
245
|
}
|
|
@@ -264,6 +266,18 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
|
|
|
264
266
|
}
|
|
265
267
|
}
|
|
266
268
|
|
|
269
|
+
if (asyncDir) {
|
|
270
|
+
const log = readRunnerLogTail(asyncDir);
|
|
271
|
+
const text = log.tail
|
|
272
|
+
? `Status file not found.\nRunner log tail (${log.path}):\n${log.tail}`
|
|
273
|
+
: `Status file not found.\nRunner log (empty or missing): expected at ${log.path}`;
|
|
274
|
+
return {
|
|
275
|
+
content: [{ type: "text", text }],
|
|
276
|
+
isError: true,
|
|
277
|
+
details: { mode: "single", results: [] },
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
267
281
|
return {
|
|
268
282
|
content: [{ type: "text", text: "Status file not found." }],
|
|
269
283
|
isError: true,
|
|
@@ -164,10 +164,35 @@ function buildStartedStatus(asyncDir: string, startedRun: StartedRunMetadata, no
|
|
|
164
164
|
};
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
export function readRunnerLogTail(asyncDir: string, maxBytes = 4096, maxLines = 20): { path: string; tail?: string } {
|
|
168
|
+
const logPath = path.join(asyncDir, "runner.log");
|
|
169
|
+
try {
|
|
170
|
+
const stat = fs.statSync(logPath);
|
|
171
|
+
if (stat.size === 0) return { path: logPath };
|
|
172
|
+
const fd = fs.openSync(logPath, "r");
|
|
173
|
+
try {
|
|
174
|
+
const length = Math.min(stat.size, maxBytes);
|
|
175
|
+
const buffer = Buffer.alloc(length);
|
|
176
|
+
fs.readSync(fd, buffer, 0, length, Math.max(0, stat.size - maxBytes));
|
|
177
|
+
const lines = buffer.toString("utf-8").split("\n").filter((line) => line.trim() !== "");
|
|
178
|
+
const tail = lines.slice(-maxLines).join("\n");
|
|
179
|
+
return tail ? { path: logPath, tail } : { path: logPath };
|
|
180
|
+
} finally {
|
|
181
|
+
fs.closeSync(fd);
|
|
182
|
+
}
|
|
183
|
+
} catch {
|
|
184
|
+
return { path: logPath };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
167
188
|
function buildFailedRepair(status: AsyncStatus, asyncDir: string, now: number, reason?: string): { status: AsyncStatus; result: object; message: string } {
|
|
168
189
|
const runId = status.runId || path.basename(asyncDir);
|
|
169
190
|
const pid = typeof status.pid === "number" ? status.pid : "unknown";
|
|
170
191
|
const message = reason ?? `Async runner process ${pid} exited or disappeared before writing a result. Marked run failed by stale-run reconciliation.`;
|
|
192
|
+
const log = readRunnerLogTail(asyncDir);
|
|
193
|
+
const enrichedMessage = log.tail
|
|
194
|
+
? `${message}\nRunner log tail (${log.path}):\n${log.tail}`
|
|
195
|
+
: `${message}\nRunner log (empty or missing): expected at ${log.path}`;
|
|
171
196
|
const steps = status.steps?.length ? status.steps : [{ agent: "subagent", status: "running" as const }];
|
|
172
197
|
const repairedSteps = steps.map((step) => step.status === "running" || step.status === "pending"
|
|
173
198
|
? {
|
|
@@ -177,7 +202,7 @@ function buildFailedRepair(status: AsyncStatus, asyncDir: string, now: number, r
|
|
|
177
202
|
endedAt: step.endedAt ?? now,
|
|
178
203
|
durationMs: step.startedAt !== undefined && step.durationMs === undefined ? Math.max(0, now - step.startedAt) : step.durationMs,
|
|
179
204
|
exitCode: step.exitCode ?? 1,
|
|
180
|
-
error: step.error ??
|
|
205
|
+
error: step.error ?? enrichedMessage,
|
|
181
206
|
}
|
|
182
207
|
: step);
|
|
183
208
|
const repairedStatus: AsyncStatus = {
|
|
@@ -191,18 +216,18 @@ function buildFailedRepair(status: AsyncStatus, asyncDir: string, now: number, r
|
|
|
191
216
|
const resultAgent = repairedSteps[status.currentStep ?? 0]?.agent ?? repairedSteps[0]?.agent ?? "subagent";
|
|
192
217
|
return {
|
|
193
218
|
status: repairedStatus,
|
|
194
|
-
message,
|
|
219
|
+
message: enrichedMessage,
|
|
195
220
|
result: {
|
|
196
221
|
id: runId,
|
|
197
222
|
agent: resultAgent,
|
|
198
223
|
mode: status.mode,
|
|
199
224
|
success: false,
|
|
200
225
|
state: "failed",
|
|
201
|
-
summary:
|
|
226
|
+
summary: enrichedMessage,
|
|
202
227
|
results: repairedSteps.map((step) => ({
|
|
203
228
|
agent: step.agent,
|
|
204
|
-
output: step.status === "complete" || step.status === "completed" ? "" :
|
|
205
|
-
error: step.status === "complete" || step.status === "completed" ? undefined : step.error ??
|
|
229
|
+
output: step.status === "complete" || step.status === "completed" ? "" : enrichedMessage,
|
|
230
|
+
error: step.status === "complete" || step.status === "completed" ? undefined : step.error ?? enrichedMessage,
|
|
206
231
|
success: step.status === "complete" || step.status === "completed",
|
|
207
232
|
model: step.model,
|
|
208
233
|
attemptedModels: step.attemptedModels,
|
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
aggregateParallelOutputs,
|
|
45
45
|
MAX_PARALLEL_CONCURRENCY,
|
|
46
46
|
} from "../shared/parallel-utils.ts";
|
|
47
|
-
import { buildPiArgs, cleanupTempDir } from "../shared/pi-args.ts";
|
|
47
|
+
import { buildPiArgs, cleanupTempDir, runDirEnv } from "../shared/pi-args.ts";
|
|
48
48
|
import { outputEntryFromAsyncResult, resolveOutputReferences } from "../shared/chain-outputs.ts";
|
|
49
49
|
import { createStructuredOutputRuntime, readStructuredOutput } from "../shared/structured-output.ts";
|
|
50
50
|
import { collectDynamicResults, DynamicFanoutError, materializeDynamicParallelStep, validateDynamicCollection } from "../shared/dynamic-fanout.ts";
|
|
@@ -967,6 +967,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
|
|
|
967
967
|
const overallStartTime = Date.now();
|
|
968
968
|
const shareEnabled = config.share === true;
|
|
969
969
|
const asyncDir = config.asyncDir;
|
|
970
|
+
Object.assign(process.env, runDirEnv(asyncDir));
|
|
970
971
|
const statusPath = path.join(asyncDir, "status.json");
|
|
971
972
|
const eventsPath = path.join(asyncDir, "events.jsonl");
|
|
972
973
|
const logPath = path.join(asyncDir, `subagent-log-${id}.md`);
|
|
@@ -38,7 +38,7 @@ import { createForkContextResolver } from "../../shared/fork-context.ts";
|
|
|
38
38
|
import { resolveCurrentSessionId } from "../../shared/session-identity.ts";
|
|
39
39
|
import { applyIntercomBridgeToAgent, INTERCOM_BRIDGE_MARKER, resolveIntercomBridge, resolveIntercomSessionTarget, resolveSubagentIntercomTarget, type IntercomBridgeState } from "../../intercom/intercom-bridge.ts";
|
|
40
40
|
import { formatControlIntercomMessage, formatControlNoticeMessage, resolveControlConfig, shouldNotifyControlEvent } from "../shared/subagent-control.ts";
|
|
41
|
-
import { finalizeSingleOutput, injectSingleOutputInstruction, normalizeTopLevelOutput, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
|
|
41
|
+
import { finalizeSingleOutput, injectSingleOutputInstruction, normalizeTopLevelOutput, resolveParallelTaskOutputPath, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
|
|
42
42
|
import { compactForegroundDetails, getSingleResultOutput, mapConcurrent, readStatus, resolveChildCwd } from "../../shared/utils.ts";
|
|
43
43
|
import {
|
|
44
44
|
attachNestedChildrenToResultChildren,
|
|
@@ -1468,28 +1468,62 @@ function resolveParallelTaskCwd(
|
|
|
1468
1468
|
function buildParallelWorktreeSuffix(
|
|
1469
1469
|
worktreeSetup: WorktreeSetup | undefined,
|
|
1470
1470
|
artifactsDir: string,
|
|
1471
|
+
runId: string,
|
|
1471
1472
|
tasks: TaskParam[],
|
|
1472
1473
|
): string {
|
|
1473
1474
|
if (!worktreeSetup) return "";
|
|
1474
|
-
const diffsDir = path.join(artifactsDir, "worktree-diffs");
|
|
1475
|
+
const diffsDir = path.join(artifactsDir, runId, "worktree-diffs");
|
|
1475
1476
|
const diffs = diffWorktrees(worktreeSetup, tasks.map((task) => task.agent), diffsDir);
|
|
1476
1477
|
return formatWorktreeDiffSummary(diffs);
|
|
1477
1478
|
}
|
|
1478
1479
|
|
|
1480
|
+
function resolveParallelTaskOutput(input: {
|
|
1481
|
+
output: string | boolean | undefined;
|
|
1482
|
+
task: TaskParam;
|
|
1483
|
+
paramsCwd: string;
|
|
1484
|
+
ctxCwd: string;
|
|
1485
|
+
worktreeSetup: WorktreeSetup | undefined;
|
|
1486
|
+
artifactsDir: string;
|
|
1487
|
+
runId: string;
|
|
1488
|
+
index: number;
|
|
1489
|
+
}): { path: string | undefined } | { error: string } {
|
|
1490
|
+
const taskCwd = resolveParallelTaskCwd(input.task, input.paramsCwd, input.worktreeSetup, input.index);
|
|
1491
|
+
return resolveParallelTaskOutputPath({
|
|
1492
|
+
output: input.output,
|
|
1493
|
+
ctxCwd: input.ctxCwd,
|
|
1494
|
+
taskCwd,
|
|
1495
|
+
isolated: Boolean(input.worktreeSetup),
|
|
1496
|
+
runDir: path.join(input.artifactsDir, input.runId),
|
|
1497
|
+
index: input.index,
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1479
1501
|
function findDuplicateParallelOutputPath(input: {
|
|
1480
1502
|
tasks: TaskParam[];
|
|
1481
1503
|
behaviors: ResolvedStepBehavior[];
|
|
1482
1504
|
paramsCwd: string;
|
|
1483
1505
|
ctxCwd: string;
|
|
1484
1506
|
worktreeSetup?: WorktreeSetup;
|
|
1507
|
+
artifactsDir: string;
|
|
1508
|
+
runId: string;
|
|
1485
1509
|
}): string | undefined {
|
|
1486
1510
|
const seen = new Map<string, { index: number; agent: string }>();
|
|
1487
1511
|
for (let index = 0; index < input.tasks.length; index++) {
|
|
1488
1512
|
const behavior = input.behaviors[index];
|
|
1489
1513
|
if (!behavior?.output) continue;
|
|
1490
1514
|
const task = input.tasks[index]!;
|
|
1491
|
-
const
|
|
1492
|
-
|
|
1515
|
+
const resolved = resolveParallelTaskOutput({
|
|
1516
|
+
output: behavior.output,
|
|
1517
|
+
task,
|
|
1518
|
+
paramsCwd: input.paramsCwd,
|
|
1519
|
+
ctxCwd: input.ctxCwd,
|
|
1520
|
+
worktreeSetup: input.worktreeSetup,
|
|
1521
|
+
artifactsDir: input.artifactsDir,
|
|
1522
|
+
runId: input.runId,
|
|
1523
|
+
index,
|
|
1524
|
+
});
|
|
1525
|
+
if ("error" in resolved) return resolved.error;
|
|
1526
|
+
const outputPath = resolved.path;
|
|
1493
1527
|
if (!outputPath) continue;
|
|
1494
1528
|
const previous = seen.get(outputPath);
|
|
1495
1529
|
if (previous) {
|
|
@@ -1511,7 +1545,17 @@ async function runForegroundParallelTasks(input: ForegroundParallelRunInput): Pr
|
|
|
1511
1545
|
const progressInstructions = behavior
|
|
1512
1546
|
? buildChainInstructions({ ...behavior, output: false, reads: false }, input.paramsCwd, index === input.firstProgressIndex)
|
|
1513
1547
|
: { prefix: "", suffix: "" };
|
|
1514
|
-
const
|
|
1548
|
+
const resolvedOutput = resolveParallelTaskOutput({
|
|
1549
|
+
output: behavior?.output,
|
|
1550
|
+
task,
|
|
1551
|
+
paramsCwd: input.paramsCwd,
|
|
1552
|
+
ctxCwd: input.ctx.cwd,
|
|
1553
|
+
worktreeSetup: input.worktreeSetup,
|
|
1554
|
+
artifactsDir: input.artifactsDir,
|
|
1555
|
+
runId: input.runId,
|
|
1556
|
+
index,
|
|
1557
|
+
});
|
|
1558
|
+
const outputPath = "error" in resolvedOutput ? undefined : resolvedOutput.path;
|
|
1515
1559
|
const taskText = injectSingleOutputInstruction(
|
|
1516
1560
|
`${readInstructions.prefix}${input.taskTexts[index]!}${progressInstructions.suffix}`,
|
|
1517
1561
|
outputPath,
|
|
@@ -1799,12 +1843,23 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
|
|
|
1799
1843
|
paramsCwd: effectiveCwd,
|
|
1800
1844
|
ctxCwd: ctx.cwd,
|
|
1801
1845
|
worktreeSetup,
|
|
1846
|
+
artifactsDir,
|
|
1847
|
+
runId,
|
|
1802
1848
|
});
|
|
1803
1849
|
if (duplicateOutputError) return buildParallelModeError(duplicateOutputError);
|
|
1804
1850
|
for (let index = 0; index < tasks.length; index++) {
|
|
1805
|
-
const
|
|
1806
|
-
|
|
1807
|
-
|
|
1851
|
+
const resolved = resolveParallelTaskOutput({
|
|
1852
|
+
output: behaviors[index]?.output,
|
|
1853
|
+
task: tasks[index]!,
|
|
1854
|
+
paramsCwd: effectiveCwd,
|
|
1855
|
+
ctxCwd: ctx.cwd,
|
|
1856
|
+
worktreeSetup,
|
|
1857
|
+
artifactsDir,
|
|
1858
|
+
runId,
|
|
1859
|
+
index,
|
|
1860
|
+
});
|
|
1861
|
+
if ("error" in resolved) return buildParallelModeError(resolved.error);
|
|
1862
|
+
const validationError = validateFileOnlyOutputMode(behaviors[index]?.outputMode, resolved.path, `Parallel task ${index + 1} (${tasks[index]!.agent})`);
|
|
1808
1863
|
if (validationError) return buildParallelModeError(validationError);
|
|
1809
1864
|
}
|
|
1810
1865
|
|
|
@@ -1871,6 +1926,9 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
|
|
|
1871
1926
|
if (result.artifactPaths) allArtifactPaths.push(result.artifactPaths);
|
|
1872
1927
|
}
|
|
1873
1928
|
|
|
1929
|
+
const worktreeSuffix = buildParallelWorktreeSuffix(worktreeSetup, artifactsDir, runId, tasks);
|
|
1930
|
+
const withWorktreeSuffix = (text: string): string => (worktreeSuffix ? `${text}\n\n${worktreeSuffix}` : text);
|
|
1931
|
+
|
|
1874
1932
|
const interrupted = results.find((result) => result.interrupted);
|
|
1875
1933
|
const details = compactForegroundDetails({
|
|
1876
1934
|
mode: "parallel",
|
|
@@ -1882,7 +1940,7 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
|
|
|
1882
1940
|
rememberForegroundRun(deps.state, { runId, mode: "parallel", cwd: effectiveCwd, results: details.results });
|
|
1883
1941
|
if (interrupted) {
|
|
1884
1942
|
return {
|
|
1885
|
-
content: [{ type: "text", text: `Parallel run paused after interrupt (${interrupted.agent}). Waiting for explicit next action.` }],
|
|
1943
|
+
content: [{ type: "text", text: withWorktreeSuffix(`Parallel run paused after interrupt (${interrupted.agent}). Waiting for explicit next action.`) }],
|
|
1886
1944
|
details,
|
|
1887
1945
|
};
|
|
1888
1946
|
}
|
|
@@ -1890,7 +1948,7 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
|
|
|
1890
1948
|
const detached = detachedIndex >= 0 ? results[detachedIndex] : undefined;
|
|
1891
1949
|
if (detached) {
|
|
1892
1950
|
return {
|
|
1893
|
-
content: [{ type: "text", text: `Parallel run detached for intercom coordination (${detached.agent}). Reply to the supervisor request first. After the child exits, start a fresh follow-up if needed.` }],
|
|
1951
|
+
content: [{ type: "text", text: withWorktreeSuffix(`Parallel run detached for intercom coordination (${detached.agent}). Reply to the supervisor request first. After the child exits, start a fresh follow-up if needed.`) }],
|
|
1894
1952
|
details,
|
|
1895
1953
|
};
|
|
1896
1954
|
}
|
|
@@ -1906,12 +1964,11 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
|
|
|
1906
1964
|
});
|
|
1907
1965
|
if (intercomReceipt) {
|
|
1908
1966
|
return {
|
|
1909
|
-
content: [{ type: "text", text: intercomReceipt.text }],
|
|
1967
|
+
content: [{ type: "text", text: withWorktreeSuffix(intercomReceipt.text) }],
|
|
1910
1968
|
details: intercomReceipt.details,
|
|
1911
1969
|
};
|
|
1912
1970
|
}
|
|
1913
1971
|
|
|
1914
|
-
const worktreeSuffix = buildParallelWorktreeSuffix(worktreeSetup, artifactsDir, tasks);
|
|
1915
1972
|
const ok = results.filter((result) => result.exitCode === 0).length;
|
|
1916
1973
|
const downgradeNote = backgroundRequestedWhileClarifying ? " (background requested, but clarify kept this run foreground)" : "";
|
|
1917
1974
|
const aggregatedOutput = aggregateParallelOutputs(
|
|
@@ -1925,9 +1982,7 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
|
|
|
1925
1982
|
);
|
|
1926
1983
|
|
|
1927
1984
|
const summary = `${ok}/${results.length} succeeded${downgradeNote}`;
|
|
1928
|
-
const fullContent =
|
|
1929
|
-
? `${summary}\n\n${aggregatedOutput}\n\n${worktreeSuffix}`
|
|
1930
|
-
: `${summary}\n\n${aggregatedOutput}`;
|
|
1985
|
+
const fullContent = withWorktreeSuffix(`${summary}\n\n${aggregatedOutput}`);
|
|
1931
1986
|
|
|
1932
1987
|
return {
|
|
1933
1988
|
content: [{ type: "text", text: fullContent }],
|
|
@@ -13,6 +13,7 @@ const FANOUT_CHILD_EXTENSION_PATH = path.join(path.dirname(fileURLToPath(import.
|
|
|
13
13
|
export const SUBAGENT_CHILD_ENV = "PI_SUBAGENT_CHILD";
|
|
14
14
|
export const SUBAGENT_ORCHESTRATOR_TARGET_ENV = "PI_SUBAGENT_ORCHESTRATOR_TARGET";
|
|
15
15
|
export const SUBAGENT_RUN_ID_ENV = "PI_SUBAGENT_RUN_ID";
|
|
16
|
+
export const SUBAGENT_RUN_DIR_ENV = "PI_SUBAGENT_RUN_DIR";
|
|
16
17
|
export const SUBAGENT_CHILD_AGENT_ENV = "PI_SUBAGENT_CHILD_AGENT";
|
|
17
18
|
export const SUBAGENT_CHILD_INDEX_ENV = "PI_SUBAGENT_CHILD_INDEX";
|
|
18
19
|
export const SUBAGENT_FANOUT_CHILD_ENV = "PI_SUBAGENT_FANOUT_CHILD";
|
|
@@ -68,6 +69,10 @@ interface BuildPiArgsResult {
|
|
|
68
69
|
tempDir?: string;
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
export function runDirEnv(asyncDir: string): Record<string, string> {
|
|
73
|
+
return { [SUBAGENT_RUN_DIR_ENV]: asyncDir };
|
|
74
|
+
}
|
|
75
|
+
|
|
71
76
|
export function applyThinkingSuffix(model: string | undefined, thinking: string | undefined): string | undefined {
|
|
72
77
|
if (!model || !thinking || thinking === "off") return model;
|
|
73
78
|
const colonIdx = model.lastIndexOf(":");
|
|
@@ -40,6 +40,37 @@ export function resolveSingleOutputPath(
|
|
|
40
40
|
return path.resolve(baseCwd, output);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
export interface ParallelTaskOutputInput {
|
|
44
|
+
output: string | boolean | undefined;
|
|
45
|
+
ctxCwd: string;
|
|
46
|
+
taskCwd?: string;
|
|
47
|
+
isolated: boolean;
|
|
48
|
+
runDir?: string;
|
|
49
|
+
index: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type ParallelTaskOutputResult = { path: string | undefined } | { error: string };
|
|
53
|
+
|
|
54
|
+
export function resolveParallelTaskOutputPath(input: ParallelTaskOutputInput): ParallelTaskOutputResult {
|
|
55
|
+
const { output, ctxCwd, taskCwd, isolated, runDir, index } = input;
|
|
56
|
+
const redirectable = isolated
|
|
57
|
+
&& Boolean(runDir)
|
|
58
|
+
&& typeof output === "string"
|
|
59
|
+
&& output.length > 0
|
|
60
|
+
&& output !== "false"
|
|
61
|
+
&& output !== "true"
|
|
62
|
+
&& !path.isAbsolute(output);
|
|
63
|
+
if (!redirectable) return { path: resolveSingleOutputPath(output, ctxCwd, taskCwd) };
|
|
64
|
+
|
|
65
|
+
const leaf = path.join(runDir!, `task-${index}`);
|
|
66
|
+
const resolved = path.resolve(leaf, output as string);
|
|
67
|
+
const relative = path.relative(leaf, resolved);
|
|
68
|
+
if (relative === "" || relative.split(path.sep)[0] === ".." || path.isAbsolute(relative)) {
|
|
69
|
+
return { error: `Parallel task ${index + 1} output '${output}' resolves outside its per-task directory (${resolved}). Use a path inside ${leaf}, or an absolute path.` };
|
|
70
|
+
}
|
|
71
|
+
return { path: resolved };
|
|
72
|
+
}
|
|
73
|
+
|
|
43
74
|
export function injectSingleOutputInstruction(task: string, outputPath: string | undefined): string {
|
|
44
75
|
if (!outputPath) return task;
|
|
45
76
|
return `${task}\n\n---\n**Output:** Write your findings to: ${outputPath}`;
|
|
@@ -27,6 +27,7 @@ interface WorktreeDiff {
|
|
|
27
27
|
insertions: number;
|
|
28
28
|
deletions: number;
|
|
29
29
|
patchPath: string;
|
|
30
|
+
captureError?: string;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
interface WorktreeTaskCwdConflict {
|
|
@@ -523,24 +524,28 @@ export function createWorktrees(cwd: string, runId: string, count: number, optio
|
|
|
523
524
|
}
|
|
524
525
|
|
|
525
526
|
export function diffWorktrees(setup: WorktreeSetup, agents: string[], diffsDir: string): WorktreeDiff[] {
|
|
527
|
+
const agentFor = (index: number): string => agents[index] ?? `task-${index + 1}`;
|
|
526
528
|
try {
|
|
527
529
|
fs.mkdirSync(diffsDir, { recursive: true });
|
|
528
|
-
} catch {
|
|
529
|
-
|
|
530
|
-
return
|
|
530
|
+
} catch (error) {
|
|
531
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
532
|
+
return setup.worktrees.map((worktree, index) => ({
|
|
533
|
+
...emptyDiff(index, agentFor(index), worktree.branch, ""),
|
|
534
|
+
captureError: `could not create patch directory '${diffsDir}': ${message}`,
|
|
535
|
+
}));
|
|
531
536
|
}
|
|
532
537
|
|
|
533
538
|
const diffs: WorktreeDiff[] = [];
|
|
534
539
|
for (let index = 0; index < setup.worktrees.length; index++) {
|
|
535
540
|
const worktree = setup.worktrees[index]!;
|
|
536
|
-
const agent =
|
|
541
|
+
const agent = agentFor(index);
|
|
537
542
|
const patchPath = path.join(diffsDir, `task-${index}-${safePatchAgentName(agent)}.patch`);
|
|
538
543
|
try {
|
|
539
544
|
diffs.push(captureWorktreeDiff(setup, worktree, agent, patchPath));
|
|
540
|
-
} catch {
|
|
541
|
-
|
|
545
|
+
} catch (error) {
|
|
546
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
542
547
|
writeEmptyPatch(patchPath);
|
|
543
|
-
diffs.push(emptyDiff(index, agent, worktree.branch, patchPath));
|
|
548
|
+
diffs.push({ ...emptyDiff(index, agent, worktree.branch, patchPath), captureError: message });
|
|
544
549
|
}
|
|
545
550
|
}
|
|
546
551
|
|
|
@@ -558,7 +563,8 @@ export function cleanupWorktrees(setup: WorktreeSetup): void {
|
|
|
558
563
|
|
|
559
564
|
export function formatWorktreeDiffSummary(diffs: WorktreeDiff[]): string {
|
|
560
565
|
const changed = diffs.filter(hasWorktreeChanges);
|
|
561
|
-
|
|
566
|
+
const failed = diffs.filter((diff) => diff.captureError);
|
|
567
|
+
if (changed.length === 0 && failed.length === 0) return "";
|
|
562
568
|
|
|
563
569
|
const lines: string[] = ["=== Worktree Changes ===", ""];
|
|
564
570
|
for (const diff of changed) {
|
|
@@ -570,8 +576,11 @@ export function formatWorktreeDiffSummary(diffs: WorktreeDiff[]): string {
|
|
|
570
576
|
}
|
|
571
577
|
lines.push("");
|
|
572
578
|
}
|
|
579
|
+
for (const diff of failed) {
|
|
580
|
+
lines.push(`--- Task ${diff.index + 1} (${diff.agent}): patch capture FAILED: ${diff.captureError} ---`, "");
|
|
581
|
+
}
|
|
573
582
|
|
|
574
|
-
const
|
|
575
|
-
lines.push(`Full patches: ${
|
|
583
|
+
const withPatch = changed.find((diff) => diff.patchPath.length > 0);
|
|
584
|
+
if (withPatch) lines.push(`Full patches: ${path.dirname(withPatch.patchPath)}`);
|
|
576
585
|
return lines.join("\n").trimEnd();
|
|
577
586
|
}
|
package/src/shared/artifacts.ts
CHANGED
|
@@ -60,7 +60,8 @@ export function cleanupOldArtifacts(dir: string, maxAgeDays: number): void {
|
|
|
60
60
|
try {
|
|
61
61
|
const stat = fs.statSync(filePath);
|
|
62
62
|
if (stat.mtimeMs < cutoff) {
|
|
63
|
-
fs.
|
|
63
|
+
if (stat.isDirectory()) fs.rmSync(filePath, { recursive: true, force: true });
|
|
64
|
+
else fs.unlinkSync(filePath);
|
|
64
65
|
}
|
|
65
66
|
} catch {
|
|
66
67
|
// Artifact cleanup is best-effort housekeeping. Skip files that disappear
|