pi-legion 5.0.1 → 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 +858 -0
- package/CONTRIBUTING.md +33 -2
- package/README.md +10 -5
- package/agents/centurion.md +32 -7
- package/agents/scout.md +11 -0
- package/docs/contracts/abort-soft-interrupt-v1.md +162 -0
- package/docs/contracts/centurion-declared-phases-v1.md +104 -0
- package/docs/contracts/declarative-phase-machine-v5.md +43 -0
- package/docs/contracts/extension-inheritance-wiring-v1.md +88 -0
- package/docs/contracts/finished-cache-eviction-v1.md +169 -0
- package/docs/contracts/galaxy-scratch-v1.md +78 -0
- package/docs/contracts/legion-chart-frame-buckets-v1.md +201 -0
- package/docs/contracts/legion-main-screen-mount-v1.md +161 -0
- package/docs/contracts/legion-page-floor-v1.md +129 -0
- package/docs/contracts/legion-session-continuity-v1.md +118 -0
- package/docs/contracts/mailbox-delivery-v1.md +162 -0
- package/docs/contracts/notice-claim-v1.md +206 -0
- package/docs/contracts/partial-failure-exit-v1.md +235 -0
- package/docs/contracts/proc-sweep-v1.md +107 -0
- package/docs/contracts/provider-empty-generation-retry-v1.md +161 -0
- package/docs/contracts/stall-visibility-v1.md +218 -0
- package/docs/contracts/subagent-renewal-v1.md +5 -3
- package/docs/contracts/vexillatio-nonblocking-launch-v1.md +1 -0
- package/docs/contracts/viz-frame-alloc-v1.md +123 -0
- package/docs/contracts/wait-mail-preempt-v1.md +117 -0
- package/docs/contracts/wait-not-stop.md +1 -1
- package/docs/design.md +4 -1
- package/docs/inprocess-supervisor.md +9 -3
- package/docs/legion-v2-contract.md +122 -20
- package/docs/vexillatio-contract.md +5 -1
- package/index.ts +1903 -130
- package/package.json +5 -5
- package/skills/pi-legion/SKILL.md +2 -1
- package/src/agents/agents.ts +26 -1
- package/src/agents/settings.ts +67 -2
- package/src/api.ts +17 -0
- package/src/extension/child-guard.ts +2 -1
- package/src/extension/join.ts +54 -0
- package/src/extension/legion-commands.ts +15 -4
- package/src/extension/legion-doctor.ts +48 -6
- package/src/extension/legion-status.ts +44 -2
- package/src/extension/legion-tools.ts +1044 -109
- package/src/extension/notify.ts +31 -2
- package/src/extension/playbook.ts +6 -3
- package/src/extension/runtime.ts +882 -81
- package/src/extension/spawn-hints.ts +52 -12
- package/src/extension/summary.ts +7 -15
- package/src/extension/timing.ts +4 -15
- package/src/legion/db.ts +23 -1
- package/src/legion/events.ts +7 -0
- package/src/legion/interrupt.ts +98 -0
- package/src/legion/mail-release.ts +266 -0
- package/src/legion/mail.ts +456 -29
- package/src/legion/nodes.ts +31 -0
- package/src/legion/phase-conditions.ts +7 -1
- package/src/legion/phase-machine.ts +160 -8
- package/src/legion/state-machine.ts +34 -2
- package/src/runs/acceptance.ts +105 -6
- package/src/runs/args.ts +16 -0
- package/src/runs/child-scope.ts +323 -0
- package/src/runs/extension-inheritance.ts +145 -19
- package/src/runs/orchestrator.ts +613 -55
- package/src/runs/store.ts +149 -5
- package/src/shared/proc-sweep.ts +317 -0
- package/src/shared/progress.ts +11 -2
- package/src/shared/session-map.ts +29 -0
- package/src/shared/session.ts +308 -6
- package/src/shared/types.ts +34 -0
- package/src/supervisor/inprocess-supervisor.ts +158 -12
- package/src/supervisor/race-timeout.ts +55 -0
- package/src/supervisor/routing-supervisor.ts +55 -4
- package/src/supervisor/rpc-supervisor.ts +215 -29
- package/src/supervisor/types.ts +439 -2
- package/src/tui/status.ts +2 -1
- package/src/tui/viz/canvas.ts +358 -36
- package/src/tui/viz/palette.ts +18 -2
- package/src/tui/viz/panels.ts +507 -104
- package/src/tui/viz/rate.ts +120 -15
- package/src/tui/viz/view.ts +72 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-legion",
|
|
3
|
-
"version": "5.0
|
|
3
|
+
"version": "5.2.0",
|
|
4
4
|
"description": "Pi Legion v2: recursive centurion teams on Pi. Children run in-process by default (rpc only for a worktree-owning centurion); shared SQLite WAL legion.db. Declarative per-team phase machine (built-in default: planning/implementing/reviewing/verifying), parent/child/sibling legion_mail, three-layer /legion UI, upward MR trees, squad resume.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -87,10 +87,10 @@
|
|
|
87
87
|
}
|
|
88
88
|
},
|
|
89
89
|
"devDependencies": {
|
|
90
|
-
"@earendil-works/pi-agent-core": "^1.
|
|
91
|
-
"@earendil-works/pi-ai": "^1.
|
|
92
|
-
"@earendil-works/pi-coding-agent": "^1.
|
|
93
|
-
"@earendil-works/pi-tui": "^1.
|
|
90
|
+
"@earendil-works/pi-agent-core": "^1.4.0",
|
|
91
|
+
"@earendil-works/pi-ai": "^1.4.0",
|
|
92
|
+
"@earendil-works/pi-coding-agent": "^1.4.0",
|
|
93
|
+
"@earendil-works/pi-tui": "^1.4.0",
|
|
94
94
|
"@types/node": "^22.0.0",
|
|
95
95
|
"typebox": "^1.3.30",
|
|
96
96
|
"typescript": "^5.6.0"
|
|
@@ -70,7 +70,8 @@ commands under the supervisor, without an LLM deciding whether they passed.
|
|
|
70
70
|
|
|
71
71
|
A centurion owns a Git worktree for its subtree; squad members share that
|
|
72
72
|
worktree, and a nested centurion gets a nested Git worktree. Each squad
|
|
73
|
-
squashes its subtree
|
|
73
|
+
squashes its subtree onto a branch for its parent — the branch is the deliverable, and an
|
|
74
|
+
MR is added only when the repository has a remote. Deliveries move upward until the root
|
|
74
75
|
reviews and merges into the default branch. Do not merge a child squad's work
|
|
75
76
|
at the wrong level.
|
|
76
77
|
|
package/src/agents/agents.ts
CHANGED
|
@@ -31,10 +31,12 @@ import {
|
|
|
31
31
|
import { getAgentDir } from "./paths.ts";
|
|
32
32
|
import { ignoredKindMessage, reportIgnoredOnce } from "../shared/ignored-kind.ts";
|
|
33
33
|
import {
|
|
34
|
+
canReachTerminal,
|
|
34
35
|
type PhaseDefinition,
|
|
35
36
|
type PhaseMachine,
|
|
36
37
|
type PhaseTransitionRule,
|
|
37
38
|
normalizePhaseMachine,
|
|
39
|
+
phaseStart,
|
|
38
40
|
validatePhaseMachine,
|
|
39
41
|
} from "../legion/phase-machine.ts";
|
|
40
42
|
import type { PhaseCondition } from "../legion/phase-conditions.ts";
|
|
@@ -528,8 +530,21 @@ function parsePhaseBlock(text: string): PhaseMachine | undefined {
|
|
|
528
530
|
* Validation happens HERE, at load, and every problem is reported at once. A
|
|
529
531
|
* machine that only fails mid-run would surface after a team had already spent
|
|
530
532
|
* tokens on a workflow that could never complete.
|
|
533
|
+
*
|
|
534
|
+
* Exported since #371: the SAME parser reads a declaration handed to the
|
|
535
|
+
* `subagent` tool at dispatch time, so the card layer and the inline layer can
|
|
536
|
+
* never disagree about what a legal machine is.
|
|
537
|
+
*
|
|
538
|
+
* `requireTerminal` adds the one question this parser does not ask for a card:
|
|
539
|
+
* can the DECLARED graph actually reach a terminal phase (#371 G4)? A card has
|
|
540
|
+
* never been checked for it and its rules are frozen, so the check is opt-in
|
|
541
|
+
* rather than folded into `validatePhaseMachine`.
|
|
531
542
|
*/
|
|
532
|
-
function parsePhaseMachine(
|
|
543
|
+
export function parsePhaseMachine(
|
|
544
|
+
value: unknown,
|
|
545
|
+
filePath: string,
|
|
546
|
+
options: { requireTerminal?: boolean } = {},
|
|
547
|
+
): PhaseMachine | undefined {
|
|
533
548
|
if (value === undefined || value === null) return undefined;
|
|
534
549
|
let raw: unknown = value;
|
|
535
550
|
if (typeof value === "string") {
|
|
@@ -570,6 +585,16 @@ function parsePhaseMachine(value: unknown, filePath: string): PhaseMachine | und
|
|
|
570
585
|
// on the machine as it will actually run.
|
|
571
586
|
const normalized = normalizePhaseMachine(machine);
|
|
572
587
|
const errors = validatePhaseMachine(normalized);
|
|
588
|
+
// #371 G4: a well-formed graph may still be a loop. Asked only when the graph
|
|
589
|
+
// is otherwise valid (a structural error already names the real problem), and
|
|
590
|
+
// asked of the DECLARED graph — `normalizePhaseMachine` appends `done` to every
|
|
591
|
+
// non-terminal phase, so the normalized machine answers "yes" by construction.
|
|
592
|
+
if (options.requireTerminal === true && errors.length === 0 && !canReachTerminal(machine)) {
|
|
593
|
+
errors.push({
|
|
594
|
+
path: "phases",
|
|
595
|
+
message: `phase machine cannot reach a terminal phase from '${phaseStart(machine)}'`,
|
|
596
|
+
});
|
|
597
|
+
}
|
|
573
598
|
if (errors.length > 0) {
|
|
574
599
|
const detail = errors.map((error) => ` - ${error.path}: ${error.message}`).join("\n");
|
|
575
600
|
throw new Error(`Agent '${filePath}' has an invalid phase machine:\n${detail}`);
|
package/src/agents/settings.ts
CHANGED
|
@@ -135,7 +135,16 @@ const LEGION_SETTING_FIELDS = [
|
|
|
135
135
|
"orphanStaleMs",
|
|
136
136
|
] as const satisfies readonly (keyof LegionSettings)[];
|
|
137
137
|
|
|
138
|
-
|
|
138
|
+
/**
|
|
139
|
+
* Which `legion` values the operator actually wrote down (#358 review, major 1).
|
|
140
|
+
*
|
|
141
|
+
* `parseLegionSettings` FILLS every default so a consumer never has to invent a
|
|
142
|
+
* number, and that fill is indistinguishable from a decision once it has been
|
|
143
|
+
* spread into a plain object. Anything that treats "the setting is present" as
|
|
144
|
+
* "the operator chose it" therefore sees a choice that was never made — which is
|
|
145
|
+
* exactly how a card's own `timeoutMs` on a built-in phase came to be ignored.
|
|
146
|
+
*/
|
|
147
|
+
export interface LegionExplicitFields {
|
|
139
148
|
fields: Set<keyof LegionSettings>;
|
|
140
149
|
phaseTimeouts: Set<(typeof LEGION_PHASES)[number]>;
|
|
141
150
|
}
|
|
@@ -253,7 +262,16 @@ function parseLegionSettings(
|
|
|
253
262
|
return out;
|
|
254
263
|
}
|
|
255
264
|
|
|
256
|
-
|
|
265
|
+
/**
|
|
266
|
+
* Read the operator's own decisions out of a `legion` block.
|
|
267
|
+
*
|
|
268
|
+
* A settings object produced by {@link parseLegionSettings} / {@link mergeLegionSettings}
|
|
269
|
+
* carries them on a symbol; a hand-built object (a test, a caller) falls back to
|
|
270
|
+
* "an own key means explicit", which is the only thing that is observable there.
|
|
271
|
+
* Read this — never `phaseTimeouts?.[phase] !== undefined` — to answer "did a
|
|
272
|
+
* human set this clock?".
|
|
273
|
+
*/
|
|
274
|
+
export function legionExplicitFields(
|
|
257
275
|
settings: LegionSettings | undefined,
|
|
258
276
|
): LegionExplicitFields {
|
|
259
277
|
if (!settings) return { fields: new Set(), phaseTimeouts: new Set() };
|
|
@@ -272,6 +290,53 @@ function legionExplicitFields(
|
|
|
272
290
|
};
|
|
273
291
|
}
|
|
274
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Did the operator set this phase's advisory clock by hand (#358 review, major 1)?
|
|
295
|
+
*
|
|
296
|
+
* The answer consumers need is about the DECISION, not about the value being
|
|
297
|
+
* present: a filled-in default is present too. `phase` is free-form because a
|
|
298
|
+
* declared machine names its own phases.
|
|
299
|
+
*/
|
|
300
|
+
export function isExplicitPhaseTimeout(
|
|
301
|
+
settings: LegionSettings | undefined,
|
|
302
|
+
phase: string,
|
|
303
|
+
): boolean {
|
|
304
|
+
return (legionExplicitFields(settings).phaseTimeouts as ReadonlySet<string>).has(phase);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The `legion` settings a run actually uses, defaults filled (#358 review, major 1).
|
|
309
|
+
*
|
|
310
|
+
* The fill is what lets every consumer read a number, but it must not masquerade
|
|
311
|
+
* as an operator decision: `checkOwnPhaseTimeout` asks
|
|
312
|
+
* {@link legionExplicitFields} whether a phase clock was set by a human, and a
|
|
313
|
+
* built-in default that arrived by being spread in would answer yes. This is the
|
|
314
|
+
* seam that keeps the two apart — replacing it with a plain spread silently turns
|
|
315
|
+
* `settings > machine > default` into `settings(filled) > nothing`, i.e. a card's
|
|
316
|
+
* `timeoutMs` on a built-in phase name never wins.
|
|
317
|
+
*/
|
|
318
|
+
export function legionSettingsForRun(
|
|
319
|
+
settings: SubagentsSettings | undefined,
|
|
320
|
+
): LegionSettings {
|
|
321
|
+
const legion = settings?.legion;
|
|
322
|
+
const resolved: TrackedLegionSettings = {
|
|
323
|
+
...DEFAULT_LEGION_SETTINGS,
|
|
324
|
+
...(legion ?? {}),
|
|
325
|
+
phaseTimeouts: {
|
|
326
|
+
...DEFAULT_LEGION_SETTINGS.phaseTimeouts,
|
|
327
|
+
...(legion?.phaseTimeouts ?? {}),
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
const explicit = legionExplicitFields(legion);
|
|
331
|
+
Object.defineProperty(resolved, LEGION_EXPLICIT_FIELDS, {
|
|
332
|
+
value: {
|
|
333
|
+
fields: new Set(explicit.fields),
|
|
334
|
+
phaseTimeouts: new Set(explicit.phaseTimeouts),
|
|
335
|
+
} satisfies LegionExplicitFields,
|
|
336
|
+
});
|
|
337
|
+
return resolved;
|
|
338
|
+
}
|
|
339
|
+
|
|
275
340
|
function mergeLegionSettings(
|
|
276
341
|
user: LegionSettings | undefined,
|
|
277
342
|
project: LegionSettings | undefined,
|
package/src/api.ts
CHANGED
|
@@ -187,8 +187,24 @@ export {
|
|
|
187
187
|
} from "./runs/worktree.ts";
|
|
188
188
|
|
|
189
189
|
// ── parent session: completion notify + input-box status ────────────────────
|
|
190
|
+
//
|
|
191
|
+
// `SessionRuntime.consumeCollect*` answers with `ClaimedCollect`, not a bare
|
|
192
|
+
// snapshot (#320):
|
|
193
|
+
//
|
|
194
|
+
// ```ts
|
|
195
|
+
// const { snapshot, claimedNow } = await rt.consumeCollect("worker-0")!;
|
|
196
|
+
// ```
|
|
197
|
+
//
|
|
198
|
+
// `claimedNow` is `true` only when THIS call took the round's completion claim, so a
|
|
199
|
+
// caller that bails before handing the result to the agent (an aborted parent turn, a
|
|
200
|
+
// failed persist) rolls it back with `unclaimUndelivered(name, snapshot, claimedNow)`
|
|
201
|
+
// — a call that did not take the claim must leave the claim of the delivery that did
|
|
202
|
+
// alone. The flag travels with the return value on purpose: two collects can await the
|
|
203
|
+
// same job promise and therefore see the same snapshot object, so a per-snapshot record
|
|
204
|
+
// would let the second call overwrite the first one's answer.
|
|
190
205
|
export { createSessionRuntime, shouldRecycleAfterCollect } from "./extension/runtime.ts";
|
|
191
206
|
export type {
|
|
207
|
+
ClaimedCollect,
|
|
192
208
|
CollectSnapshot,
|
|
193
209
|
SessionRuntime,
|
|
194
210
|
SessionRuntimeDeps,
|
|
@@ -261,6 +277,7 @@ export {
|
|
|
261
277
|
extractVerdict,
|
|
262
278
|
isLastTurnComplete,
|
|
263
279
|
parseSessionFile,
|
|
280
|
+
parseSessionFileCached,
|
|
264
281
|
parseSessionText,
|
|
265
282
|
} from "./shared/session.ts";
|
|
266
283
|
export {
|
|
@@ -34,7 +34,8 @@ export function childTaskAppendix(opts?: {
|
|
|
34
34
|
const worktree = opts?.worktreeBranch
|
|
35
35
|
? [
|
|
36
36
|
`- You are in an isolated git worktree on branch \`${opts.worktreeBranch}\`. Do not write the parent checkout.`,
|
|
37
|
-
"- Commit on this branch
|
|
37
|
+
"- Commit on this branch. The branch is the deliverable: put its name and HEAD sha in your verdict reason. Do not merge locally; do not push to main or master.",
|
|
38
|
+
"- A merge request / pull request is a convenience, not a requirement: open one only when the repository has a remote and `gh`/`glab` is available, and then add its URL to the verdict. With no remote, write `no remote: branch <name> at <sha>`; never invent a URL, and a missing MR is not a failure.",
|
|
38
39
|
]
|
|
39
40
|
: [];
|
|
40
41
|
return [
|
package/src/extension/join.ts
CHANGED
|
@@ -51,6 +51,13 @@ function defaultJoinConfig(): JoinConfig {
|
|
|
51
51
|
export interface JoinEntry {
|
|
52
52
|
runId: string;
|
|
53
53
|
name: string;
|
|
54
|
+
/**
|
|
55
|
+
* Reporting round of this child (D10). The runtime assigns it from a per-name
|
|
56
|
+
* monotonic counter in `track()`; the coordinator only carries it, because one
|
|
57
|
+
* round is one deliverable result — two unread rounds of the same child must
|
|
58
|
+
* both survive the buffer, which a `(runId, name)` key cannot express.
|
|
59
|
+
*/
|
|
60
|
+
round: number;
|
|
54
61
|
/** Raw collect result; the deliver sink feeds it to formatGroupedNotice. */
|
|
55
62
|
input: CompletionInput;
|
|
56
63
|
/** From the triggering watch() call, forwarded to the delivery options. */
|
|
@@ -127,6 +134,37 @@ export class JoinCoordinator {
|
|
|
127
134
|
}
|
|
128
135
|
}
|
|
129
136
|
|
|
137
|
+
/**
|
|
138
|
+
* D8: drop the already-TERMINAL entries of one member.
|
|
139
|
+
*
|
|
140
|
+
* `remove()` only clears `pending` (a member that has already finished lives in
|
|
141
|
+
* `buffered`, not in `pending`), so a released child left its buffered entry
|
|
142
|
+
* behind as an orphan that fired at window expiry (W3) — most visibly right
|
|
143
|
+
* after a `wait()` hit, which retires and releases the very child whose entry
|
|
144
|
+
* is sitting in the window. Semantics of `remove()` are otherwise unchanged.
|
|
145
|
+
*/
|
|
146
|
+
dropBuffered(runId: string, name: string): void {
|
|
147
|
+
this.dropBufferedWhere(runId, (entry) => entry.name === name);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* D10: drop the terminal entries of ONE ROUND of one member.
|
|
152
|
+
*
|
|
153
|
+
* This is what `release()` needs. `release()` acts on a single tracked job, and
|
|
154
|
+
* a job carries exactly one round, so the copy it may drop is the one that round
|
|
155
|
+
* produced. The name-wide `dropBuffered` above is the REVOKE path (D7): a parent
|
|
156
|
+
* asking to see the child again invalidates every copy of the name. Using the
|
|
157
|
+
* name-wide form from `release()` would silently swallow an EARLIER round's
|
|
158
|
+
* unread notice — the loss D10 exists to prevent (one round = one deliverable
|
|
159
|
+
* result).
|
|
160
|
+
*/
|
|
161
|
+
dropBufferedRound(runId: string, name: string, round: number): void {
|
|
162
|
+
this.dropBufferedWhere(
|
|
163
|
+
runId,
|
|
164
|
+
(entry) => entry.name === name && entry.round === round,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
130
168
|
/** Terminal entry point (success and collect-failure alike). */
|
|
131
169
|
onTerminal(entry: JoinEntry): void {
|
|
132
170
|
if (this.disposed) return;
|
|
@@ -184,6 +222,22 @@ export class JoinCoordinator {
|
|
|
184
222
|
this.groupOf.clear();
|
|
185
223
|
}
|
|
186
224
|
|
|
225
|
+
/** One implementation for both drops: keep every entry the predicate rejects. */
|
|
226
|
+
private dropBufferedWhere(
|
|
227
|
+
runId: string,
|
|
228
|
+
matches: (entry: BufferedEntry) => boolean,
|
|
229
|
+
): void {
|
|
230
|
+
if (this.disposed) return;
|
|
231
|
+
const group = this.groups.get(runId);
|
|
232
|
+
if (!group || group.buffered.length === 0) return;
|
|
233
|
+
const kept = group.buffered.filter((entry) => !matches(entry));
|
|
234
|
+
if (kept.length === group.buffered.length) return;
|
|
235
|
+
group.buffered = kept;
|
|
236
|
+
if (group.pending.size === 0 && group.buffered.length === 0) {
|
|
237
|
+
this.dropGroup(runId, group);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
187
241
|
private ensureGroup(runId: string): Group {
|
|
188
242
|
let group = this.groups.get(runId);
|
|
189
243
|
if (!group) {
|
|
@@ -727,6 +727,20 @@ export function registerLegionCommand(
|
|
|
727
727
|
if (classicTree) query = "";
|
|
728
728
|
const vizSnapshot = deps.runtime.vizSnapshot?.bind(deps.runtime);
|
|
729
729
|
if (!query && !classicTree && vizSnapshot) {
|
|
730
|
+
// #347: mount the page on the main screen instead of as a full-screen
|
|
731
|
+
// overlay. pi-tui composites *every* row of a visible overlay through
|
|
732
|
+
// `compositeTuiLine` (three `Intl.Segmenter` grapheme walks, cached by
|
|
733
|
+
// whole line, which a live frame never repeats); for this page — 56–68%
|
|
734
|
+
// non-ASCII braille/box/block cells — that was 57–70% of the frame and
|
|
735
|
+
// ~183µs/row for the whole terminal height. A main-screen mount (the
|
|
736
|
+
// mount the classic `--tree` view below already uses) replaces
|
|
737
|
+
// the base instead of stacking on it, so the page costs one plain
|
|
738
|
+
// `render()` and nothing else: measured 2.5–2.7× on the same component
|
|
739
|
+
// and data, with the frame's bytes within 3% (`bench/perf-core/09`).
|
|
740
|
+
// Esc/q still return to the session view: the host restores the editor
|
|
741
|
+
// it swapped out. The detail modal opened from inside the page stays a
|
|
742
|
+
// 75% floating overlay (`openLiveOverlay` below).
|
|
743
|
+
// Contract: `docs/contracts/legion-main-screen-mount-v1.md`.
|
|
730
744
|
await ctx.ui.custom(createLegionViewFactory({
|
|
731
745
|
getData: () => vizSnapshot(),
|
|
732
746
|
onStopNode: deps.stop ? (nodeId) => void confirmStopNode(ctx, nodeId) : undefined,
|
|
@@ -735,10 +749,7 @@ export function registerLegionCommand(
|
|
|
735
749
|
openLiveOverlay(pi, ctx.ui, ctx.cwd, liveOverlay, nodeId, overlayStop(ctx.ui));
|
|
736
750
|
}
|
|
737
751
|
},
|
|
738
|
-
}), {
|
|
739
|
-
overlay: true,
|
|
740
|
-
overlayOptions: { width: "100%", maxHeight: "100%", anchor: "top-left", row: 0, col: 0 },
|
|
741
|
-
});
|
|
752
|
+
}), { overlay: false });
|
|
742
753
|
return;
|
|
743
754
|
}
|
|
744
755
|
if (query) {
|
|
@@ -25,6 +25,10 @@ import {
|
|
|
25
25
|
roleToolGateNextSteps,
|
|
26
26
|
} from "../runs/role-tool-gates.ts";
|
|
27
27
|
import { getAgentDir } from "../agents/paths.ts";
|
|
28
|
+
import {
|
|
29
|
+
hostExtensionDiscoveryState,
|
|
30
|
+
type HostExtensionDiscoveryState,
|
|
31
|
+
} from "../runs/extension-inheritance.ts";
|
|
28
32
|
import {
|
|
29
33
|
CHILD_MAX_OLD_SPACE_ENV,
|
|
30
34
|
CHILD_RESERVE_ENV,
|
|
@@ -355,11 +359,19 @@ function sampleAgentConfig(agent?: AgentConfig): AgentConfig {
|
|
|
355
359
|
}
|
|
356
360
|
|
|
357
361
|
|
|
358
|
-
/**
|
|
362
|
+
/**
|
|
363
|
+
* Fix text for inprocess model/provider gaps (#212, #329).
|
|
364
|
+
*
|
|
365
|
+
* #329: same two levers as `spawn-hints.ts` (#317/#328) — `models.json`, or the
|
|
366
|
+
* `PI_LEGION_CHILD_EXTENSIONS` path list. The dead switch (`inheritExtensions: true`,
|
|
367
|
+
* `PI_LEGION_CHILD_INHERIT_EXTENSIONS=1`) must not come back here: `true` is the default
|
|
368
|
+
* policy since #280, so it changes nothing, and when host discovery fails it still yields
|
|
369
|
+
* 0 extensions — the very state doctor is reporting on.
|
|
370
|
+
*/
|
|
359
371
|
export function inprocessModelFix(): string {
|
|
360
372
|
return (
|
|
361
373
|
"Declare the provider statically in models.json (agent dir / project), " +
|
|
362
|
-
|
|
374
|
+
`or pass the provider extension explicitly with ${CHILD_EXTENSIONS_ENV}=<path>.`
|
|
363
375
|
);
|
|
364
376
|
}
|
|
365
377
|
|
|
@@ -368,7 +380,7 @@ function rpcChildExtensionsFix(providerExtHome: readonly string[]): string | und
|
|
|
368
380
|
if (providerExtHome.length) {
|
|
369
381
|
return `export ${CHILD_EXTENSIONS_ENV}=${providerExtHome.join(",")}`;
|
|
370
382
|
}
|
|
371
|
-
return `Add the provider package extension path to ${CHILD_EXTENSIONS_ENV}, or
|
|
383
|
+
return `Add the provider package extension path to ${CHILD_EXTENSIONS_ENV}, or add it under the role's extensions: frontmatter.`;
|
|
372
384
|
}
|
|
373
385
|
|
|
374
386
|
function previewChildArgv(agent: AgentConfig, env: NodeJS.ProcessEnv): string[] {
|
|
@@ -427,6 +439,29 @@ function extensionsForRole(agent: AgentConfig, env: NodeJS.ProcessEnv, _extras:
|
|
|
427
439
|
return collectChildExtensions(agent, env);
|
|
428
440
|
}
|
|
429
441
|
|
|
442
|
+
/**
|
|
443
|
+
* One-line answer to "is inheritance actually wired?" (#317).
|
|
444
|
+
*
|
|
445
|
+
* The role line above only says `(all host-discovered extensions)`, which reads
|
|
446
|
+
* identically whether discovery worked, failed, or never ran. This turns the
|
|
447
|
+
* three cases into text: `ok`, `failed <n>s ago` (with the age, because the
|
|
448
|
+
* backoff is 5 s and a stale stamp is a different diagnosis than a live one),
|
|
449
|
+
* and `never` (no probe in this process — the common shape for a doctor run
|
|
450
|
+
* before the first spawn).
|
|
451
|
+
*/
|
|
452
|
+
function formatDiscoveryProbe(state: HostExtensionDiscoveryState, now = Date.now()): string {
|
|
453
|
+
switch (state.status) {
|
|
454
|
+
case "ok":
|
|
455
|
+
return "ok";
|
|
456
|
+
case "failed": {
|
|
457
|
+
const ageS = Math.max(0, Math.floor((now - (state.at ?? now)) / 1000));
|
|
458
|
+
return `failed ${ageS}s ago`;
|
|
459
|
+
}
|
|
460
|
+
default:
|
|
461
|
+
return "never";
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
430
465
|
function scanOrphanTemps(storeRoot: string): { total: number; orphanDead: number; live: number; unknown: number; samples: string[] } {
|
|
431
466
|
const runsRoot = path.join(storeRoot, "runs");
|
|
432
467
|
let total = 0;
|
|
@@ -790,6 +825,12 @@ export function runLegionDoctor(options: DoctorOptions = {}): DoctorReport {
|
|
|
790
825
|
push("## Extensions");
|
|
791
826
|
push(`- spawnMode (sample/default): ${spawnMode}`);
|
|
792
827
|
push(`- inheritExtensions (env/role default): ${inheritGlobal ? "yes (loads all host extensions)" : "no"}`);
|
|
828
|
+
// #317: `(all host-discovered extensions)` alone cannot say whether inheritance
|
|
829
|
+
// is wired — failed discovery, an empty host and "never probed" all looked the
|
|
830
|
+
// same. The count comes from the last probe in THIS process; doctor never
|
|
831
|
+
// probes on its own, so `never` is honest and cheap.
|
|
832
|
+
const discovery = hostExtensionDiscoveryState();
|
|
833
|
+
push(`- discovered host extensions: ${discovery.count} (last probe: ${formatDiscoveryProbe(discovery)})`);
|
|
793
834
|
if (isInprocess) {
|
|
794
835
|
push("- child argv --no-extensions: n/a (inprocess has no child argv)");
|
|
795
836
|
} else {
|
|
@@ -1035,15 +1076,16 @@ export function runLegionDoctor(options: DoctorOptions = {}): DoctorReport {
|
|
|
1035
1076
|
const modelIssue = issues.some((i) =>
|
|
1036
1077
|
i.code === "CHILD_NO_MODELS" || i.code === "TEAM_MODELS_MISSING" || i.code === "RECENT_MODEL_SPAWN_FAIL",
|
|
1037
1078
|
);
|
|
1079
|
+
// #329: never name the dead inheritance switch here (see `inprocessModelFix`).
|
|
1038
1080
|
if (modelIssue && isInprocess) {
|
|
1039
1081
|
steps.push(
|
|
1040
|
-
|
|
1082
|
+
`Inprocess: declare the provider in models.json, or pass the provider extension with ${CHILD_EXTENSIONS_ENV}=<path> — then restart pi (#212).`,
|
|
1041
1083
|
);
|
|
1042
1084
|
} else if (modelIssue && providerExtHome.length) {
|
|
1043
1085
|
steps.push(`export ${CHILD_EXTENSIONS_ENV}=${providerExtHome.join(",")} # then restart pi`);
|
|
1044
|
-
steps.push(`# or role frontmatter extensions: […]
|
|
1086
|
+
steps.push(`# or role frontmatter extensions: […] (role-scoped only)`);
|
|
1045
1087
|
} else if (modelIssue) {
|
|
1046
|
-
steps.push(`# set role frontmatter extensions: […]
|
|
1088
|
+
steps.push(`# set role frontmatter extensions: […], or export ${CHILD_EXTENSIONS_ENV}=<paths>`);
|
|
1047
1089
|
}
|
|
1048
1090
|
steps.push(...roleToolGateNextSteps(issues));
|
|
1049
1091
|
if (issues.some((i) => i.code === "MEMORY_GATE_WOULD_REFUSE")) {
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { NODE_STATUSES, type LegionNode } from "../legion/nodes.ts";
|
|
10
|
+
import type { InFlightToolStack } from "../supervisor/types.ts";
|
|
11
|
+
import { formatInFlightToolLine } from "../supervisor/types.ts";
|
|
10
12
|
import { formatTree } from "./legion-tools.ts";
|
|
11
13
|
import {
|
|
12
14
|
renderLiveChildStatus,
|
|
@@ -14,7 +16,39 @@ import {
|
|
|
14
16
|
} from "./runtime.ts";
|
|
15
17
|
|
|
16
18
|
/** Child snapshot the status tool already prints. `now` is supplied by the caller. */
|
|
17
|
-
export type LegionChildStatusFacts = Omit<LiveChildStatusInput, "now"
|
|
19
|
+
export type LegionChildStatusFacts = Omit<LiveChildStatusInput, "now"> & {
|
|
20
|
+
/**
|
|
21
|
+
* The tool calls in flight right now (#315, #331), innermost last. Read from
|
|
22
|
+
* the supervisor, never persisted: it describes this process's live child, so
|
|
23
|
+
* a rebuilt record (another session's child) has none and none is invented.
|
|
24
|
+
*/
|
|
25
|
+
inFlightTools?: InFlightToolStack;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Put the live-activity line at the TOP of one rendered child block (#315).
|
|
30
|
+
*
|
|
31
|
+
* The fact is the whole stack (#331) so the line can name nesting and parallel
|
|
32
|
+
* siblings; an absent or empty stack renders the block unchanged.
|
|
33
|
+
*
|
|
34
|
+
* A lead line, not a footnote: `working · bash 00:07:31` is the answer to "what
|
|
35
|
+
* is it doing, and for how long", and the block below it already leads its own
|
|
36
|
+
* liveness story with `alive · quiet Ns`. Prepending needs no knowledge of the
|
|
37
|
+
* block's internal line order, so this keeps working if
|
|
38
|
+
* `renderLiveChildStatus` grows or reorders lines.
|
|
39
|
+
*
|
|
40
|
+
* `undefined` (or an empty stack) renders the block unchanged: no fact, no
|
|
41
|
+
* line. This is display only — it never decides liveness and never stops
|
|
42
|
+
* anything.
|
|
43
|
+
*/
|
|
44
|
+
export function withInFlightToolLine(
|
|
45
|
+
block: string,
|
|
46
|
+
stack: InFlightToolStack | undefined,
|
|
47
|
+
now: number,
|
|
48
|
+
): string {
|
|
49
|
+
if (stack === undefined || stack.length === 0) return block;
|
|
50
|
+
return `${formatInFlightToolLine(stack, now)}\n${block}`;
|
|
51
|
+
}
|
|
18
52
|
|
|
19
53
|
export interface LegionStatusJob {
|
|
20
54
|
name: string;
|
|
@@ -72,7 +106,15 @@ function liveJobForNode(node: LegionNode, jobs: readonly LegionStatusJob[]): Leg
|
|
|
72
106
|
}
|
|
73
107
|
|
|
74
108
|
function renderFacts(facts: LegionChildStatusFacts, now: number | undefined): string {
|
|
75
|
-
|
|
109
|
+
// The in-flight stack is display-only and lives outside
|
|
110
|
+
// `renderLiveChildStatus`, so it is split off before the snapshot is rendered
|
|
111
|
+
// and prepended afterwards.
|
|
112
|
+
const { inFlightTools, ...snapshot } = facts;
|
|
113
|
+
return withInFlightToolLine(
|
|
114
|
+
renderLiveChildStatus(withNow(snapshot, now)),
|
|
115
|
+
inFlightTools,
|
|
116
|
+
now ?? Date.now(),
|
|
117
|
+
);
|
|
76
118
|
}
|
|
77
119
|
|
|
78
120
|
function renderNode(
|