pi-cohort 5.1.2 → 5.2.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 +27 -0
- package/README.md +5 -1
- package/agents/monitor.md +26 -0
- package/package.json +3 -3
- package/skills/pi-cohort/SKILL.md +13 -0
- package/src/extension/control-notices.ts +5 -3
- package/src/extension/index.ts +7 -4
- package/src/extension/schemas.ts +0 -3
- package/src/runs/background/async-execution.ts +76 -19
- 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/shared/pi-args.ts +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [5.2.0] - 2026-09-01
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `runner.log` diagnostics for the detached async runner: captures the
|
|
8
|
+
runner's stdout/stderr, spawn-time jiti revalidation, and enriched failure
|
|
9
|
+
messages that append the log's tail; the run dir is also exported to the
|
|
10
|
+
child as `PI_SUBAGENT_RUN_DIR` and echoed on an `Async dir:` start line.
|
|
11
|
+
- Builtin `monitor` persona: watches an already-started job (async run dir,
|
|
12
|
+
PID, log, or probe command), reports progress deltas on a cadence, and
|
|
13
|
+
flags stalls, plus SKILL.md guidance to pair it with long-running async
|
|
14
|
+
jobs.
|
|
15
|
+
|
|
16
|
+
## [5.1.3] - 2026-08-18
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- Foreground control notices are steered to a turn boundary while the session
|
|
21
|
+
is streaming instead of splicing into an open tool cycle (which corrupted
|
|
22
|
+
the session with a duplicated `tool_use_id`); idle delivery still appends
|
|
23
|
+
without triggering a turn. (#7)
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- `dir` no longer advertised by the subagent tool schema (follows `runId`,
|
|
28
|
+
#5) - still accepted at runtime for async status/resume, `id` preferred.
|
|
29
|
+
|
|
3
30
|
## [5.1.2] - 2026-08-07
|
|
4
31
|
|
|
5
32
|
### Changed
|
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.2.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",
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
"scripts": {
|
|
37
37
|
"check:agents-core": "node scripts/check-agents-core.mjs",
|
|
38
38
|
"test": "npm run test:unit",
|
|
39
|
-
"test:unit": "node --
|
|
40
|
-
"test:integration": "node --
|
|
39
|
+
"test:unit": "node --test test/unit/*.test.ts",
|
|
40
|
+
"test:integration": "node --import ./test/support/register-loader.mjs --test test/integration/*.test.ts",
|
|
41
41
|
"test:all": "npm run check:agents-core && npm run test:unit && npm run test:integration"
|
|
42
42
|
},
|
|
43
43
|
"pi": {
|
|
@@ -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.
|
|
@@ -39,6 +39,7 @@ function deliverControlNotice(input: {
|
|
|
39
39
|
pi: Pick<ExtensionAPI, "sendMessage">;
|
|
40
40
|
visibleControlNotices: Set<string>;
|
|
41
41
|
details: SubagentControlMessageDetails;
|
|
42
|
+
isIdle: () => boolean;
|
|
42
43
|
}): void {
|
|
43
44
|
const childIntercomTarget = controlNoticeTarget(input.details);
|
|
44
45
|
const key = controlNotificationKey(input.details.event, childIntercomTarget);
|
|
@@ -52,7 +53,7 @@ function deliverControlNotice(input: {
|
|
|
52
53
|
display: true,
|
|
53
54
|
details: { ...input.details, childIntercomTarget, noticeText },
|
|
54
55
|
},
|
|
55
|
-
{ triggerTurn: input.details.source !== "foreground" },
|
|
56
|
+
{ triggerTurn: input.details.source !== "foreground" || !input.isIdle() },
|
|
56
57
|
);
|
|
57
58
|
}
|
|
58
59
|
|
|
@@ -72,8 +73,9 @@ export function handleSubagentControlNotice(input: {
|
|
|
72
73
|
foregroundDelayMs?: number;
|
|
73
74
|
}): void {
|
|
74
75
|
if (!input.details?.event || input.details.event.type === "active_long_running") return;
|
|
76
|
+
const isIdle = () => input.state.lastUiContext?.isIdle() ?? false;
|
|
75
77
|
if (input.details.source !== "foreground") {
|
|
76
|
-
deliverControlNotice(input);
|
|
78
|
+
deliverControlNotice({ ...input, isIdle });
|
|
77
79
|
return;
|
|
78
80
|
}
|
|
79
81
|
|
|
@@ -85,7 +87,7 @@ export function handleSubagentControlNotice(input: {
|
|
|
85
87
|
const timer = setTimeout(() => {
|
|
86
88
|
pending.delete(timerKey);
|
|
87
89
|
if (!isForegroundNoticeStillActionable(input.state, input.details)) return;
|
|
88
|
-
deliverControlNotice(input);
|
|
90
|
+
deliverControlNotice({ ...input, isIdle });
|
|
89
91
|
}, input.foregroundDelayMs ?? 1000);
|
|
90
92
|
timer.unref?.();
|
|
91
93
|
pending.set(timerKey, timer);
|
package/src/extension/index.ts
CHANGED
|
@@ -183,10 +183,13 @@ function parseSubagentNotifyContent(content: string): SubagentNotifyDetails | un
|
|
|
183
183
|
}
|
|
184
184
|
|
|
185
185
|
class SubagentControlNoticeComponent implements Component {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
) {
|
|
186
|
+
private readonly details: SubagentControlMessageDetails;
|
|
187
|
+
private readonly theme: ExtensionContext["ui"]["theme"];
|
|
188
|
+
|
|
189
|
+
constructor(details: SubagentControlMessageDetails, theme: ExtensionContext["ui"]["theme"]) {
|
|
190
|
+
this.details = details;
|
|
191
|
+
this.theme = theme;
|
|
192
|
+
}
|
|
190
193
|
|
|
191
194
|
invalidate(): void {}
|
|
192
195
|
|
package/src/extension/schemas.ts
CHANGED
|
@@ -259,9 +259,6 @@ export const SubagentParams = Type.Object({
|
|
|
259
259
|
id: Type.Optional(Type.String({
|
|
260
260
|
description: "Run id/prefix for status/interrupt/resume."
|
|
261
261
|
})),
|
|
262
|
-
dir: Type.Optional(Type.String({
|
|
263
|
-
description: "Async run dir for status/resume."
|
|
264
|
-
})),
|
|
265
262
|
index: Type.Optional(Type.Integer({ minimum: 0, description: "Zero-based index for per-child actions." })),
|
|
266
263
|
message: Type.Optional(Type.String({ description: "Follow-up message for resume." })),
|
|
267
264
|
// Chain identifier for management (can't reuse 'chain' — that's the execution array)
|
|
@@ -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 {
|
|
@@ -503,6 +558,7 @@ export function executeAsyncChain(
|
|
|
503
558
|
},
|
|
504
559
|
id,
|
|
505
560
|
runnerCwd,
|
|
561
|
+
asyncDir,
|
|
506
562
|
);
|
|
507
563
|
} catch (error) {
|
|
508
564
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -603,7 +659,7 @@ export function executeAsyncChain(
|
|
|
603
659
|
.join(" -> ");
|
|
604
660
|
|
|
605
661
|
return {
|
|
606
|
-
content: [{ type: "text", text: formatAsyncStartedMessage(`Async ${resultMode}: ${chainDesc}
|
|
662
|
+
content: [{ type: "text", text: formatAsyncStartedMessage(asyncStartHeadline(`Async ${resultMode}: ${chainDesc}`, id, asyncDir)) }],
|
|
607
663
|
details: { mode: resultMode, runId: id, results: [], asyncId: id, asyncDir, workflowGraph },
|
|
608
664
|
};
|
|
609
665
|
}
|
|
@@ -738,6 +794,7 @@ export function executeAsyncSingle(
|
|
|
738
794
|
},
|
|
739
795
|
id,
|
|
740
796
|
runnerCwd,
|
|
797
|
+
asyncDir,
|
|
741
798
|
);
|
|
742
799
|
} catch (error) {
|
|
743
800
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -796,7 +853,7 @@ export function executeAsyncSingle(
|
|
|
796
853
|
}
|
|
797
854
|
|
|
798
855
|
return {
|
|
799
|
-
content: [{ type: "text", text: formatAsyncStartedMessage(`Async: ${agent}
|
|
856
|
+
content: [{ type: "text", text: formatAsyncStartedMessage(asyncStartHeadline(`Async: ${agent}`, id, asyncDir)) }],
|
|
800
857
|
details: { mode: "single", runId: id, results: [], asyncId: id, asyncDir },
|
|
801
858
|
};
|
|
802
859
|
}
|
|
@@ -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`);
|
|
@@ -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(":");
|