pi-legion 2.0.3 → 3.0.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 +93 -1
- package/CONTRIBUTING.md +27 -24
- package/README.md +103 -19
- package/docs/contracts/osc-busy-plugin.md +72 -0
- package/docs/contracts/spawn-default-inprocess-v3.md +322 -0
- package/docs/contracts/wait-not-stop.md +30 -0
- package/docs/design.md +2 -2
- package/docs/headless-child-contract.md +83 -0
- package/docs/inprocess-supervisor.md +140 -0
- package/docs/qa-hunting-round.md +150 -0
- package/docs/subagents-teams.md +55 -18
- package/docs/tui-qa-playbook.md +284 -0
- package/index.ts +396 -62
- package/package.json +15 -8
- package/skills/pi-legion/SKILL.md +5 -0
- package/src/agents/agents.ts +34 -6
- package/src/agents/context.ts +83 -1
- package/src/agents/overrides.ts +1 -0
- package/src/agents/settings.ts +122 -1
- package/src/api.ts +23 -0
- package/src/extension/child-guard.ts +42 -3
- package/src/extension/legion-commands.ts +15 -3
- package/src/extension/legion-doctor.ts +55 -0
- package/src/extension/legion-stop.ts +6 -2
- package/src/extension/playbook.ts +3 -3
- package/src/extension/runtime.ts +57 -8
- package/src/extension/scout-guard.ts +39 -1
- package/src/extension/session-errors.ts +191 -0
- package/src/extension/timing.ts +391 -0
- package/src/runs/args.ts +8 -0
- package/src/runs/orchestrator.ts +125 -16
- package/src/shared/output-cap.ts +2 -2
- package/src/shared/session.ts +2 -1
- package/src/shared/timing-breakdown.ts +23 -0
- package/src/shared/types.ts +74 -0
- package/src/supervisor/child-context.ts +90 -0
- package/src/supervisor/child-memory.ts +27 -3
- package/src/supervisor/inprocess-depth.ts +71 -0
- package/src/supervisor/inprocess-supervisor.ts +1011 -0
- package/src/supervisor/routing-supervisor.ts +179 -0
- package/src/supervisor/rpc-supervisor.ts +14 -0
- package/src/supervisor/spawn-mode.ts +35 -0
- package/src/supervisor/types.ts +13 -0
- package/src/supervisor/ui-proxy.ts +78 -0
- package/src/tui/live-overlay.ts +297 -24
- package/src/tui/status.ts +71 -35
- package/src/tui/viz/layout.ts +50 -6
- package/src/tui/viz/model.ts +6 -0
- package/src/tui/viz/panels.ts +164 -12
- package/src/tui/viz/railway.ts +154 -21
- package/src/tui/viz/rate.ts +36 -14
- package/src/tui/viz/view.ts +69 -10
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent timing slash command (`/legion timing`).
|
|
3
|
+
*
|
|
4
|
+
* Reuses summary.ts duration resolution + session parse (via aggregateSubagentRuns
|
|
5
|
+
* / parseSessionFile). Zero schema change — wall from spawnedAt/retiredAt|now,
|
|
6
|
+
* generation from ParsedSession.generationMs, other = max(0, wall − gen).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import type {
|
|
11
|
+
ExtensionAPI,
|
|
12
|
+
ExtensionCommandContext,
|
|
13
|
+
} from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { parseSessionFile } from "../shared/session.ts";
|
|
15
|
+
import type { ChildRecord, RunRecord, TaskState } from "../shared/types.ts";
|
|
16
|
+
import {
|
|
17
|
+
RunStore,
|
|
18
|
+
pickChildByName,
|
|
19
|
+
} from "../runs/store.ts";
|
|
20
|
+
import { sendSlashText, type LegionSubcommand } from "./slash.ts";
|
|
21
|
+
import {
|
|
22
|
+
aggregateSubagentRuns,
|
|
23
|
+
formatDuration,
|
|
24
|
+
type SummaryChild,
|
|
25
|
+
type SummarySession,
|
|
26
|
+
type SubagentSummary,
|
|
27
|
+
} from "./summary.ts";
|
|
28
|
+
import { breakdown } from "../shared/timing-breakdown.ts";
|
|
29
|
+
|
|
30
|
+
export { breakdown } from "../shared/timing-breakdown.ts";
|
|
31
|
+
|
|
32
|
+
const TIMING_USAGE = "Usage: /legion timing [--json] [<name>]";
|
|
33
|
+
|
|
34
|
+
export interface TimingChild {
|
|
35
|
+
name: string;
|
|
36
|
+
role: string;
|
|
37
|
+
state: TaskState;
|
|
38
|
+
/** Still in a running lifecycle state (working/blocked/awaiting). */
|
|
39
|
+
live: boolean;
|
|
40
|
+
wall_ms: number;
|
|
41
|
+
/** Session-derived model generation wall; null when no turn data. */
|
|
42
|
+
generation_ms: number | null;
|
|
43
|
+
/** max(0, wall − gen); null when generation_ms unavailable. Coarse bucket. */
|
|
44
|
+
other_ms: number | null;
|
|
45
|
+
turns: number | null;
|
|
46
|
+
startedAtMs: number | null;
|
|
47
|
+
endedAtMs: number | null;
|
|
48
|
+
/** True when wall_ms tracks the live clock (retiredAt absent + running). */
|
|
49
|
+
liveWall: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface TimingReport {
|
|
53
|
+
children: TimingChild[];
|
|
54
|
+
/** Earliest spawn → latest end across children with known endpoints. */
|
|
55
|
+
span_ms: number | null;
|
|
56
|
+
sum_wall_ms: number;
|
|
57
|
+
cwd?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface TimingFormatOptions {
|
|
61
|
+
cwd?: string;
|
|
62
|
+
json?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function sessionHasTurns(
|
|
66
|
+
session: SummarySession | undefined,
|
|
67
|
+
): session is SummarySession {
|
|
68
|
+
return Boolean(session && session.turns.length > 0);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Map one SummaryChild (already duration-resolved) into a timing row. */
|
|
72
|
+
export function timingChildFromSummary(child: SummaryChild): TimingChild {
|
|
73
|
+
const generation_ms = sessionHasTurns(child.session)
|
|
74
|
+
? child.session.generationMs
|
|
75
|
+
: null;
|
|
76
|
+
const parts = breakdown(child.durationMs, generation_ms);
|
|
77
|
+
return {
|
|
78
|
+
name: child.child.name,
|
|
79
|
+
role: child.role,
|
|
80
|
+
state: child.child.state,
|
|
81
|
+
live: child.running,
|
|
82
|
+
wall_ms: parts.wall_ms,
|
|
83
|
+
generation_ms: parts.generation_ms,
|
|
84
|
+
other_ms: parts.other_ms,
|
|
85
|
+
turns: child.turns,
|
|
86
|
+
startedAtMs: child.startedAtMs,
|
|
87
|
+
endedAtMs: child.endedAtMs,
|
|
88
|
+
liveWall: child.liveDuration,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build a timing report from the same aggregation path as `/legion summary`.
|
|
94
|
+
* Children are sorted by startedAt (unknowns last), then name.
|
|
95
|
+
*/
|
|
96
|
+
export function buildTimingReport(
|
|
97
|
+
summary: SubagentSummary,
|
|
98
|
+
options: { cwd?: string } = {},
|
|
99
|
+
): TimingReport {
|
|
100
|
+
const children: TimingChild[] = [];
|
|
101
|
+
for (const group of summary.groups) {
|
|
102
|
+
for (const child of group.children) {
|
|
103
|
+
children.push(timingChildFromSummary(child));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
children.sort((a, b) => {
|
|
107
|
+
if (a.startedAtMs === null && b.startedAtMs === null) {
|
|
108
|
+
return a.name.localeCompare(b.name);
|
|
109
|
+
}
|
|
110
|
+
if (a.startedAtMs === null) return 1;
|
|
111
|
+
if (b.startedAtMs === null) return -1;
|
|
112
|
+
return a.startedAtMs - b.startedAtMs || a.name.localeCompare(b.name);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const started = children
|
|
116
|
+
.map((c) => c.startedAtMs)
|
|
117
|
+
.filter((v): v is number => v !== null);
|
|
118
|
+
const ended = children
|
|
119
|
+
.map((c) => c.endedAtMs)
|
|
120
|
+
.filter((v): v is number => v !== null);
|
|
121
|
+
const span_ms =
|
|
122
|
+
started.length > 0 && ended.length > 0
|
|
123
|
+
? Math.max(0, Math.max(...ended) - Math.min(...started))
|
|
124
|
+
: null;
|
|
125
|
+
const sum_wall_ms = children.reduce((sum, c) => sum + c.wall_ms, 0);
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
children,
|
|
129
|
+
span_ms,
|
|
130
|
+
sum_wall_ms,
|
|
131
|
+
...(options.cwd
|
|
132
|
+
? { cwd: options.cwd }
|
|
133
|
+
: summary.cwd
|
|
134
|
+
? { cwd: summary.cwd }
|
|
135
|
+
: {}),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function formatMsCell(
|
|
140
|
+
ms: number | null,
|
|
141
|
+
opts: { live?: boolean } = {},
|
|
142
|
+
): string {
|
|
143
|
+
if (ms === null) return "—";
|
|
144
|
+
const rendered = formatDuration(ms);
|
|
145
|
+
return opts.live ? `${rendered}+` : rendered;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function formatOptionalMs(ms: number | null): string {
|
|
149
|
+
return ms === null ? "—" : formatDuration(ms);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function formatTurns(turns: number | null): string {
|
|
153
|
+
return turns === null ? "—" : String(turns);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Human table for all children + optional concurrency footer. */
|
|
157
|
+
export function formatTimingTable(
|
|
158
|
+
report: TimingReport,
|
|
159
|
+
options: TimingFormatOptions = {},
|
|
160
|
+
): string {
|
|
161
|
+
if (report.children.length === 0) {
|
|
162
|
+
const cwd = options.cwd ?? report.cwd ?? process.cwd();
|
|
163
|
+
return `No subagent runs under ${cwd}.\nLaunch children with the subagent tool to see timing here.`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const lines = [
|
|
167
|
+
`Subagent timing — ${report.children.length} children`,
|
|
168
|
+
"",
|
|
169
|
+
"| name | state | wall | gen | other | turns |",
|
|
170
|
+
"|---|---|---|---|---|---|",
|
|
171
|
+
];
|
|
172
|
+
for (const child of report.children) {
|
|
173
|
+
const wall =
|
|
174
|
+
child.startedAtMs === null
|
|
175
|
+
? "—"
|
|
176
|
+
: formatMsCell(child.wall_ms, { live: child.liveWall });
|
|
177
|
+
lines.push(
|
|
178
|
+
`| ${child.name} | ${child.state} | ${wall} | ${formatOptionalMs(child.generation_ms)} | ${formatOptionalMs(child.other_ms)} | ${formatTurns(child.turns)} |`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (report.span_ms !== null && report.children.length > 0) {
|
|
183
|
+
const ratio =
|
|
184
|
+
report.span_ms > 0
|
|
185
|
+
? (report.sum_wall_ms / report.span_ms).toFixed(2)
|
|
186
|
+
: "—";
|
|
187
|
+
lines.push(
|
|
188
|
+
"",
|
|
189
|
+
`span ${formatDuration(report.span_ms)} · sum wall ${formatDuration(report.sum_wall_ms)} · concurrency ~${ratio}×`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return lines.join("\n");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Single-child detail (same口径 as summary detail, timing-focused). */
|
|
196
|
+
export function formatTimingDetail(child: TimingChild, run: RunRecord): string {
|
|
197
|
+
const title = `${child.name} — ${child.role}`;
|
|
198
|
+
const lines = [
|
|
199
|
+
title,
|
|
200
|
+
` state: ${child.state}${child.live ? " (live)" : ""}`,
|
|
201
|
+
` run: ${run.runId}`,
|
|
202
|
+
];
|
|
203
|
+
const wall =
|
|
204
|
+
child.startedAtMs === null
|
|
205
|
+
? "—"
|
|
206
|
+
: formatMsCell(child.wall_ms, { live: child.liveWall });
|
|
207
|
+
lines.push(` wall: ${wall}`);
|
|
208
|
+
lines.push(` generation: ${formatOptionalMs(child.generation_ms)}`);
|
|
209
|
+
lines.push(
|
|
210
|
+
` other: ${formatOptionalMs(child.other_ms)} (wall − gen; not precise wait/tool)`,
|
|
211
|
+
);
|
|
212
|
+
lines.push(` turns: ${formatTurns(child.turns)}`);
|
|
213
|
+
return lines.join("\n");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function childToJson(child: TimingChild): Record<string, unknown> {
|
|
217
|
+
return {
|
|
218
|
+
name: child.name,
|
|
219
|
+
role: child.role,
|
|
220
|
+
state: child.state,
|
|
221
|
+
live: child.live,
|
|
222
|
+
wall_ms: child.wall_ms,
|
|
223
|
+
generation_ms: child.generation_ms,
|
|
224
|
+
other_ms: child.other_ms,
|
|
225
|
+
turns: child.turns,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function formatTimingJson(
|
|
230
|
+
report: TimingReport,
|
|
231
|
+
detail?: { child: TimingChild; run: RunRecord },
|
|
232
|
+
): string {
|
|
233
|
+
if (detail) {
|
|
234
|
+
return JSON.stringify(
|
|
235
|
+
{
|
|
236
|
+
...childToJson(detail.child),
|
|
237
|
+
runId: detail.run.runId,
|
|
238
|
+
},
|
|
239
|
+
null,
|
|
240
|
+
2,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
return JSON.stringify(
|
|
244
|
+
{
|
|
245
|
+
children: report.children.map(childToJson),
|
|
246
|
+
span_ms: report.span_ms,
|
|
247
|
+
sum_wall_ms: report.sum_wall_ms,
|
|
248
|
+
...(report.span_ms !== null && report.span_ms > 0
|
|
249
|
+
? {
|
|
250
|
+
concurrency_ratio: Number(
|
|
251
|
+
(report.sum_wall_ms / report.span_ms).toFixed(4),
|
|
252
|
+
),
|
|
253
|
+
}
|
|
254
|
+
: {}),
|
|
255
|
+
},
|
|
256
|
+
null,
|
|
257
|
+
2,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function parseTimingArgs(
|
|
262
|
+
args: string,
|
|
263
|
+
):
|
|
264
|
+
| { ok: true; json: boolean; name?: string }
|
|
265
|
+
| { ok: false; message: string } {
|
|
266
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
267
|
+
let json = false;
|
|
268
|
+
let name: string | undefined;
|
|
269
|
+
for (const token of tokens) {
|
|
270
|
+
if (token === "--json") {
|
|
271
|
+
if (json) return { ok: false, message: TIMING_USAGE };
|
|
272
|
+
json = true;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (token.startsWith("-")) return { ok: false, message: TIMING_USAGE };
|
|
276
|
+
if (name !== undefined) return { ok: false, message: TIMING_USAGE };
|
|
277
|
+
name = token;
|
|
278
|
+
}
|
|
279
|
+
return { ok: true, json, ...(name ? { name } : {}) };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function sessionMapFor(runs: readonly RunRecord[]): Map<string, SummarySession> {
|
|
283
|
+
const sessions = new Map<string, SummarySession>();
|
|
284
|
+
for (const run of runs) {
|
|
285
|
+
for (const child of run.children) {
|
|
286
|
+
if (child.sessionFile) {
|
|
287
|
+
sessions.set(child.sessionFile, parseSessionFile(child.sessionFile));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return sessions;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function timingCompletions(
|
|
295
|
+
prefix: string,
|
|
296
|
+
cwd: string,
|
|
297
|
+
): Array<{ value: string; label: string }> | null {
|
|
298
|
+
const trimmed = prefix.trimStart();
|
|
299
|
+
if (trimmed.startsWith("--") && !trimmed.includes(" ")) {
|
|
300
|
+
return "--json".startsWith(trimmed)
|
|
301
|
+
? [{ value: "--json", label: "--json" }]
|
|
302
|
+
: [];
|
|
303
|
+
}
|
|
304
|
+
const json = /^--json\s+/.test(trimmed);
|
|
305
|
+
const namePrefix = json ? trimmed.replace(/^--json\s+/, "") : prefix.trim();
|
|
306
|
+
if (namePrefix.includes(" ")) return null;
|
|
307
|
+
const store = new RunStore({ rootDir: path.join(cwd, ".pi-subagents") });
|
|
308
|
+
const names = new Set<string>();
|
|
309
|
+
for (const run of store.listRuns()) {
|
|
310
|
+
for (const child of run.children) names.add(child.name);
|
|
311
|
+
}
|
|
312
|
+
return [...names]
|
|
313
|
+
.filter((name) => name.startsWith(namePrefix))
|
|
314
|
+
.sort((a, b) => a.localeCompare(b))
|
|
315
|
+
.map((name) => ({
|
|
316
|
+
value: json ? `--json ${name}` : name,
|
|
317
|
+
label: name,
|
|
318
|
+
}));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function notifyUnknownChild(
|
|
322
|
+
ctx: ExtensionCommandContext,
|
|
323
|
+
name: string,
|
|
324
|
+
): void {
|
|
325
|
+
ctx.ui.notify(
|
|
326
|
+
`unknown child: ${name} (no run under ${ctx.cwd}/.pi-subagents). Child records are scoped to the cwd they were launched from — pass the same cwd used for launch.`,
|
|
327
|
+
"error",
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function findTimingChild(
|
|
332
|
+
report: TimingReport,
|
|
333
|
+
summary: SubagentSummary,
|
|
334
|
+
name: string,
|
|
335
|
+
): { timing: TimingChild; run: RunRecord; child: ChildRecord } | null {
|
|
336
|
+
const picked = pickChildByName(summary.runs, name);
|
|
337
|
+
if (!picked) return null;
|
|
338
|
+
const timing = report.children.find((c) => c.name === picked.child.name);
|
|
339
|
+
if (!timing) return null;
|
|
340
|
+
return { timing, run: picked.run, child: picked.child };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Build the `/legion timing [--json] [<name>]` handler. */
|
|
344
|
+
export function createTimingCommand(pi: ExtensionAPI): LegionSubcommand {
|
|
345
|
+
let completionCwd = process.cwd();
|
|
346
|
+
return {
|
|
347
|
+
description: "Show wall / generation / other timing for subagent children",
|
|
348
|
+
getArgumentCompletions: (prefix) => timingCompletions(prefix, completionCwd),
|
|
349
|
+
handler: async (args, ctx) => {
|
|
350
|
+
completionCwd = ctx.cwd;
|
|
351
|
+
const parsed = parseTimingArgs(args);
|
|
352
|
+
if (parsed.ok === false) {
|
|
353
|
+
ctx.ui.notify(parsed.message, "error");
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const store = new RunStore({
|
|
357
|
+
rootDir: path.join(ctx.cwd, ".pi-subagents"),
|
|
358
|
+
});
|
|
359
|
+
const runs = store.listRuns();
|
|
360
|
+
const sessions = sessionMapFor(runs);
|
|
361
|
+
const now = Date.now();
|
|
362
|
+
const summary = aggregateSubagentRuns(runs, { now, sessions });
|
|
363
|
+
const report = buildTimingReport(summary, { cwd: ctx.cwd });
|
|
364
|
+
|
|
365
|
+
if (parsed.name) {
|
|
366
|
+
const found = findTimingChild(report, summary, parsed.name);
|
|
367
|
+
if (!found) {
|
|
368
|
+
notifyUnknownChild(ctx, parsed.name);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
sendSlashText(
|
|
372
|
+
pi,
|
|
373
|
+
parsed.json
|
|
374
|
+
? formatTimingJson(report, {
|
|
375
|
+
child: found.timing,
|
|
376
|
+
run: found.run,
|
|
377
|
+
})
|
|
378
|
+
: formatTimingDetail(found.timing, found.run),
|
|
379
|
+
);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
sendSlashText(
|
|
384
|
+
pi,
|
|
385
|
+
parsed.json
|
|
386
|
+
? formatTimingJson(report)
|
|
387
|
+
: formatTimingTable(report, { cwd: ctx.cwd }),
|
|
388
|
+
);
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
}
|
package/src/runs/args.ts
CHANGED
|
@@ -97,6 +97,12 @@ export interface BuildArgsInput {
|
|
|
97
97
|
* the child Pi process so the session records it as the first user turn.
|
|
98
98
|
*/
|
|
99
99
|
includeTask?: boolean;
|
|
100
|
+
/**
|
|
101
|
+
* When true, append `--approve` so headless pi trusts the project cwd for
|
|
102
|
+
* settings / resources / extension discovery (#193). Default false: omit
|
|
103
|
+
* the flag (v1 never emits `--no-approve`).
|
|
104
|
+
*/
|
|
105
|
+
approve?: boolean;
|
|
100
106
|
}
|
|
101
107
|
|
|
102
108
|
export interface BuildArgsResult {
|
|
@@ -123,6 +129,8 @@ export function buildPiArgs(input: BuildArgsInput): BuildArgsResult {
|
|
|
123
129
|
pushSkillArgs(args, input.agent);
|
|
124
130
|
pushSystemPromptArgs(args, tempFiles, input);
|
|
125
131
|
if (input.includeTask !== false) pushTaskArg(args, tempFiles, input);
|
|
132
|
+
// Opt-in project trust for headless rpc (#193). Never emit --no-approve in v1.
|
|
133
|
+
if (input.approve === true) args.push("--approve");
|
|
126
134
|
|
|
127
135
|
return { args, tempFiles };
|
|
128
136
|
}
|
package/src/runs/orchestrator.ts
CHANGED
|
@@ -12,13 +12,13 @@ import * as path from "node:path";
|
|
|
12
12
|
import { modelCandidates } from "../agents/model-resolution.ts";
|
|
13
13
|
import { DEFAULT_TEAM, TEAM_ENV, type ActiveTeam } from "../agents/teams.ts";
|
|
14
14
|
import type { LegionSettings } from "../shared/types.ts";
|
|
15
|
-
import {
|
|
15
|
+
import { LEGION_IDENTITY_ENV_KEYS, legionIdentityEnv } from "./args.ts";
|
|
16
16
|
import type { DatabaseSync } from "../legion/db.ts";
|
|
17
17
|
import type { RunStore } from "./store.ts";
|
|
18
18
|
import type { LegionEventBus, LegionProcessEventType } from "../extension/event-bus.ts";
|
|
19
19
|
import { appendEvent } from "../legion/events.ts";
|
|
20
20
|
import { withLaunchBudget, type LegionBudgetLimits } from "../legion/budget.ts";
|
|
21
|
-
import { getChildByName, getNode, insertNode, nodeIdForChild, sanitizeNodeSlug, type LegionNode, updateNode
|
|
21
|
+
import { getChildByName, getNode, insertNode, nodeIdForChild, sanitizeNodeSlug, type LegionNode, updateNode } from "../legion/nodes.ts";
|
|
22
22
|
import { upsertUsage } from "../legion/usage.ts";
|
|
23
23
|
import { evaluatePhaseTransition, isLegionPhase, isTerminalPhase } from "../legion/state-machine.ts";
|
|
24
24
|
import { CHILD_ACCEPTANCE_ROLE_ENV, CHILD_ROLE_ENV } from "../extension/child-guard.ts";
|
|
@@ -54,11 +54,13 @@ import {
|
|
|
54
54
|
type NestedPathEntry,
|
|
55
55
|
type RetireOptions,
|
|
56
56
|
type RunRecord,
|
|
57
|
+
type SpawnMode,
|
|
57
58
|
SubagentError,
|
|
58
59
|
type Usage,
|
|
59
60
|
} from "../shared/types.ts";
|
|
60
61
|
import { RpcSupervisor } from "../supervisor/rpc-supervisor.ts";
|
|
61
62
|
import type { LegionSupervisor, SettleResult, SpawnInput } from "../supervisor/types.ts";
|
|
63
|
+
import { isInProcessSpawn, resolveSpawnMode } from "../supervisor/spawn-mode.ts";
|
|
62
64
|
import type { PendingUIRequest, UIProxy } from "../supervisor/ui-proxy.ts";
|
|
63
65
|
import { nativeModelFor } from "./kind.ts";
|
|
64
66
|
import { applyVerification, type VerifyRunner } from "./acceptance.ts";
|
|
@@ -107,6 +109,11 @@ export interface OrchestratorDeps {
|
|
|
107
109
|
worktreeBootstrapTimeoutMs?: number;
|
|
108
110
|
team?: ActiveTeam;
|
|
109
111
|
childContext?: string;
|
|
112
|
+
/**
|
|
113
|
+
* When true, RPC child argv includes `--approve` (`subagents.rpc.approve`).
|
|
114
|
+
* Default false / omitted.
|
|
115
|
+
*/
|
|
116
|
+
rpcApprove?: boolean;
|
|
110
117
|
onBudgetRefused?: (event: BudgetRefusedEvent) => void;
|
|
111
118
|
}
|
|
112
119
|
|
|
@@ -132,9 +139,20 @@ const CHILD_ENV = "PI_SUBAGENT_CHILD";
|
|
|
132
139
|
const POLL_INTERVAL_MS = 500;
|
|
133
140
|
|
|
134
141
|
function parseDepthCeiling(raw: string | undefined): number | undefined {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
142
|
+
// Genuinely unset / blank → no env ceiling (settings + hard cap still apply).
|
|
143
|
+
if (raw === undefined) return undefined;
|
|
144
|
+
const t = raw.trim();
|
|
145
|
+
if (t === "") return undefined;
|
|
146
|
+
// #173: illegal values fail closed to leaf-only (0), matching contract :361 —
|
|
147
|
+
// never widen to the hard cap via `undefined` → +Infinity.
|
|
148
|
+
if (!/^\d+$/.test(t)) {
|
|
149
|
+
console.error(
|
|
150
|
+
`[pi-legion] ${MAX_DEPTH_ENV}=${JSON.stringify(t)} is not a non-negative integer; treating as 0 (leaf only).`,
|
|
151
|
+
);
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
const value = Number(t);
|
|
155
|
+
return Number.isInteger(value) && value >= 0 ? value : 0;
|
|
138
156
|
}
|
|
139
157
|
|
|
140
158
|
/** Root/process ceiling: inherited env, settings, and the saturated path cap. */
|
|
@@ -234,6 +252,7 @@ export class Orchestrator {
|
|
|
234
252
|
private readonly worktreeBootstrapTimeoutMs?: number;
|
|
235
253
|
private readonly team?: ActiveTeam;
|
|
236
254
|
private readonly childContext?: string;
|
|
255
|
+
private readonly rpcApprove: boolean;
|
|
237
256
|
private readonly onBudgetRefused?: (event: BudgetRefusedEvent) => void;
|
|
238
257
|
private readonly legion?: OrchestratorLegionContext;
|
|
239
258
|
private readonly requestedDepthCap?: number;
|
|
@@ -267,6 +286,7 @@ export class Orchestrator {
|
|
|
267
286
|
this.worktreeBootstrapTimeoutMs = deps.worktreeBootstrapTimeoutMs;
|
|
268
287
|
this.team = deps.team;
|
|
269
288
|
this.childContext = deps.childContext?.trim() || undefined;
|
|
289
|
+
this.rpcApprove = deps.rpcApprove === true;
|
|
270
290
|
this.onBudgetRefused = deps.onBudgetRefused;
|
|
271
291
|
this.legion = deps.legion;
|
|
272
292
|
}
|
|
@@ -341,11 +361,24 @@ export class Orchestrator {
|
|
|
341
361
|
const depthCeiling = input.utility
|
|
342
362
|
? parent.depth + 1
|
|
343
363
|
: Math.min(legion.settings?.maxDepth ?? this.maxDepthValue, MAX_NESTED_PATH_ENTRIES);
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
364
|
+
// Resume must NOT inflate maxChildrenPerNode (#180). Retired nodes free
|
|
365
|
+
// slots (#142); turning one live again re-charges the fanout cap — same
|
|
366
|
+
// gate as a fresh launch. activeReservation below still covers the node's
|
|
367
|
+
// own maxActiveNodes slot when it is already starting/running.
|
|
368
|
+
const maxChildren = legion.settings?.maxChildrenPerNode ?? 8;
|
|
347
369
|
const activeReservation = previous && (previous.status === "starting" || previous.status === "running") ? 1 : 0;
|
|
348
|
-
|
|
370
|
+
// In-process children use subagents.inprocess.* only — do not spend RPC legion.* slots (#197).
|
|
371
|
+
const limits: LegionBudgetLimits = isInProcessSpawn(input.agent)
|
|
372
|
+
? {
|
|
373
|
+
maxDepth: MAX_NESTED_PATH_ENTRIES,
|
|
374
|
+
maxChildrenPerNode: Number.MAX_SAFE_INTEGER,
|
|
375
|
+
maxActiveNodes: Number.MAX_SAFE_INTEGER,
|
|
376
|
+
}
|
|
377
|
+
: {
|
|
378
|
+
maxDepth: depthCeiling,
|
|
379
|
+
maxChildrenPerNode: maxChildren,
|
|
380
|
+
maxActiveNodes: (legion.settings?.maxActiveNodes ?? 30) + activeReservation,
|
|
381
|
+
};
|
|
349
382
|
const gate = withLaunchBudget(legion.db, { parentId: legion.nodeId, depth: parent.depth + 1, nodeId: input.nodeId }, () => {
|
|
350
383
|
if (previous) {
|
|
351
384
|
updateNode(legion.db, input.nodeId, { status: "starting", sessionFile: input.sessionFile, worktreePath: input.worktreePath ?? previous.worktreePath, model: input.model ?? previous.model, team: team && team !== DEFAULT_TEAM ? team : previous.team }, this.now);
|
|
@@ -520,10 +553,6 @@ export class Orchestrator {
|
|
|
520
553
|
const ledgerPrevious = nodeId ? getNode(this.legion!.db, nodeId) : null;
|
|
521
554
|
const previous = this.children.get(name) ?? (ledgerPrevious ? this.childFromNode(name, ledgerPrevious) : undefined);
|
|
522
555
|
if (previous && !this.children.has(name)) this.children.set(name, previous);
|
|
523
|
-
// A utility dispatch is a hard leaf, not a nested command-chain launch.
|
|
524
|
-
// The frozen contract therefore skips assertWithinBudgets entirely.
|
|
525
|
-
if (!input.utilityDispatch) this.assertWithinBudgets(Boolean(previous));
|
|
526
|
-
if (this.supervisor.isAlive(name)) throw new SubagentError(`child is already alive: ${name}`, ErrorCodes.NAME_TAKEN);
|
|
527
556
|
const models = this.planModelCandidates({
|
|
528
557
|
agent: input.agent,
|
|
529
558
|
...(input.model !== undefined ? { model: input.model } : {}),
|
|
@@ -542,6 +571,53 @@ export class Orchestrator {
|
|
|
542
571
|
launch: input.worktree,
|
|
543
572
|
parentInWorktree: isInsideChildWorktree(this.cwd),
|
|
544
573
|
});
|
|
574
|
+
// Spawn-mode arbitration (contract D2). `resolveSpawnMode` only reports the
|
|
575
|
+
// configured intent; the launch context decides whether an OMITTED mode can
|
|
576
|
+
// actually run in-process. An omitted mode falls back to rpc when this
|
|
577
|
+
// launch needs worktree isolation (a hard requirement of the in-process
|
|
578
|
+
// backend). An explicit value is never downgraded (invariant I6) — a bad
|
|
579
|
+
// explicit combination still throws below.
|
|
580
|
+
// `kind !== "pi"` is NOT re-tested here: the early guard above (before this
|
|
581
|
+
// arbitration) already refuses it, so such a clause would be dead code.
|
|
582
|
+
const explicitMode = input.agent.spawnMode !== undefined;
|
|
583
|
+
const mode: SpawnMode =
|
|
584
|
+
!explicitMode && worktree === true
|
|
585
|
+
? "rpc"
|
|
586
|
+
: resolveSpawnMode(input.agent);
|
|
587
|
+
const inProcess = mode === "inprocess";
|
|
588
|
+
// Downstream must see the arbitrated result, otherwise RoutingSupervisor
|
|
589
|
+
// re-resolves the omitted field to `inprocess` on its own and throws
|
|
590
|
+
// "cannot combine with worktree: true" — the arbitration would be moot.
|
|
591
|
+
// `worktree` is normalized too: a role card that pins `worktree: true`
|
|
592
|
+
// (e.g. `agents/worker.md`) keeps that raw value even when THIS launch
|
|
593
|
+
// resolved to no isolation (`parentRole === "centurion"`, an explicit
|
|
594
|
+
// `launch({ worktree: false })`, or the nested-worktree suppression), and
|
|
595
|
+
// leaking it makes `{spawnMode:"inprocess", worktree:true}` reach
|
|
596
|
+
// routing-supervisor.ts:79 / inprocess-supervisor.ts:342, which reject it
|
|
597
|
+
// with INVALID_PARAMS. That shape is exactly the "centurion dispatches
|
|
598
|
+
// worker" production path, so it must be normalized to `worktree: false`.
|
|
599
|
+
// The `&& worktree` short-circuit keeps the rpc path returning the
|
|
600
|
+
// original object (invariant I3: zero diff on the RPC backend).
|
|
601
|
+
// reserveNode() and SpawnInput.agent below both receive this clone.
|
|
602
|
+
const agent: AgentConfig = mode === input.agent.spawnMode && worktree
|
|
603
|
+
? input.agent
|
|
604
|
+
: { ...input.agent, spawnMode: mode, ...(worktree ? {} : { worktree: false }) };
|
|
605
|
+
// A utility dispatch is a hard leaf, not a nested command-chain launch.
|
|
606
|
+
// In-process children use subagents.inprocess.* budgets (never RPC keys).
|
|
607
|
+
if (!input.utilityDispatch && !inProcess) this.assertWithinBudgets(Boolean(previous));
|
|
608
|
+
if (this.supervisor.isAlive(name)) throw new SubagentError(`child is already alive: ${name}`, ErrorCodes.NAME_TAKEN);
|
|
609
|
+
// Only an EXPLICIT inprocess can reach this: the arbitration above already
|
|
610
|
+
// routed every omitted-mode launch that needs a worktree to rpc, so I1
|
|
611
|
+
// (explicit inprocess + worktree stays a loud throw) holds. This replaces
|
|
612
|
+
// the old gate, which inspected the raw `worktree` fields and therefore
|
|
613
|
+
// rejected callers that explicitly overrode a worktree role with
|
|
614
|
+
// `launch({ worktree: false })` — that override now wins as it should.
|
|
615
|
+
if (inProcess && worktree) {
|
|
616
|
+
throw new SubagentError(
|
|
617
|
+
`spawnMode "inprocess" cannot use worktree isolation for '${name}'`,
|
|
618
|
+
ErrorCodes.INVALID_PARAMS,
|
|
619
|
+
);
|
|
620
|
+
}
|
|
545
621
|
let worktreePath = previous?.worktreePath;
|
|
546
622
|
let worktreeBranch = previous?.worktreeBranch;
|
|
547
623
|
let createdWorktree = false;
|
|
@@ -588,7 +664,7 @@ export class Orchestrator {
|
|
|
588
664
|
preCreateSessionFile(sessionFile);
|
|
589
665
|
if (this.legion && nodeId) {
|
|
590
666
|
const reservationRunId = createdRunId ?? (previous ? ledgerPrevious?.runId : undefined) ?? this.legion.runId;
|
|
591
|
-
reservedNode = this.reserveNode({ nodeId, name, agent
|
|
667
|
+
reservedNode = this.reserveNode({ nodeId, name, agent, task: input.task, sessionFile, runId: reservationRunId, team: input.team, worktreePath, model: models.candidates[0], utility: Boolean(input.utilityDispatch && input.agent.utility) });
|
|
592
668
|
reserved = true;
|
|
593
669
|
parentReservationCreated = await this.persistParentReservation({ nodeId, name, agent: input.agent, sessionFile, ...(models.candidates[0] ? { model: models.candidates[0] } : {}), ...(worktreePath ? { worktreePath } : {}) });
|
|
594
670
|
}
|
|
@@ -604,7 +680,7 @@ export class Orchestrator {
|
|
|
604
680
|
const spawnInput: SpawnInput = {
|
|
605
681
|
name,
|
|
606
682
|
task: input.task,
|
|
607
|
-
agent
|
|
683
|
+
agent,
|
|
608
684
|
sessionFile,
|
|
609
685
|
tempDir,
|
|
610
686
|
cwd: worktreePath ?? this.cwd,
|
|
@@ -616,6 +692,34 @@ export class Orchestrator {
|
|
|
616
692
|
...(input.agent.allowNestedSubagents !== undefined && !input.agent.utility ? { allowNestedSubagents: input.agent.allowNestedSubagents } : {}),
|
|
617
693
|
...(worktreeBranch ? { worktreeBranch } : {}),
|
|
618
694
|
...(this.childContext ? { childContext: this.childContext } : {}),
|
|
695
|
+
...(this.rpcApprove ? { approve: true } : {}),
|
|
696
|
+
// D5: in-process children cannot inherit lineage through env (they share
|
|
697
|
+
// the parent's process.env and would read the PARENT's identity, or none
|
|
698
|
+
// at the root). Hand the arbitrator a real identity scope instead. Only
|
|
699
|
+
// the in-process backend consumes it; RPC lineage stays on env (I3).
|
|
700
|
+
//
|
|
701
|
+
// `stripLegionIdentity` (vexillatio) must mask lineage on BOTH
|
|
702
|
+
// channels: the env keys are deleted above, so the context must not
|
|
703
|
+
// hand back the same identity through ALS. Keep the scope (so the
|
|
704
|
+
// child still knows it is not the root) but omit node/db lineage.
|
|
705
|
+
...(inProcess && this.legion
|
|
706
|
+
? {
|
|
707
|
+
childLaunchContext: {
|
|
708
|
+
...(!input.stripLegionIdentity && nodeId ? { nodeId } : {}),
|
|
709
|
+
...(input.stripLegionIdentity
|
|
710
|
+
? {}
|
|
711
|
+
: { parentNodeId: this.legion.nodeId, dbPath: this.legion.dbPath }),
|
|
712
|
+
// The nested path is lineage too: LEGION_IDENTITY_ENV_KEYS
|
|
713
|
+
// strips PI_SUBAGENT_PARENT_PATH on the rpc side, so the
|
|
714
|
+
// in-process channel must not hand it back.
|
|
715
|
+
...(input.stripLegionIdentity ? {} : { parentPath: this.childPath(name) }),
|
|
716
|
+
...(agent.acceptance?.role ? { acceptanceRole: agent.acceptance.role } : {}),
|
|
717
|
+
allowNested: input.agent.allowNestedSubagents === true && !input.agent.utility,
|
|
718
|
+
utility: Boolean(input.utilityDispatch && input.agent.utility),
|
|
719
|
+
spawnMode: "inprocess" as const,
|
|
720
|
+
},
|
|
721
|
+
}
|
|
722
|
+
: {}),
|
|
619
723
|
};
|
|
620
724
|
try {
|
|
621
725
|
result = await this.supervisor.spawnChild(spawnInput);
|
|
@@ -1128,7 +1232,12 @@ export class Orchestrator {
|
|
|
1128
1232
|
if (opts?.cause === "session-shutdown" && this.supervisor.isAlive(name)) {
|
|
1129
1233
|
child.execution = attributeParentSessionShutdown(child.execution ?? deriveOutcome(parsed), parsed);
|
|
1130
1234
|
}
|
|
1131
|
-
if (this.supervisor.isAlive(name))
|
|
1235
|
+
if (this.supervisor.isAlive(name)) {
|
|
1236
|
+
await this.supervisor.retire(
|
|
1237
|
+
name,
|
|
1238
|
+
opts?.graceful === false ? { graceful: false } : {},
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1132
1241
|
if (child.nodeId && this.legion) {
|
|
1133
1242
|
if (child.execution && child.execution.status !== "unknown" && child.execution.status !== "running") {
|
|
1134
1243
|
this.recordSettled(child, child.execution);
|
package/src/shared/output-cap.ts
CHANGED
|
@@ -11,12 +11,12 @@
|
|
|
11
11
|
export const DEFAULT_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
|
|
12
12
|
export const MAX_OUTPUT_BYTES_ENV = "PI_LEGION_MAX_OUTPUT_BYTES";
|
|
13
13
|
|
|
14
|
-
/** Resolve the per-stream cap from env; garbage /
|
|
14
|
+
/** Resolve the per-stream cap from env; garbage / floored < 1 → default (#174). */
|
|
15
15
|
export function resolveMaxOutputBytes(env: NodeJS.ProcessEnv = process.env): number {
|
|
16
16
|
const raw = env[MAX_OUTPUT_BYTES_ENV]?.trim();
|
|
17
17
|
if (!raw) return DEFAULT_MAX_OUTPUT_BYTES;
|
|
18
18
|
const n = Number(raw);
|
|
19
|
-
return Number.isFinite(n) && n
|
|
19
|
+
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : DEFAULT_MAX_OUTPUT_BYTES;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export interface CappedOutput {
|
package/src/shared/session.ts
CHANGED
|
@@ -327,7 +327,8 @@ const SESSION_CACHE_MAX_ENTRIES = 64;
|
|
|
327
327
|
export function resolveSessionMaxLineBytes(env: NodeJS.ProcessEnv = process.env): number {
|
|
328
328
|
const raw = env[SESSION_MAX_LINE_BYTES_ENV]?.trim();
|
|
329
329
|
const n = raw ? Number(raw) : NaN;
|
|
330
|
-
|
|
330
|
+
// #174: require floored value >= 1 — (0,1) must not become cap=0.
|
|
331
|
+
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : DEFAULT_SESSION_MAX_LINE_BYTES;
|
|
331
332
|
}
|
|
332
333
|
|
|
333
334
|
interface SessionParseState {
|