pi-legion 3.0.0 → 3.0.3
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 +163 -2
- package/CONTRIBUTING.md +38 -25
- package/README.md +23 -18
- package/agents/worker.md +6 -6
- package/docs/contracts/vexillatio-nonblocking-launch-v1.md +161 -0
- package/docs/design.md +39 -9
- package/docs/inprocess-supervisor.md +67 -5
- package/docs/legion-v2-contract.md +6 -3
- package/docs/subagents-teams.md +8 -4
- package/docs/vexillatio-contract.md +2 -1
- package/index.ts +149 -58
- package/package.json +4 -7
- package/src/agents/agents.ts +5 -5
- package/src/api.ts +6 -1
- package/src/extension/auto-doctor.ts +21 -3
- package/src/extension/join.ts +7 -18
- package/src/extension/legion-doctor.ts +125 -38
- package/src/extension/legion-tools.ts +7 -1
- package/src/extension/playbook.ts +1 -1
- package/src/extension/runtime.ts +206 -8
- package/src/extension/spawn-hints.ts +25 -4
- package/src/runs/orchestrator.ts +78 -8
- package/src/runs/worktree.ts +5 -4
- package/src/shared/session.ts +83 -7
- package/src/shared/types.ts +17 -2
- package/src/supervisor/inprocess-supervisor.ts +449 -16
- package/src/supervisor/routing-supervisor.ts +1 -1
- package/src/supervisor/spawn-mode.ts +25 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-legion",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.3",
|
|
4
4
|
"description": "Pi Legion v2: recursive centurion trees on headless Pi RPC; shared SQLite WAL legion.db. Four-stage planning/implementing/reviewing/verifying state machine, parent/child/sibling legion_mail, three-layer /legion UI, upward MR trees, squad resume.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -49,14 +49,13 @@
|
|
|
49
49
|
"LICENSE"
|
|
50
50
|
],
|
|
51
51
|
"scripts": {
|
|
52
|
-
"preinstall": "node -e \"const ua=process.env.npm_config_user_agent||'';if(!ua.includes('pnpm')){console.error('Use pnpm to install dependencies (see CONTRIBUTING.md).');process.exit(1)}\"",
|
|
53
52
|
"typecheck": "tsc --noEmit",
|
|
54
53
|
"test": "env -u PI_SUBAGENT_ROLE -u PI_SUBAGENT_ACCEPTANCE_ROLE -u PI_SUBAGENT_ALLOW_NESTED -u PI_SUBAGENT_CHILD -u PI_SUBAGENT_UTILITY_CHILD -u PI_SUBAGENT_MAX_DEPTH -u PI_SUBAGENT_PARENT_PATH -u PI_LEGION_DB -u PI_LEGION_NODE_ID node --experimental-strip-types --test test/unit/*.test.ts",
|
|
55
54
|
"test:integration": "env -u PI_SUBAGENT_ROLE -u PI_SUBAGENT_ACCEPTANCE_ROLE -u PI_SUBAGENT_ALLOW_NESTED -u PI_SUBAGENT_CHILD -u PI_SUBAGENT_UTILITY_CHILD -u PI_SUBAGENT_MAX_DEPTH -u PI_SUBAGENT_PARENT_PATH -u PI_LEGION_DB -u PI_LEGION_NODE_ID node --experimental-strip-types --test test/integration/*.test.ts",
|
|
56
55
|
"test:live": "env -u PI_SUBAGENT_ROLE -u PI_SUBAGENT_ACCEPTANCE_ROLE -u PI_SUBAGENT_ALLOW_NESTED -u PI_SUBAGENT_CHILD -u PI_SUBAGENT_UTILITY_CHILD -u PI_SUBAGENT_MAX_DEPTH -u PI_SUBAGENT_PARENT_PATH -u PI_LEGION_DB -u PI_LEGION_NODE_ID node --experimental-strip-types --test test/live/*.test.ts",
|
|
57
|
-
"test:all": "
|
|
56
|
+
"test:all": "node --run typecheck && node --run test && node --run test:integration",
|
|
58
57
|
"test:docker": "env -u PI_SUBAGENT_ROLE -u PI_SUBAGENT_ACCEPTANCE_ROLE -u PI_SUBAGENT_ALLOW_NESTED -u PI_SUBAGENT_CHILD -u PI_SUBAGENT_UTILITY_CHILD -u PI_SUBAGENT_MAX_DEPTH -u PI_SUBAGENT_PARENT_PATH sh -c 'echo \"RPC mainline Docker checks only; legacy Herdr workspace/kind/notification scenarios are archived under test/legacy/docker and require pi-legion v0.16.x\" && bash test/docker/verify-1-child-guard.sh && bash test/docker/verify-2-task-appendix.sh && bash test/docker/verify-3-readonly.sh && bash test/docker/verify-4-acceptance.sh && bash test/docker/verify-5-blocked.sh && bash test/docker/verify-6-cache.sh && bash test/docker/verify-7-budget.sh && bash test/docker/verify-8-verify-command.sh && bash test/docker/verify-9-worktree.sh && bash test/docker/verify-10-fallback.sh && bash test/docker/verify-11-nested-alias.sh'",
|
|
59
|
-
"prepublishOnly": "
|
|
58
|
+
"prepublishOnly": "node --run typecheck && node --run test && node --run test:integration",
|
|
60
59
|
"mock-llm": "node --experimental-strip-types test/mock-llm/cli.ts",
|
|
61
60
|
"test:mock-llm": "env -u PI_SUBAGENT_ROLE -u PI_SUBAGENT_ACCEPTANCE_ROLE -u PI_SUBAGENT_ALLOW_NESTED -u PI_SUBAGENT_CHILD -u PI_SUBAGENT_UTILITY_CHILD -u PI_SUBAGENT_MAX_DEPTH -u PI_SUBAGENT_PARENT_PATH -u PI_LEGION_DB -u PI_LEGION_NODE_ID node --experimental-strip-types --test test/unit/mock-llm*.test.ts test/integration/mock-llm*.test.ts test/integration/railway-mock-e2e.test.ts",
|
|
62
61
|
"test:tui-visual": "docker build -q -f test/docker/Dockerfile.tui-sandbox -t ghcr.io/zzjcool/pi-legion/tui-sandbox:latest . >/dev/null && docker run --rm -v $(pwd):/plugin -w /plugin ghcr.io/zzjcool/pi-legion/tui-sandbox:latest bash test/docker/verify-tui-visual.sh"
|
|
@@ -94,9 +93,7 @@
|
|
|
94
93
|
"typebox": "^1.3.30",
|
|
95
94
|
"typescript": "^5.6.0"
|
|
96
95
|
},
|
|
97
|
-
"packageManager": "pnpm@9.15.9",
|
|
98
96
|
"engines": {
|
|
99
|
-
"node": ">=22"
|
|
100
|
-
"pnpm": ">=9"
|
|
97
|
+
"node": ">=22"
|
|
101
98
|
}
|
|
102
99
|
}
|
package/src/agents/agents.ts
CHANGED
|
@@ -559,11 +559,11 @@ function applyBehaviorFields(config: AgentConfig, fm: AgentFrontmatter): void {
|
|
|
559
559
|
setIf(config, "acceptance", parseAcceptance(fm.acceptance, config.name));
|
|
560
560
|
setIf(config, "toolBudget", parseBudget(fm.toolBudget));
|
|
561
561
|
setIf(config, "turnBudget", parseTurnBudget(fm.turnBudget));
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
562
|
+
// #214 / contract §9: only centurion holds worktree isolation. Writers no
|
|
563
|
+
// longer auto-default to worktree:true — members work in-place; parallel
|
|
564
|
+
// isolated writers are N root-dispatched centurions. Explicit worktree:true
|
|
565
|
+
// on a non-centurion is still demoted at launch (warn once / force false).
|
|
566
|
+
// centurion.md pins worktree:true itself; no auto-default here.
|
|
567
567
|
}
|
|
568
568
|
|
|
569
569
|
/** Transitional blocked-policy frontmatter retained for approval handling. */
|
package/src/api.ts
CHANGED
|
@@ -132,7 +132,12 @@ export type { BudgetRefusedEvent, CollectResult, OrchestratorDeps } from "./runs
|
|
|
132
132
|
export { RpcSupervisor } from "./supervisor/rpc-supervisor.ts";
|
|
133
133
|
export { InProcessSupervisor } from "./supervisor/inprocess-supervisor.ts";
|
|
134
134
|
export { RoutingSupervisor } from "./supervisor/routing-supervisor.ts";
|
|
135
|
-
export {
|
|
135
|
+
export {
|
|
136
|
+
resolveSpawnMode,
|
|
137
|
+
DEFAULT_SPAWN_MODE,
|
|
138
|
+
DEFAULT_INPROCESS_MAX_ACTIVE,
|
|
139
|
+
DEFAULT_INPROCESS_SPAWN_START_TIMEOUT_MS,
|
|
140
|
+
} from "./supervisor/spawn-mode.ts";
|
|
136
141
|
export { InMemoryUIProxy, isExtensionUIRequest } from "./supervisor/ui-proxy.ts";
|
|
137
142
|
export { runInChildContext, currentChildContext } from "./supervisor/child-context.ts";
|
|
138
143
|
export type { ChildLaunchContext } from "./supervisor/child-context.ts";
|
|
@@ -71,6 +71,7 @@ function readSettings(): unknown {
|
|
|
71
71
|
export function buildAutoDoctorSnippet(kind: AutoDoctorKind, opts?: {
|
|
72
72
|
cwd?: string;
|
|
73
73
|
name?: string;
|
|
74
|
+
spawnMode?: "rpc" | "inprocess";
|
|
74
75
|
}): string {
|
|
75
76
|
const settings = readSettings();
|
|
76
77
|
const report = runLegionDoctor({
|
|
@@ -80,6 +81,7 @@ export function buildAutoDoctorSnippet(kind: AutoDoctorKind, opts?: {
|
|
|
80
81
|
probeModels: false,
|
|
81
82
|
expectedModels: expectedModelsFromSettings(settings),
|
|
82
83
|
sections: [...SECTIONS[kind]],
|
|
84
|
+
spawnMode: opts?.spawnMode,
|
|
83
85
|
// Failures already recorded by the caller; doctor will pick them up.
|
|
84
86
|
});
|
|
85
87
|
let body = report.text.trim();
|
|
@@ -108,25 +110,41 @@ export function enrichFailureWithAutoDoctor(
|
|
|
108
110
|
cwd?: string;
|
|
109
111
|
/** When false, skip recording into the recent-failure ring. Default true. */
|
|
110
112
|
record?: boolean;
|
|
113
|
+
/** Prefer inprocess hints when known (#212). */
|
|
114
|
+
spawnMode?: "rpc" | "inprocess";
|
|
111
115
|
} = {},
|
|
112
116
|
): string {
|
|
113
117
|
const kind = classifyFailure({ message, stderr: input.stderr, code: input.code });
|
|
118
|
+
// Infer rpc when argv shows --no-extensions and caller did not tag spawnMode (#212).
|
|
119
|
+
const spawnMode =
|
|
120
|
+
input.spawnMode ??
|
|
121
|
+
(input.args?.includes("--no-extensions") ? "rpc" as const : undefined);
|
|
114
122
|
if (input.record !== false) {
|
|
115
123
|
recordSpawnFailure({
|
|
116
124
|
name: input.name ?? "?",
|
|
117
125
|
message,
|
|
118
126
|
stderr: input.stderr,
|
|
119
|
-
hadNoExtensions:
|
|
127
|
+
hadNoExtensions:
|
|
128
|
+
spawnMode === "inprocess" ? false : input.args?.includes("--no-extensions"),
|
|
129
|
+
spawnMode,
|
|
120
130
|
});
|
|
121
131
|
}
|
|
122
132
|
if (!kind) return message;
|
|
123
133
|
|
|
124
134
|
const parts = [message];
|
|
125
|
-
const hint = spawnFailureHint({
|
|
135
|
+
const hint = spawnFailureHint({
|
|
136
|
+
stderr: input.stderr ?? message,
|
|
137
|
+
args: input.args,
|
|
138
|
+
spawnMode,
|
|
139
|
+
});
|
|
126
140
|
if (hint) parts.push("", hint);
|
|
127
141
|
|
|
128
142
|
try {
|
|
129
|
-
parts.push("", buildAutoDoctorSnippet(kind, {
|
|
143
|
+
parts.push("", buildAutoDoctorSnippet(kind, {
|
|
144
|
+
cwd: input.cwd,
|
|
145
|
+
name: input.name,
|
|
146
|
+
spawnMode,
|
|
147
|
+
}));
|
|
130
148
|
} catch (error) {
|
|
131
149
|
parts.push(
|
|
132
150
|
"",
|
package/src/extension/join.ts
CHANGED
|
@@ -18,9 +18,12 @@
|
|
|
18
18
|
* the whole buffer now (the common "all children done" path).
|
|
19
19
|
* 3. Otherwise buffer it; the window starts at the FIRST onTerminal.
|
|
20
20
|
*
|
|
21
|
-
* Window expiry:
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* Window expiry: flush what is buffered, once. The window exists only to
|
|
22
|
+
* collect stragglers from one batch — it is NEVER extended because the parent
|
|
23
|
+
* session happens to be busy. A turn-level busy signal was the wrong predictor
|
|
24
|
+
* (the parent is busy for the whole tool loop by definition) and it stacked a
|
|
25
|
+
* second delay on top of the runtime's run-scoped outbox, which is the single
|
|
26
|
+
* place a completion may be held. Pending members survive — a straggler
|
|
24
27
|
* finishing later starts a fresh batch of its own.
|
|
25
28
|
*/
|
|
26
29
|
|
|
@@ -34,14 +37,10 @@ export type JoinMode = "each" | "smart";
|
|
|
34
37
|
|
|
35
38
|
export const DEFAULT_JOIN_MODE: JoinMode = "smart";
|
|
36
39
|
export const DEFAULT_FLUSH_MS = 10_000;
|
|
37
|
-
/** Max times a busy parent extends a window (total ≤ (1+3)×flushMs). */
|
|
38
|
-
export const MAX_BUSY_EXTENSIONS = 3;
|
|
39
40
|
|
|
40
41
|
export interface JoinConfig {
|
|
41
42
|
mode: JoinMode;
|
|
42
43
|
flushMs: number;
|
|
43
|
-
/** Reports whether the parent is mid-turn; absent means "never busy". */
|
|
44
|
-
parentBusy?: () => boolean;
|
|
45
44
|
}
|
|
46
45
|
|
|
47
46
|
/** Default: smart mode with a ten-second window. */
|
|
@@ -76,7 +75,6 @@ interface Group {
|
|
|
76
75
|
/** Names tracked but not yet terminal (blocked members are excluded). */
|
|
77
76
|
pending: Set<string>;
|
|
78
77
|
buffered: BufferedEntry[];
|
|
79
|
-
extensions: number;
|
|
80
78
|
timer?: JoinSchedulerHandle;
|
|
81
79
|
}
|
|
82
80
|
|
|
@@ -162,7 +160,6 @@ export class JoinCoordinator {
|
|
|
162
160
|
return;
|
|
163
161
|
}
|
|
164
162
|
if (!group.timer) {
|
|
165
|
-
group.extensions = 0;
|
|
166
163
|
group.timer = this.scheduleFn(this.config.flushMs, () => {
|
|
167
164
|
this.onWindowExpired(entry.runId);
|
|
168
165
|
});
|
|
@@ -190,7 +187,7 @@ export class JoinCoordinator {
|
|
|
190
187
|
private ensureGroup(runId: string): Group {
|
|
191
188
|
let group = this.groups.get(runId);
|
|
192
189
|
if (!group) {
|
|
193
|
-
group = { pending: new Set(), buffered: []
|
|
190
|
+
group = { pending: new Set(), buffered: [] };
|
|
194
191
|
this.groups.set(runId, group);
|
|
195
192
|
}
|
|
196
193
|
return group;
|
|
@@ -200,14 +197,6 @@ export class JoinCoordinator {
|
|
|
200
197
|
const group = this.groups.get(runId);
|
|
201
198
|
if (!group || this.disposed) return;
|
|
202
199
|
group.timer = undefined;
|
|
203
|
-
const busy = this.config.parentBusy?.() ?? false;
|
|
204
|
-
if (busy && group.extensions < MAX_BUSY_EXTENSIONS) {
|
|
205
|
-
group.extensions += 1;
|
|
206
|
-
group.timer = this.scheduleFn(this.config.flushMs, () => {
|
|
207
|
-
this.onWindowExpired(runId);
|
|
208
|
-
});
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
200
|
this.flush(runId);
|
|
212
201
|
}
|
|
213
202
|
|
|
@@ -53,8 +53,12 @@ import {
|
|
|
53
53
|
} from "../runs/verify-queue.ts";
|
|
54
54
|
import { ALLOW_NESTED_ENV } from "./budget.ts";
|
|
55
55
|
import { IDLE_STOP_HINT, IDLE_STOP_HINT_SHORT } from "./legion-stop.ts";
|
|
56
|
-
import type { AgentConfig } from "../shared/types.ts";
|
|
57
|
-
import {
|
|
56
|
+
import type { AgentConfig, SpawnMode } from "../shared/types.ts";
|
|
57
|
+
import { resolveSpawnMode } from "../supervisor/spawn-mode.ts";
|
|
58
|
+
import {
|
|
59
|
+
recentSpawnFailures,
|
|
60
|
+
type SpawnFailureRecord,
|
|
61
|
+
} from "./spawn-hints.ts";
|
|
58
62
|
import {
|
|
59
63
|
formatSessionErrorTitle,
|
|
60
64
|
sessionErrorsLogPath,
|
|
@@ -179,6 +183,11 @@ export interface DoctorOptions {
|
|
|
179
183
|
sections?: readonly DoctorSection[];
|
|
180
184
|
/** Injected session-error summary (tests); defaults to reading storeRoot log. */
|
|
181
185
|
sessionErrors?: SessionErrorSummary;
|
|
186
|
+
/**
|
|
187
|
+
* Override resolved spawn backend for diagnosis (#212).
|
|
188
|
+
* When omitted, uses `resolveSpawnMode(sampleAgent)` (default inprocess).
|
|
189
|
+
*/
|
|
190
|
+
spawnMode?: SpawnMode;
|
|
182
191
|
}
|
|
183
192
|
|
|
184
193
|
function readJson(file: string): unknown {
|
|
@@ -345,6 +354,23 @@ function sampleAgentConfig(agent?: AgentConfig): AgentConfig {
|
|
|
345
354
|
);
|
|
346
355
|
}
|
|
347
356
|
|
|
357
|
+
|
|
358
|
+
/** Fix text for inprocess model/provider gaps — never recommends PI_LEGION_CHILD_EXTENSIONS (#212). */
|
|
359
|
+
export function inprocessModelFix(): string {
|
|
360
|
+
return (
|
|
361
|
+
"Declare the provider statically in models.json (agent dir / project), " +
|
|
362
|
+
"or set role frontmatter extensions: […] / inheritExtensions: true."
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** RPC-only fix that exports PI_LEGION_CHILD_EXTENSIONS when paths are known. */
|
|
367
|
+
function rpcChildExtensionsFix(providerExtHome: readonly string[]): string | undefined {
|
|
368
|
+
if (providerExtHome.length) {
|
|
369
|
+
return `export ${CHILD_EXTENSIONS_ENV}=${providerExtHome.join(",")}`;
|
|
370
|
+
}
|
|
371
|
+
return `Add the provider package extension path to ${CHILD_EXTENSIONS_ENV}, or set role extensions: / inheritExtensions: true.`;
|
|
372
|
+
}
|
|
373
|
+
|
|
348
374
|
function previewChildArgv(agent: AgentConfig, env: NodeJS.ProcessEnv): string[] {
|
|
349
375
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "legion-doctor-"));
|
|
350
376
|
try {
|
|
@@ -506,9 +532,18 @@ export function runLegionDoctor(options: DoctorOptions = {}): DoctorReport {
|
|
|
506
532
|
|
|
507
533
|
const inheritGlobal = childInheritsExtensions({}, env);
|
|
508
534
|
const extras = childExtraExtensions(env);
|
|
509
|
-
const sample = sampleAgentConfig(
|
|
510
|
-
|
|
511
|
-
|
|
535
|
+
const sample = sampleAgentConfig(
|
|
536
|
+
options.spawnMode && options.sampleAgent
|
|
537
|
+
? { ...options.sampleAgent, spawnMode: options.spawnMode }
|
|
538
|
+
: options.spawnMode
|
|
539
|
+
? { ...sampleAgentConfig(), spawnMode: options.spawnMode }
|
|
540
|
+
: options.sampleAgent,
|
|
541
|
+
);
|
|
542
|
+
const spawnMode: SpawnMode = resolveSpawnMode(sample);
|
|
543
|
+
const isInprocess = spawnMode === "inprocess";
|
|
544
|
+
// Argv preview is RPC-shaped; only meaningful when default/sample backend is rpc (#212).
|
|
545
|
+
const argv = isInprocess ? [] : previewChildArgv(sample, env);
|
|
546
|
+
const noExtensions = isInprocess ? false : argv.includes("--no-extensions");
|
|
512
547
|
const explicitExts = argv.flatMap((a, i) => (a === "--extension" ? [argv[i + 1]!] : []));
|
|
513
548
|
|
|
514
549
|
const probe = options.probeModels !== false;
|
|
@@ -556,7 +591,10 @@ export function runLegionDoctor(options: DoctorOptions = {}): DoctorReport {
|
|
|
556
591
|
].filter((ext, i, all) => all.indexOf(ext) === i);
|
|
557
592
|
const providerExtHome = providerExtPaths.map((ext) => homeize(ext, home));
|
|
558
593
|
|
|
559
|
-
|
|
594
|
+
// CHILD_NO_MODELS / CHILD_FEWER_MODELS come from the RPC CLI probe
|
|
595
|
+
// (`pi --no-extensions --list-models`). Skip them when default backend is
|
|
596
|
+
// inprocess — that probe is a false lead for ModelRuntime children (#212).
|
|
597
|
+
if (!isInprocess && !inheritGlobal && noExtensions && hostModels.length > 0 && childModels.length === 0) {
|
|
560
598
|
issues.push({
|
|
561
599
|
severity: "error",
|
|
562
600
|
code: "CHILD_NO_MODELS",
|
|
@@ -564,11 +602,9 @@ export function runLegionDoctor(options: DoctorOptions = {}): DoctorReport {
|
|
|
564
602
|
message:
|
|
565
603
|
`Host has ${hostModels.length} model(s) but a child with --no-extensions sees none. ` +
|
|
566
604
|
`Providers are registered by host packages that children no longer load (#43).`,
|
|
567
|
-
fix: providerExtHome
|
|
568
|
-
? `export ${CHILD_EXTENSIONS_ENV}=${providerExtHome.join(",")}`
|
|
569
|
-
: `Add the provider package extension path to ${CHILD_EXTENSIONS_ENV}, or set role extensions: / inheritExtensions: true.`,
|
|
605
|
+
fix: rpcChildExtensionsFix(providerExtHome),
|
|
570
606
|
});
|
|
571
|
-
} else if (!inheritGlobal && noExtensions && hostModels.length > childModels.length && childModels.length > 0) {
|
|
607
|
+
} else if (!isInprocess && !inheritGlobal && noExtensions && hostModels.length > childModels.length && childModels.length > 0) {
|
|
572
608
|
issues.push({
|
|
573
609
|
severity: "warning",
|
|
574
610
|
code: "CHILD_FEWER_MODELS",
|
|
@@ -578,18 +614,19 @@ export function runLegionDoctor(options: DoctorOptions = {}): DoctorReport {
|
|
|
578
614
|
}
|
|
579
615
|
|
|
580
616
|
const expected = options.expectedModels ?? [];
|
|
581
|
-
|
|
617
|
+
// Inprocess shares host discovery after extension drain — compare roster to host.
|
|
618
|
+
const missing = inheritGlobal || isInprocess
|
|
582
619
|
? expected.filter((m) => !modelMatches(m, hostModels))
|
|
583
620
|
: expected.filter((m) => !modelMatches(m, childModels));
|
|
584
|
-
if (missing.length > 0 && (childModels.length === 0 || !inheritGlobal)) {
|
|
621
|
+
if (missing.length > 0 && (isInprocess || childModels.length === 0 || !inheritGlobal)) {
|
|
585
622
|
issues.push({
|
|
586
623
|
severity: "error",
|
|
587
624
|
code: "TEAM_MODELS_MISSING",
|
|
588
625
|
section: "models",
|
|
589
|
-
message:
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
626
|
+
message: isInprocess
|
|
627
|
+
? `Team/roster model(s) not visible on the host registry (inprocess resolves via ModelRuntime): ${missing.join(", ")}`
|
|
628
|
+
: `Team/roster model(s) invisible to children: ${missing.join(", ")}`,
|
|
629
|
+
fix: isInprocess ? inprocessModelFix() : rpcChildExtensionsFix(providerExtHome),
|
|
593
630
|
});
|
|
594
631
|
}
|
|
595
632
|
|
|
@@ -625,16 +662,30 @@ export function runLegionDoctor(options: DoctorOptions = {}): DoctorReport {
|
|
|
625
662
|
}
|
|
626
663
|
|
|
627
664
|
const failures = options.failures ?? recentSpawnFailures();
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
665
|
+
const modelFailRe = /not found|No models|Unknown provider|inprocess model resolve failed/i;
|
|
666
|
+
const recentModelFails = failures.filter((f) => modelFailRe.test(f.stderr ?? f.message));
|
|
667
|
+
if (recentModelFails.length > 0) {
|
|
668
|
+
// Default backend (or tagged failure) decides the fix — never recommend
|
|
669
|
+
// PI_LEGION_CHILD_EXTENSIONS for inprocess (#212).
|
|
670
|
+
const treatAsInprocess =
|
|
671
|
+
isInprocess || recentModelFails.every((f) => f.spawnMode === "inprocess");
|
|
672
|
+
if (treatAsInprocess) {
|
|
673
|
+
issues.push({
|
|
674
|
+
severity: "error",
|
|
675
|
+
code: "RECENT_MODEL_SPAWN_FAIL",
|
|
676
|
+
section: "spawn",
|
|
677
|
+
message: "A recent inprocess child spawn failed to resolve a model/provider.",
|
|
678
|
+
fix: inprocessModelFix(),
|
|
679
|
+
});
|
|
680
|
+
} else if (recentModelFails.some((f) => f.hadNoExtensions !== false)) {
|
|
681
|
+
issues.push({
|
|
682
|
+
severity: "error",
|
|
683
|
+
code: "RECENT_MODEL_SPAWN_FAIL",
|
|
684
|
+
section: "spawn",
|
|
685
|
+
message: "A recent child spawn failed with a model/provider error under --no-extensions.",
|
|
686
|
+
fix: rpcChildExtensionsFix(providerExtHome),
|
|
687
|
+
});
|
|
688
|
+
}
|
|
638
689
|
}
|
|
639
690
|
|
|
640
691
|
const gate = resolveMemoryGateConfig(env);
|
|
@@ -721,8 +772,14 @@ export function runLegionDoctor(options: DoctorOptions = {}): DoctorReport {
|
|
|
721
772
|
const warnN = issues.filter((i) => i.severity === "warning").length;
|
|
722
773
|
push(`- status: ${errN ? "FAIL" : warnN ? "WARN" : "OK"} (${errN} error(s), ${warnN} warning(s))`);
|
|
723
774
|
push(`- session errors: ${sessionErrors.total}${sessionErrors.logPath ? ` (log ${homeize(sessionErrors.logPath, home)})` : ""}`);
|
|
724
|
-
push(`-
|
|
725
|
-
|
|
775
|
+
push(`- spawnMode (sample/default): ${spawnMode}`);
|
|
776
|
+
if (isInprocess) {
|
|
777
|
+
push(`- child backend: inprocess (ModelRuntime; no --no-extensions argv); inherit: ${inheritGlobal ? "yes" : "no"}`);
|
|
778
|
+
push(`- models host (inprocess uses host registry after extension drain): ${hostModels.length}`);
|
|
779
|
+
} else {
|
|
780
|
+
push(`- child --no-extensions: ${noExtensions ? "yes" : "no"}; inherit: ${inheritGlobal ? "yes" : "no"}`);
|
|
781
|
+
push(`- models host/child: ${hostModels.length}/${childModels.length}`);
|
|
782
|
+
}
|
|
726
783
|
push(`- live children: ${runtime.liveJobs?.length ?? "(unknown — pass runtime)"}`);
|
|
727
784
|
push(`- free memory: ${availableMb} MB (gate min ${gate.minFreeMb})`);
|
|
728
785
|
if (storeRoot) push(`- store: ${homeize(storeRoot, home)}`);
|
|
@@ -731,8 +788,13 @@ export function runLegionDoctor(options: DoctorOptions = {}): DoctorReport {
|
|
|
731
788
|
|
|
732
789
|
if (want(selected, "extensions")) {
|
|
733
790
|
push("## Extensions");
|
|
791
|
+
push(`- spawnMode (sample/default): ${spawnMode}`);
|
|
734
792
|
push(`- inheritExtensions (env/role default): ${inheritGlobal ? "yes (loads all host extensions)" : "no"}`);
|
|
735
|
-
|
|
793
|
+
if (isInprocess) {
|
|
794
|
+
push("- child argv --no-extensions: n/a (inprocess has no child argv)");
|
|
795
|
+
} else {
|
|
796
|
+
push(`- child argv includes --no-extensions: ${noExtensions ? "yes" : "no"}`);
|
|
797
|
+
}
|
|
736
798
|
push(`- ${CHILD_INHERIT_EXTENSIONS_ENV}=${env[CHILD_INHERIT_EXTENSIONS_ENV] ?? "(unset)"}`);
|
|
737
799
|
push(`- ${CHILD_EXTENSIONS_ENV}=${env[CHILD_EXTENSIONS_ENV] ?? "(unset)"}`);
|
|
738
800
|
push(`- extra extension paths: ${extras.length ? extras.map((p) => homeize(p, home)).join(", ") : "(none)"}`);
|
|
@@ -763,10 +825,19 @@ export function runLegionDoctor(options: DoctorOptions = {}): DoctorReport {
|
|
|
763
825
|
|
|
764
826
|
if (want(selected, "models")) {
|
|
765
827
|
push("## Models / providers");
|
|
828
|
+
push(`- spawnMode (sample/default): ${spawnMode}`);
|
|
766
829
|
push(`- host (normal discovery): ${hostModels.length}`);
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
push(
|
|
830
|
+
if (isInprocess) {
|
|
831
|
+
push("- child probe (`pi --no-extensions --list-models`): skipped as doctor signal for inprocess (#212)");
|
|
832
|
+
push(`- rpc-shaped probe count (informational only): ${childModels.length}`);
|
|
833
|
+
if (hostModels.length === 0) {
|
|
834
|
+
push("- host sees NO models — declare providers in models.json or load provider extensions for inprocess.");
|
|
835
|
+
}
|
|
836
|
+
} else {
|
|
837
|
+
push(`- child (--no-extensions): ${childModels.length}`);
|
|
838
|
+
if (hostModels.length && childModels.length === 0) {
|
|
839
|
+
push("- child sees NO models — providers come from host packages that `--no-extensions` skips.");
|
|
840
|
+
}
|
|
770
841
|
}
|
|
771
842
|
if (restoring.length) {
|
|
772
843
|
push("- packages that restore models when passed via `--extension`:");
|
|
@@ -786,11 +857,18 @@ export function runLegionDoctor(options: DoctorOptions = {}): DoctorReport {
|
|
|
786
857
|
|
|
787
858
|
if (want(selected, "spawn")) {
|
|
788
859
|
push("## Spawn");
|
|
789
|
-
push(
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
860
|
+
push(`- spawnMode (sample/default): ${spawnMode}`);
|
|
861
|
+
if (isInprocess) {
|
|
862
|
+
push(`### Sample child (${sample.name}) — inprocess`);
|
|
863
|
+
push("- no child argv (ModelRuntime + additionalExtensionPaths from role/env)");
|
|
864
|
+
push("- do not treat `pi --no-extensions --list-models` as the primary diagnosis for inprocess (#212)");
|
|
865
|
+
} else {
|
|
866
|
+
push(`### Sample child argv (${sample.name})`);
|
|
867
|
+
push("```");
|
|
868
|
+
push(argv.join(" "));
|
|
869
|
+
push("```");
|
|
870
|
+
push(`- explicit --extension count: ${explicitExts.length}`);
|
|
871
|
+
}
|
|
794
872
|
push(`- nested spawn env ${ALLOW_NESTED_ENV}=${env[ALLOW_NESTED_ENV] ?? "(unset)"}`);
|
|
795
873
|
push(`- runtime.allowNestedEnv: ${runtime.allowNestedEnv ?? "(unknown)"}`);
|
|
796
874
|
push(`- roles with allowNestedSubagents: ${
|
|
@@ -954,9 +1032,18 @@ export function runLegionDoctor(options: DoctorOptions = {}): DoctorReport {
|
|
|
954
1032
|
push("");
|
|
955
1033
|
push("## Next steps (actionable)");
|
|
956
1034
|
const steps: string[] = [];
|
|
957
|
-
|
|
1035
|
+
const modelIssue = issues.some((i) =>
|
|
1036
|
+
i.code === "CHILD_NO_MODELS" || i.code === "TEAM_MODELS_MISSING" || i.code === "RECENT_MODEL_SPAWN_FAIL",
|
|
1037
|
+
);
|
|
1038
|
+
if (modelIssue && isInprocess) {
|
|
1039
|
+
steps.push(
|
|
1040
|
+
"Inprocess: declare the provider in models.json, or set role extensions: […] / inheritExtensions: true — then restart pi (#212).",
|
|
1041
|
+
);
|
|
1042
|
+
} else if (modelIssue && providerExtHome.length) {
|
|
958
1043
|
steps.push(`export ${CHILD_EXTENSIONS_ENV}=${providerExtHome.join(",")} # then restart pi`);
|
|
959
1044
|
steps.push(`# or role frontmatter extensions: […] / inheritExtensions: true (more RAM)`);
|
|
1045
|
+
} else if (modelIssue) {
|
|
1046
|
+
steps.push(`# set role frontmatter extensions: […] / inheritExtensions: true, or export ${CHILD_EXTENSIONS_ENV}=<paths>`);
|
|
960
1047
|
}
|
|
961
1048
|
steps.push(...roleToolGateNextSteps(issues));
|
|
962
1049
|
if (issues.some((i) => i.code === "MEMORY_GATE_WOULD_REFUSE")) {
|
|
@@ -253,7 +253,13 @@ export class LegionPostman {
|
|
|
253
253
|
this.eventBus?.emit(event);
|
|
254
254
|
reported.add(node.id);
|
|
255
255
|
this.sendRootMessage(
|
|
256
|
-
|
|
256
|
+
// The notice can be delivered long after detection: it is queued through
|
|
257
|
+
// `subagent-notify` and the outbox flushes at `MAX_PENDING_AGE_MS` or at
|
|
258
|
+
// `agent_settled`. A stale `staleMs` alone therefore reads as "detected
|
|
259
|
+
// just now" when the value can be tens of minutes old, which makes a
|
|
260
|
+
// replayed notice look like a fresh detection. Stamp the detection time
|
|
261
|
+
// so a late delivery is self-evident.
|
|
262
|
+
`Legion orphan detected at ${new Date(timestamp).toISOString()}: ${node.id} (${node.role}, status=${node.status}) has no active supervisor and no heartbeat for ${Math.max(0, timestamp - node.updatedAt)}ms. Resume requires CEO approval.`,
|
|
257
263
|
);
|
|
258
264
|
}
|
|
259
265
|
}
|
|
@@ -13,7 +13,7 @@ export const PARENT_PLAYBOOK = [
|
|
|
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 use `--no-extensions`, so provider packages 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 RPC child is a progress signal, not a verdict. Check status or session JSONL before deciding to steer or resume.",
|
|
16
|
-
"6. Isolation
|
|
16
|
+
"6. Isolation belongs to centurion. For N parallel isolated writers, dispatch N centurions (each owns one worktree); 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. The `subagent` tool stays for the command chain (worker/centurion).",
|
|
19
19
|
"Forbidden: direct bash access to legion.db, hand-launching child Pi processes, or using herdr pane/tab/agent commands for dispatch.",
|