pi-legion 4.0.0 → 5.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 +168 -1
- package/README.md +101 -44
- package/agents/centurion.md +20 -16
- package/agents/scout.md +3 -1
- package/agents/worker.md +5 -7
- package/docs/contracts/declarative-phase-machine-v5.md +128 -0
- package/docs/contracts/subagent-renewal-v1.md +260 -0
- package/docs/headless-child-contract.md +6 -3
- package/docs/inprocess-supervisor.md +70 -24
- package/docs/legion-v2-contract.md +9 -0
- package/docs/spawn-mode-visibility-v1.md +4 -1
- package/index.ts +598 -44
- package/package.json +2 -2
- package/src/agents/agents.ts +293 -36
- package/src/agents/overrides.ts +3 -1
- package/src/agents/settings.ts +6 -18
- package/src/extension/child-guard.ts +25 -1
- package/src/extension/legion-tools.ts +66 -21
- package/src/extension/notify.ts +136 -4
- package/src/extension/playbook.ts +2 -2
- package/src/extension/runtime.ts +56 -1
- package/src/extension/summary.ts +13 -4
- package/src/extension/timing.ts +8 -4
- package/src/legion/phase-conditions.ts +164 -0
- package/src/legion/phase-machine.ts +403 -0
- package/src/legion/state-machine.ts +219 -115
- package/src/runs/args.ts +32 -18
- package/src/runs/extension-inheritance.ts +220 -0
- package/src/runs/orchestrator.ts +137 -44
- package/src/runs/progress-signal.ts +137 -0
- package/src/runs/store-lookup.ts +270 -0
- package/src/shared/types.ts +88 -3
- package/src/supervisor/child-context.ts +19 -0
- package/src/supervisor/inprocess-supervisor.ts +29 -5
- package/src/supervisor/routing-supervisor.ts +2 -1
- package/src/supervisor/rpc-supervisor.ts +2 -2
- package/src/tui/tree-view.ts +56 -0
- package/src/tui/viz/palette.ts +0 -3
- package/agents/advisor.md +0 -110
- package/agents/designer.md +0 -87
- package/agents/planner.md +0 -53
- package/agents/prototype.md +0 -72
- package/agents/reviewer.md +0 -113
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
isTerminalPhase,
|
|
39
39
|
type LegionPhase,
|
|
40
40
|
} from "../legion/state-machine.ts";
|
|
41
|
+
import { isTerminalIn, type PhaseMachine } from "../legion/phase-machine.ts";
|
|
41
42
|
import { upsertUsage } from "../legion/usage.ts";
|
|
42
43
|
|
|
43
44
|
const POSTMAN_INTERVAL_MS = 2_000;
|
|
@@ -66,6 +67,8 @@ export interface LegionPostmanOptions {
|
|
|
66
67
|
resumeChild?: (node: LegionNode, mail: LegionMailCommand) => Promise<void>;
|
|
67
68
|
now?: () => number;
|
|
68
69
|
intervalMs?: number;
|
|
70
|
+
/** #298: the team root's declared phase machine (absent = built-in). */
|
|
71
|
+
machine?: PhaseMachine;
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
/** Poll and deliver mail only for children hosted by this process. */
|
|
@@ -78,6 +81,7 @@ export class LegionPostman {
|
|
|
78
81
|
private readonly sendRootMessage: (text: string) => void;
|
|
79
82
|
private readonly sendSelfMessage: (text: string) => void;
|
|
80
83
|
private readonly resumeChild?: LegionPostmanOptions["resumeChild"];
|
|
84
|
+
private readonly machine?: PhaseMachine;
|
|
81
85
|
private readonly now: () => number;
|
|
82
86
|
private readonly intervalMs: number;
|
|
83
87
|
private timer: ReturnType<typeof setInterval> | undefined;
|
|
@@ -94,6 +98,7 @@ export class LegionPostman {
|
|
|
94
98
|
this.sendRootMessage = options.sendRootMessage ?? (() => {});
|
|
95
99
|
this.sendSelfMessage = options.sendSelfMessage ?? (() => {});
|
|
96
100
|
this.resumeChild = options.resumeChild;
|
|
101
|
+
this.machine = options.machine;
|
|
97
102
|
this.now = options.now ?? Date.now;
|
|
98
103
|
this.intervalMs = options.intervalMs ?? POSTMAN_INTERVAL_MS;
|
|
99
104
|
}
|
|
@@ -295,9 +300,23 @@ export class LegionPostman {
|
|
|
295
300
|
return false;
|
|
296
301
|
}
|
|
297
302
|
|
|
303
|
+
/**
|
|
304
|
+
* Terminal check that respects the team's own machine (#298).
|
|
305
|
+
*
|
|
306
|
+
* The old code asked `isTerminalPhase`, which only knew the three built-in
|
|
307
|
+
* terminal names. Under a declared machine a phase is terminal because the
|
|
308
|
+
* DECLARATION says so (`terminal: true` or no exits), so a custom name would
|
|
309
|
+
* otherwise be treated as non-terminal and the auto-finish paths would act on
|
|
310
|
+
* a team that had already finished.
|
|
311
|
+
*/
|
|
312
|
+
private isTerminalPhaseOf(phase: string): boolean {
|
|
313
|
+
if (this.machine) return isTerminalIn(this.machine, phase);
|
|
314
|
+
return isTerminalPhase(phase);
|
|
315
|
+
}
|
|
316
|
+
|
|
298
317
|
private failTeamIfAllChildrenFailed(timestamp: number): void {
|
|
299
318
|
const node = getNode(this.db, this.nodeId);
|
|
300
|
-
if (!node?.phase ||
|
|
319
|
+
if (!node?.phase || this.isTerminalPhaseOf(node.phase)) return;
|
|
301
320
|
const children = getChildren(this.db, node.id);
|
|
302
321
|
if (!children.length || children.some((child) => child.status !== "failed")) return;
|
|
303
322
|
const decision = evaluatePhaseTransition({
|
|
@@ -340,7 +359,7 @@ export class LegionPostman {
|
|
|
340
359
|
|
|
341
360
|
private checkOwnPhaseTimeout(timestamp: number): void {
|
|
342
361
|
const node = getNode(this.db, this.nodeId);
|
|
343
|
-
if (!node?.phase ||
|
|
362
|
+
if (!node?.phase || this.isTerminalPhaseOf(node.phase)) return;
|
|
344
363
|
const timeout = checkPhaseTimeout({
|
|
345
364
|
phase: node.phase,
|
|
346
365
|
phaseSince: node.phaseSince,
|
|
@@ -377,6 +396,8 @@ export interface RegisterLegionToolsOptions {
|
|
|
377
396
|
db: DatabaseSync;
|
|
378
397
|
nodeId: string;
|
|
379
398
|
role?: string;
|
|
399
|
+
/** #298: the team root's declared phase machine (absent = built-in). */
|
|
400
|
+
machine?: PhaseMachine;
|
|
380
401
|
settings?: LegionSettings;
|
|
381
402
|
eventBus?: Pick<LegionEventBus, "emit">;
|
|
382
403
|
cwd?: string;
|
|
@@ -468,7 +489,15 @@ export function registerLegionTools(pi: ExtensionAPI, options: RegisterLegionToo
|
|
|
468
489
|
label: "Legion phase",
|
|
469
490
|
description: "Request a validated centurion phase transition.",
|
|
470
491
|
parameters: Type.Object({
|
|
471
|
-
|
|
492
|
+
// #298: the phase list is declared per team, so it cannot be a
|
|
493
|
+
// compile-time enum here. `requestLegionPhase` validates the value
|
|
494
|
+
// against the team's own machine and refuses anything unknown with
|
|
495
|
+
// the list of phases that DO exist.
|
|
496
|
+
to: Type.String({
|
|
497
|
+
description:
|
|
498
|
+
'Target phase. Default machine: planning, implementing, reviewing, verifying. ' +
|
|
499
|
+
'`done`/`failed`/`aborted` are controlled by the parent or supervisor.',
|
|
500
|
+
}),
|
|
472
501
|
note: Type.Optional(Type.String()),
|
|
473
502
|
evidence: Type.Optional(Type.String()),
|
|
474
503
|
}),
|
|
@@ -483,6 +512,7 @@ export function registerLegionTools(pi: ExtensionAPI, options: RegisterLegionToo
|
|
|
483
512
|
now: options.now,
|
|
484
513
|
runCommand: options.runCommand,
|
|
485
514
|
sendSelfMessage: options.sendSelfMessage,
|
|
515
|
+
...(options.machine ? { machine: options.machine } : {}),
|
|
486
516
|
});
|
|
487
517
|
return result.ok ? toolOk(result.message) : toolError(result.message, result.code ?? "INVALID_PARAMS");
|
|
488
518
|
},
|
|
@@ -523,6 +553,8 @@ export function formatTree(nodes: readonly LegionNode[]): string {
|
|
|
523
553
|
export interface PhaseRequestOptions {
|
|
524
554
|
note?: string;
|
|
525
555
|
evidence?: string;
|
|
556
|
+
/** #298: the team's declared phase machine, when its card has one. */
|
|
557
|
+
machine?: PhaseMachine;
|
|
526
558
|
cwd: string;
|
|
527
559
|
runDir?: string;
|
|
528
560
|
settings?: LegionSettings;
|
|
@@ -533,18 +565,22 @@ export interface PhaseRequestOptions {
|
|
|
533
565
|
}
|
|
534
566
|
|
|
535
567
|
export type PhaseRequestResult =
|
|
536
|
-
| { ok: true; phase:
|
|
568
|
+
| { ok: true; phase: string; message: string }
|
|
537
569
|
| { ok: false; message: string; code?: string };
|
|
538
570
|
|
|
539
571
|
export async function requestLegionPhase(
|
|
540
572
|
db: DatabaseSync,
|
|
541
573
|
nodeId: string,
|
|
542
|
-
to:
|
|
574
|
+
to: string,
|
|
543
575
|
options: PhaseRequestOptions,
|
|
544
576
|
): Promise<PhaseRequestResult> {
|
|
545
577
|
const now = options.now ?? Date.now;
|
|
546
578
|
const self = getNode(db, nodeId);
|
|
547
579
|
if (!self || self.role !== "centurion") return { ok: false, code: "INVALID_PARAMS", message: "legion_phase is centurion-only" };
|
|
580
|
+
// #298: the team's own machine decides which phases exist. `options.machine`
|
|
581
|
+
// carries the declaration from the node's role card; absent means the built-in
|
|
582
|
+
// four-phase pipeline.
|
|
583
|
+
const machine = options.machine;
|
|
548
584
|
if (to === "done" || to === "failed" || to === "aborted") {
|
|
549
585
|
return { ok: false, code: "INVALID_PARAMS", message: `${to} is controlled by the parent or supervisor` };
|
|
550
586
|
}
|
|
@@ -552,30 +588,39 @@ export async function requestLegionPhase(
|
|
|
552
588
|
const children = getChildren(db, nodeId);
|
|
553
589
|
const proposedContractPath = path.resolve(self.worktreePath ?? options.cwd, ".pi-legion", "contract.md");
|
|
554
590
|
let contractText: string | undefined;
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
591
|
+
try { contractText = fs.readFileSync(proposedContractPath, "utf8"); } catch { contractText = undefined; }
|
|
592
|
+
// #298: NO role names here any more. The old code scanned for a settled
|
|
593
|
+
// `role === "reviewer"` and for failed `role === "worker"` children, which made
|
|
594
|
+
// those roles load-bearing: a custom machine could declare any phases it liked
|
|
595
|
+
// and still be judged by the fixed pipeline's cast list.
|
|
596
|
+
//
|
|
597
|
+
// Now the question is asked structurally — "the newest settled child that
|
|
598
|
+
// reported a verdict" and "failures recorded before this phase began" — so any
|
|
599
|
+
// role can play any part.
|
|
600
|
+
const settledWithSession = children
|
|
601
|
+
.filter((child) => child.status === "settled" && child.sessionFile)
|
|
602
|
+
.sort((left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id));
|
|
603
|
+
const latestVerdictChild = settledWithSession.at(-1);
|
|
604
|
+
const lastVerdict = latestVerdictChild?.sessionFile
|
|
605
|
+
? extractVerdict(parseSessionFile(latestVerdictChild.sessionFile).lastTurnOutput ?? "")
|
|
564
606
|
: null;
|
|
565
|
-
const
|
|
566
|
-
const
|
|
567
|
-
? children.filter((child) => child.
|
|
607
|
+
const verdictOk = lastVerdict?.ok;
|
|
608
|
+
const failedHandledIds = self.phase !== null && self.reworkCount > 0 && self.phaseSince !== null
|
|
609
|
+
? children.filter((child) => child.status === "failed" && child.updatedAt < self.phaseSince!).map((child) => child.id)
|
|
568
610
|
: [];
|
|
611
|
+
const contractPresent = Boolean(contractText?.trim());
|
|
569
612
|
const decision = evaluatePhaseTransition({
|
|
570
|
-
from:
|
|
613
|
+
from: self.phase ?? null,
|
|
571
614
|
to,
|
|
572
615
|
reworkCount: self.reworkCount,
|
|
573
|
-
contractPath:
|
|
616
|
+
contractPath: contractPresent ? proposedContractPath : self.contractPath,
|
|
617
|
+
contractPresent,
|
|
574
618
|
children: children.map(({ id, role, status }) => ({ id, role, status })),
|
|
575
|
-
failedWorkerHandledIds,
|
|
576
|
-
reviewerVerdictOk,
|
|
619
|
+
failedWorkerHandledIds: failedHandledIds,
|
|
620
|
+
reviewerVerdictOk: verdictOk,
|
|
577
621
|
verificationPassed: readVerificationResult(self),
|
|
578
622
|
actorRole: self.role,
|
|
623
|
+
...(machine ? { machine } : {}),
|
|
579
624
|
}, now);
|
|
580
625
|
|
|
581
626
|
if (!decision.allowed) {
|
package/src/extension/notify.ts
CHANGED
|
@@ -18,12 +18,57 @@
|
|
|
18
18
|
* still starts a new turn immediately.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import {
|
|
22
|
+
PROGRESS_MIN_FILES_CHANGED,
|
|
23
|
+
PROGRESS_MIN_TOOL_RESULTS,
|
|
24
|
+
} from "../runs/progress-signal.ts";
|
|
21
25
|
import { isTransientConnectionFailure } from "../shared/session.ts";
|
|
22
26
|
|
|
23
27
|
export const SUBAGENT_NOTIFY_TYPE = "subagent-notify";
|
|
24
28
|
|
|
25
29
|
export type CompletionStatus = "completed" | "failed" | "stopped" | "running";
|
|
26
30
|
|
|
31
|
+
/**
|
|
32
|
+
* D2/D2-a progress measurement, as carried by a renewal proposal.
|
|
33
|
+
*
|
|
34
|
+
* Exactly W1's `ProgressMeasure` minus `progressed` (D2-a): the renderer
|
|
35
|
+
* re-derives the verdict from `PROGRESS_MIN_TOOL_RESULTS` /
|
|
36
|
+
* `PROGRESS_MIN_FILES_CHANGED`, so a notice can never claim progress the
|
|
37
|
+
* measurer would not, and the thresholds have one home. No extra fields, so
|
|
38
|
+
* a caller can pass a `ProgressMeasure` straight through.
|
|
39
|
+
*/
|
|
40
|
+
export interface RenewalProgress {
|
|
41
|
+
/** New `toolResult` rows since the renewal baseline (a tool returned). */
|
|
42
|
+
readonly toolResults: number;
|
|
43
|
+
/** New assistant messages since the baseline (shown, never decides — D2-a). */
|
|
44
|
+
readonly assistantMessages: number;
|
|
45
|
+
/** Files created/modified in the worktree; absent when not measurable. */
|
|
46
|
+
readonly filesChanged?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* D3: structured renewal proposal (subagent-renewal contract v1, W3).
|
|
51
|
+
*
|
|
52
|
+
* **Terminal paths only (D7).** A renewal proposal requires that the child is
|
|
53
|
+
* actually dead and its session file survived — so ONLY the path that observes
|
|
54
|
+
* a terminated child may populate it. `wait { timeoutMs }` and
|
|
55
|
+
* `collect { timeoutMs }` timeouts mean the parent gave up waiting while the
|
|
56
|
+
* child is still alive (`inprocess-supervisor` just resolves
|
|
57
|
+
* `{settled:false, reason:"timeout"}`): those call sites must leave `renewal`
|
|
58
|
+
* undefined, and then this field's absence keeps every renewal line out of the
|
|
59
|
+
* notice. Renewing a live child would spend a fresh turn budget on a child
|
|
60
|
+
* that never lost one. Locked by test "A3/D7".
|
|
61
|
+
*/
|
|
62
|
+
export interface RenewalProposal {
|
|
63
|
+
/** D2 measurement at the moment of termination. */
|
|
64
|
+
readonly progress: RenewalProgress;
|
|
65
|
+
/** D6: how many renewals already used. */
|
|
66
|
+
readonly used: number;
|
|
67
|
+
readonly max: number;
|
|
68
|
+
/** The exact command that renews this child (D4: always `continue`). */
|
|
69
|
+
readonly command: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
27
72
|
export interface CompletionInput {
|
|
28
73
|
name: string;
|
|
29
74
|
agent?: string;
|
|
@@ -34,6 +79,13 @@ export interface CompletionInput {
|
|
|
34
79
|
sessionFile?: string;
|
|
35
80
|
acceptance?: { status: string; level?: string };
|
|
36
81
|
recycled?: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* D3: structured renewal proposal, present only when the renewal contract
|
|
84
|
+
* fires. Only a **real termination** populates it — `wait`/`collect`
|
|
85
|
+
* timeouts describe a still-alive child and must pass nothing here (D7);
|
|
86
|
+
* see `RenewalProposal` for the full premise.
|
|
87
|
+
*/
|
|
88
|
+
renewal?: RenewalProposal | undefined;
|
|
37
89
|
}
|
|
38
90
|
|
|
39
91
|
export interface CompletionNotice {
|
|
@@ -160,6 +212,83 @@ export function previewOutput(
|
|
|
160
212
|
return `${trimmed.slice(0, max)}\n…(preview truncated${where})`;
|
|
161
213
|
}
|
|
162
214
|
|
|
215
|
+
/**
|
|
216
|
+
* D2-a verdict for a rendered proposal, recomputed from W1's exported
|
|
217
|
+
* thresholds so the notice and the measurer cannot disagree: 3 new tool
|
|
218
|
+
* results, or 1 changed file. Assistant messages are displayed but never
|
|
219
|
+
* decide — a model repeating itself grows that count forever, and that is
|
|
220
|
+
* exactly the case where a renewal must not be offered.
|
|
221
|
+
*/
|
|
222
|
+
function countsAsProgress(progress: RenewalProgress): boolean {
|
|
223
|
+
if (progress.toolResults >= PROGRESS_MIN_TOOL_RESULTS) return true;
|
|
224
|
+
return (
|
|
225
|
+
progress.filesChanged !== undefined &&
|
|
226
|
+
progress.filesChanged >= PROGRESS_MIN_FILES_CHANGED
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* D3: the trailing renewal block, as notice lines.
|
|
232
|
+
*
|
|
233
|
+
* Three shapes (the measurement line always leads — D3 asks for the judgement
|
|
234
|
+
* basis *and* the command, and "this child produced 47 tool results before it
|
|
235
|
+
* died" is the whole argument):
|
|
236
|
+
*
|
|
237
|
+
* progressed: 47 tool results, 12 assistant messages, 6 files changed
|
|
238
|
+
* renew: subagent({ action: "continue", name: "W2", message: "<what to continue>" })
|
|
239
|
+
* renewals: 1/2
|
|
240
|
+
*
|
|
241
|
+
* … no measurable progress since spawn — not offering a renewal (A2)
|
|
242
|
+
* … renewals exhausted (2/2) — split the task instead of renewing (D6/A5)
|
|
243
|
+
*
|
|
244
|
+
* `used/max` is "renewals consumed so far", matching the exhausted line.
|
|
245
|
+
* An unmeasurable `filesChanged` drops out instead of printing `undefined`.
|
|
246
|
+
*/
|
|
247
|
+
export function formatRenewalBlock(renewal: RenewalProposal): string[] {
|
|
248
|
+
const bits = [
|
|
249
|
+
`${renewal.progress.toolResults} tool results`,
|
|
250
|
+
`${renewal.progress.assistantMessages} assistant messages`,
|
|
251
|
+
];
|
|
252
|
+
if (renewal.progress.filesChanged !== undefined) {
|
|
253
|
+
bits.push(`${renewal.progress.filesChanged} files changed`);
|
|
254
|
+
}
|
|
255
|
+
const lines = [`progressed: ${bits.join(", ")}`];
|
|
256
|
+
// A2/D2: renewing a child that moved no output forward just extends the
|
|
257
|
+
// hang by one full turn budget — the exact reason the role timeout exists.
|
|
258
|
+
if (!countsAsProgress(renewal.progress)) {
|
|
259
|
+
lines.push("no measurable progress since spawn — not offering a renewal");
|
|
260
|
+
return lines;
|
|
261
|
+
}
|
|
262
|
+
// D6/A5: the budget is a hard ceiling; past it the split is wrong, not the
|
|
263
|
+
// budget. Say so instead of offering a fourth attempt.
|
|
264
|
+
if (renewal.used >= renewal.max) {
|
|
265
|
+
lines.push(
|
|
266
|
+
`renewals exhausted (${renewal.used}/${renewal.max}) — split the task instead of renewing`,
|
|
267
|
+
);
|
|
268
|
+
return lines;
|
|
269
|
+
}
|
|
270
|
+
// D4/A8: renewal *is* `continue`; the caller hands us the finished command
|
|
271
|
+
// so the parent needs one line of typing, not a re-derivation.
|
|
272
|
+
lines.push(`renew: ${sanitizeNoticeField(renewal.command)}`);
|
|
273
|
+
lines.push(`renewals: ${renewal.used}/${renewal.max}`);
|
|
274
|
+
return lines;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The renewal block for a notice, or `undefined` when there is nothing to say.
|
|
279
|
+
*
|
|
280
|
+
* A completed child is never a renewal candidate (D1 condition 1): if the turn
|
|
281
|
+
* produced a verdict it is not a budget casualty, and offering `continue`
|
|
282
|
+
* would relaunch finished work.
|
|
283
|
+
*/
|
|
284
|
+
function renewalLinesFor(
|
|
285
|
+
status: CompletionStatus,
|
|
286
|
+
renewal: RenewalProposal | undefined,
|
|
287
|
+
): string[] | undefined {
|
|
288
|
+
if (!renewal || status === "completed") return undefined;
|
|
289
|
+
return formatRenewalBlock(renewal);
|
|
290
|
+
}
|
|
291
|
+
|
|
163
292
|
export function formatCompletionNotice(input: CompletionInput): CompletionNotice {
|
|
164
293
|
const status = completionStatusOf(
|
|
165
294
|
input.execution.status,
|
|
@@ -185,7 +314,7 @@ export function formatCompletionNotice(input: CompletionInput): CompletionNotice
|
|
|
185
314
|
if (input.outputFile) previewHints.sessionFile = input.outputFile;
|
|
186
315
|
else if (input.sessionFile) previewHints.sessionFile = input.sessionFile;
|
|
187
316
|
const preview = previewOutput(input.output, PREVIEW_CHARS, previewHints);
|
|
188
|
-
const
|
|
317
|
+
const lines: Array<string | undefined> = [
|
|
189
318
|
`Background task ${status}: **${label}**`,
|
|
190
319
|
"",
|
|
191
320
|
`execution: ${input.execution.status}${reason}`,
|
|
@@ -198,9 +327,12 @@ export function formatCompletionNotice(input: CompletionInput): CompletionNotice
|
|
|
198
327
|
input.recycled === false
|
|
199
328
|
? undefined
|
|
200
329
|
: "Child retired. Resume from the session file if you need this child again.",
|
|
201
|
-
]
|
|
202
|
-
|
|
203
|
-
|
|
330
|
+
];
|
|
331
|
+
// D3: the renewal proposal trails the failure body — appended, never woven
|
|
332
|
+
// in, so an unrenewable notice is byte-identical to what it was before.
|
|
333
|
+
const renewalLines = renewalLinesFor(status, input.renewal);
|
|
334
|
+
if (renewalLines) lines.push("", ...renewalLines);
|
|
335
|
+
const content = lines.filter((line) => line !== undefined).join("\n");
|
|
204
336
|
return {
|
|
205
337
|
content,
|
|
206
338
|
display: true,
|
|
@@ -9,11 +9,11 @@ export const PARENT_PLAYBOOK = [
|
|
|
9
9
|
"1. Call the `subagent` tool immediately with the matching role from the roster in this prompt. Do not substitute bash/curl/web fetch for a search or research role.",
|
|
10
10
|
' One child: subagent({ agent: "<role>", task: "...", name?: "scout-a" }) // optional name= registers that handle (not auto scout-N)',
|
|
11
11
|
" Parallel: subagent({ tasks: [{ agent, task }, { agent, task }] })",
|
|
12
|
-
"2. Prefer tasks[] for independent parallel work. Children are in-process by default; a separate `pi` process is used only when the launch needs a worktree
|
|
12
|
+
"2. Prefer tasks[] for independent parallel work. Children are in-process by default; a separate `pi` process is used only when the launch needs a worktree — and since only a centurion may hold one, that is the only launch that goes over rpc. No pane or tab is created.",
|
|
13
13
|
"3. Then return control. A completion message wakes this session when idle; if it is still working, the notice waits until the current turn ends. Use `wait` with `name` or `all: true` when you need blocking results, and `collect` to inspect a child's session JSONL. Esc / wait interrupt only ends the parent turn's wait — children keep running and settle still notifies; only an explicit `stop` or session exit retires them. A wait interrupted by Esc returns `interrupted (parent turn ended; children still running)`. In `pi --print` or JSON mode, session shutdown waits for live async children to settle (up to each child's turn timeout) before retiring them. Use same-turn `wait`/`collect` or `async: false` when that print turn must include the child's result.",
|
|
14
14
|
"4. Later control is only through `subagent({ action, name?, message? })`; `status` with no name (or `all: true`) overviews live children, with `name` for one child; use `stop` to gracefully retire one child or all tracked children (`force: true` for hard retire when soft stop is insufficient). A `wait` timeout does not stop children — the result lists decisions (`extend` / `steer_again` / `stop` / `force_stop`) for the parent to choose. If a child exits oddly (Model not found, missing tools, memory refused, …), call `subagent({ action: \"diagnose\" })` or ask the user to run `/legion doctor` / `/legion doctor models` — children run without host extensions unless the role inherits them, so a provider package the child needs must be passed explicitly. Do not tell a child to message or prompt the parent directly.",
|
|
15
15
|
"5. A collect timeout with a live child is a progress signal, not a verdict. Check status or session JSONL before deciding to steer or resume.",
|
|
16
|
-
"6. Isolation belongs to centurion
|
|
16
|
+
"6. Isolation belongs to centurion: each centurion owns one worktree, and members stay `worktree: false` in-process. Passing `worktree: true` on a non-centurion warns once and is forced false.",
|
|
17
17
|
"7. Launch with `verify: [\"npm run typecheck\", \"npm test\"]` when the project has a mechanical verifier. The extension runs those commands itself in the child worktree at collect time; the child does not need to paste test output.",
|
|
18
18
|
"Utility agents (`utility: true`, e.g. scout) can be dispatched by ANY role via the separate `vexillatio` tool — read-only helpers, always leaf, no depth budget. Its request shape matches `subagent`: either `vexillatio({ task, agent? })` or `vexillatio({ tasks: [{ agent, task }, …] })` (no top-level `task` needed; passing both only labels the parent run). The `subagent` tool stays for the command chain (worker/centurion).",
|
|
19
19
|
"Forbidden: direct bash access to legion.db, or launching child Pi processes by hand (use the subagent tool).",
|
package/src/extension/runtime.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { progressFromSessionFile } from "../shared/progress.ts";
|
|
|
36
36
|
import { readProcRss } from "../supervisor/proc-memory.ts";
|
|
37
37
|
import { isTransientConnectionFailure } from "../shared/session.ts";
|
|
38
38
|
import { isTerminalState, liveness } from "./liveness.ts";
|
|
39
|
+
import type { RenewalProposal } from "./notify.ts";
|
|
39
40
|
import {
|
|
40
41
|
DEFAULTS,
|
|
41
42
|
type AgentKind,
|
|
@@ -54,6 +55,7 @@ import { getSubtree } from "../legion/nodes.ts";
|
|
|
54
55
|
import { attachUsageTotals } from "../legion/usage.ts";
|
|
55
56
|
import { maxEventId } from "../legion/events.ts";
|
|
56
57
|
import type { TreeNodeSnapshot } from "../tui/tree-view.ts";
|
|
58
|
+
import type { PhaseMachine } from "../legion/phase-machine.ts";
|
|
57
59
|
|
|
58
60
|
export interface CollectSnapshot {
|
|
59
61
|
execution: { status: string; reason?: string };
|
|
@@ -149,6 +151,25 @@ export interface SessionRuntimeDeps {
|
|
|
149
151
|
getTreeNodes?: () => readonly TreeNodeSnapshot[] | undefined;
|
|
150
152
|
/** Optional name-to-tree mapping for live RPC jobs backed by M2 ChildRecord.nodeId. */
|
|
151
153
|
getTreeIdentity?: (name: string) => LegionTreeIdentity | undefined;
|
|
154
|
+
/**
|
|
155
|
+
* #298: the phase machine for a role, so the tree can show a team's own
|
|
156
|
+
* workflow. Injected rather than imported because only the host knows the
|
|
157
|
+
* resolved agent catalog (card `phases:` plus any team override).
|
|
158
|
+
*/
|
|
159
|
+
getPhaseMachine?: (role: string | null | undefined) => PhaseMachine | undefined;
|
|
160
|
+
/**
|
|
161
|
+
* Renewal proposal for a finishing child (subagent-renewal-v1 D3).
|
|
162
|
+
*
|
|
163
|
+
* Injected rather than imported: the only builder lives in `index.ts`, which
|
|
164
|
+
* already imports this module — a direct import would be a cycle. Returning
|
|
165
|
+
* `undefined` is the correct answer for every path that must NOT offer a
|
|
166
|
+
* renewal (D5 role opt-out, D1 completed, D7 still running, no baseline), so a
|
|
167
|
+
* host that never supplies this hook simply gets today's notice verbatim.
|
|
168
|
+
*/
|
|
169
|
+
getRenewalProposal?: (
|
|
170
|
+
name: string,
|
|
171
|
+
execution: { status: string; reason?: string },
|
|
172
|
+
) => RenewalProposal | undefined;
|
|
152
173
|
/** Open the live activity viewer for a node selected in the resident status widget. */
|
|
153
174
|
onStatusNodeClick?: (nodeId: string) => void;
|
|
154
175
|
/** Above-input strip style; the extension uses "railway", the default stays "tree". */
|
|
@@ -809,7 +830,7 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
|
|
|
809
830
|
for (const nodeId of eventOverrides.keys()) if (!tree.has(nodeId)) eventOverrides.delete(nodeId);
|
|
810
831
|
}
|
|
811
832
|
const nodes = [...tree.values()].map((node) => ({ ...node }));
|
|
812
|
-
return mergeSpawnModes(nodes);
|
|
833
|
+
return mergePhaseMachines(mergeSpawnModes(nodes));
|
|
813
834
|
};
|
|
814
835
|
|
|
815
836
|
/**
|
|
@@ -834,6 +855,32 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
|
|
|
834
855
|
return nodes;
|
|
835
856
|
};
|
|
836
857
|
|
|
858
|
+
/**
|
|
859
|
+
* #298: attach the declaring team's machine to the node that owns it.
|
|
860
|
+
*
|
|
861
|
+
* Only a team root declares `phases:`, so children inherit it downward: showing
|
|
862
|
+
* an empty machine on a worker would suggest it has none, when in fact it is
|
|
863
|
+
* governed by its lead's. The nearest ancestor that resolves a machine wins.
|
|
864
|
+
*/
|
|
865
|
+
const mergePhaseMachines = (nodes: TreeNodeSnapshot[]): TreeNodeSnapshot[] => {
|
|
866
|
+
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
867
|
+
for (const node of nodes) {
|
|
868
|
+
let cursor: TreeNodeSnapshot | undefined = node;
|
|
869
|
+
let hops = 0;
|
|
870
|
+
while (cursor && hops < 64) {
|
|
871
|
+
const machine = deps.getPhaseMachine?.(cursor.role);
|
|
872
|
+
if (machine) {
|
|
873
|
+
node.phaseMachine = machine;
|
|
874
|
+
break;
|
|
875
|
+
}
|
|
876
|
+
const parentId: string | null = cursor.parentId ?? null;
|
|
877
|
+
cursor = parentId ? byId.get(parentId) : undefined;
|
|
878
|
+
hops += 1;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return nodes;
|
|
882
|
+
};
|
|
883
|
+
|
|
837
884
|
const storedNodeForJob = (
|
|
838
885
|
job: TrackedJob,
|
|
839
886
|
nodes: readonly TreeNodeSnapshot[],
|
|
@@ -1466,6 +1513,14 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
|
|
|
1466
1513
|
recycled: shouldRecycleAfterCollect(
|
|
1467
1514
|
snapshot.execution.status,
|
|
1468
1515
|
),
|
|
1516
|
+
// D3: only the terminating path attaches a proposal. The
|
|
1517
|
+
// hook itself returns undefined for every case that must not
|
|
1518
|
+
// offer one (completed / still-running / opted out / no
|
|
1519
|
+
// baseline), so "absent" is meaningful, not a missing wire.
|
|
1520
|
+
...(() => {
|
|
1521
|
+
const renewal = deps.getRenewalProposal?.(job.name, snapshot.execution);
|
|
1522
|
+
return renewal === undefined ? {} : { renewal };
|
|
1523
|
+
})(),
|
|
1469
1524
|
},
|
|
1470
1525
|
triggerTurn,
|
|
1471
1526
|
});
|
package/src/extension/summary.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
RunStore,
|
|
18
18
|
pickChildByName,
|
|
19
19
|
} from "../runs/store.ts";
|
|
20
|
+
import { resolveChildAcrossStores, unknownChildMessage } from "../runs/store-lookup.ts";
|
|
20
21
|
import type {
|
|
21
22
|
ChildRecord,
|
|
22
23
|
ExecutionStatus,
|
|
@@ -715,14 +716,22 @@ function summaryCompletions(
|
|
|
715
716
|
.map((name) => ({ value: all ? `--all ${name}` : name, label: name }));
|
|
716
717
|
}
|
|
717
718
|
|
|
719
|
+
/**
|
|
720
|
+
* Miss reporting for `/legion summary <name>` (#288).
|
|
721
|
+
*
|
|
722
|
+
* Was a hand-rolled sentence telling the caller to "pass the same cwd used for
|
|
723
|
+
* launch" -- advice that the cross-store lookup had already made obsolete, and
|
|
724
|
+
* advice the caller could not act on anyway (they do not know that cwd).
|
|
725
|
+
*
|
|
726
|
+
* Now it runs the same lookup `status` uses and prints `unknownChildMessage`,
|
|
727
|
+
* so every named operation reports a miss identically and lists what WAS found.
|
|
728
|
+
*/
|
|
718
729
|
function notifyUnknownChild(
|
|
719
730
|
ctx: ExtensionCommandContext,
|
|
720
731
|
name: string,
|
|
721
732
|
): void {
|
|
722
|
-
ctx.
|
|
723
|
-
|
|
724
|
-
"error",
|
|
725
|
-
);
|
|
733
|
+
const { searched } = resolveChildAcrossStores({ name, cwd: ctx.cwd });
|
|
734
|
+
ctx.ui.notify(unknownChildMessage({ name, cwd: ctx.cwd, searched }), "error");
|
|
726
735
|
}
|
|
727
736
|
|
|
728
737
|
/** Build the `/legion summary [--all] [<name>]` handler. */
|
package/src/extension/timing.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
pickChildByName,
|
|
19
19
|
} from "../runs/store.ts";
|
|
20
20
|
import { sendSlashText, type LegionSubcommand } from "./slash.ts";
|
|
21
|
+
import { resolveChildAcrossStores, unknownChildMessage } from "../runs/store-lookup.ts";
|
|
21
22
|
import {
|
|
22
23
|
aggregateSubagentRuns,
|
|
23
24
|
formatDuration,
|
|
@@ -318,14 +319,17 @@ function timingCompletions(
|
|
|
318
319
|
}));
|
|
319
320
|
}
|
|
320
321
|
|
|
322
|
+
/**
|
|
323
|
+
* Miss reporting for `/legion timing <name>` (#288). Same cross-store lookup and
|
|
324
|
+
* same message as `status` and `summary`, so a miss reads identically everywhere
|
|
325
|
+
* and reports what was actually searched instead of advising an unknowable cwd.
|
|
326
|
+
*/
|
|
321
327
|
function notifyUnknownChild(
|
|
322
328
|
ctx: ExtensionCommandContext,
|
|
323
329
|
name: string,
|
|
324
330
|
): void {
|
|
325
|
-
ctx.
|
|
326
|
-
|
|
327
|
-
"error",
|
|
328
|
-
);
|
|
331
|
+
const { searched } = resolveChildAcrossStores({ name, cwd: ctx.cwd });
|
|
332
|
+
ctx.ui.notify(unknownChildMessage({ name, cwd: ctx.cwd, searched }), "error");
|
|
329
333
|
}
|
|
330
334
|
|
|
331
335
|
function findTimingChild(
|