pi-legion 3.0.3 → 4.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 +263 -2
- package/CONTRIBUTING.md +4 -17
- package/README.md +29 -22
- package/agents/worker.md +1 -1
- package/docs/contracts/display-liveness-v1.md +373 -0
- package/docs/contracts/drop-non-pi-kinds-v4.md +198 -0
- package/docs/contracts/esc-abort-v1.md +4 -4
- package/docs/contracts/esc-no-retire-v3.md +2 -2
- package/docs/contracts/launch-admission-queue-v1.md +839 -0
- package/docs/contracts/status-panel-v1.md +174 -0
- package/docs/inprocess-supervisor.md +37 -4
- package/docs/legion-v2-contract.md +18 -2
- package/docs/performance-2026-10-19.md +199 -0
- package/docs/spawn-mode-visibility-v1.md +206 -0
- package/docs/vexillatio-contract.md +3 -1
- package/index.ts +633 -84
- package/package.json +4 -2
- package/skills/pi-legion/SKILL.md +2 -3
- package/src/agents/agents.ts +17 -10
- package/src/agents/overrides.ts +0 -1
- package/src/agents/presets.ts +14 -40
- package/src/agents/settings.ts +26 -77
- package/src/agents/step-model.ts +7 -22
- package/src/api.ts +1 -35
- package/src/extension/event-bus.ts +80 -8
- package/src/extension/legion-commands.ts +43 -17
- package/src/extension/legion-doctor.ts +1 -1
- package/src/extension/legion-stop.ts +39 -13
- package/src/extension/legion-tools.ts +21 -4
- package/src/extension/liveness.ts +200 -0
- package/src/extension/notify.ts +6 -6
- package/src/extension/playbook.ts +6 -6
- package/src/extension/recycle.ts +6 -2
- package/src/extension/runtime.ts +422 -18
- package/src/extension/slash.ts +0 -21
- package/src/extension/spawn-render.ts +183 -0
- package/src/extension/summary.ts +1 -1
- package/src/legion/db.ts +165 -1
- package/src/legion/events.ts +81 -16
- package/src/legion/nodes.ts +13 -2
- package/src/legion/rebuild.ts +7 -1
- package/src/legion/usage.ts +58 -4
- package/src/runs/orchestrator.ts +1165 -62
- package/src/runs/worktree.ts +190 -21
- package/src/shared/ignored-kind.ts +57 -0
- package/src/shared/progress.ts +11 -0
- package/src/shared/session.ts +199 -19
- package/src/shared/types.ts +50 -66
- package/src/supervisor/admission-queue.ts +516 -0
- package/src/supervisor/inprocess-supervisor.ts +108 -33
- package/src/supervisor/proc-memory.ts +67 -0
- package/src/supervisor/routing-supervisor.ts +58 -1
- package/src/tui/live-overlay.ts +14 -0
- package/src/tui/session-messages-file.ts +100 -0
- package/src/tui/status.ts +103 -21
- package/src/tui/tree-view.ts +36 -3
- package/src/tui/viz/layout.ts +54 -3
- package/src/tui/viz/model.ts +67 -14
- package/src/tui/viz/palette.ts +11 -0
- package/src/tui/viz/panels.ts +222 -8
- package/src/tui/viz/railway.ts +28 -25
- package/src/tui/viz/view.ts +110 -23
- package/src/backends/legacy/client.ts +0 -638
- package/src/backends/legacy/cursor-chat.ts +0 -348
- package/src/backends/legacy/runner.ts +0 -101
- package/src/backends/legacy/types.ts +0 -138
- package/src/runs/kind.ts +0 -260
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-legion",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
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",
|
|
@@ -54,9 +54,11 @@
|
|
|
54
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",
|
|
55
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",
|
|
56
56
|
"test:all": "node --run typecheck && node --run test && node --run test:integration",
|
|
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 \"
|
|
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 \"Docker mainline checks for the RPC path only\" && 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'",
|
|
58
58
|
"prepublishOnly": "node --run typecheck && node --run test && node --run test:integration",
|
|
59
59
|
"mock-llm": "node --experimental-strip-types test/mock-llm/cli.ts",
|
|
60
|
+
"test:perf": "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/perf/*.test.ts",
|
|
61
|
+
"test:wal-stress": "node --experimental-strip-types test/stress/wal-stress.ts",
|
|
60
62
|
"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",
|
|
61
63
|
"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"
|
|
62
64
|
},
|
|
@@ -82,9 +82,8 @@ Keep the run/session records while the squad may need to resume. The
|
|
|
82
82
|
|
|
83
83
|
A Git worktree is workspace separation, not a security sandbox. Read-only
|
|
84
84
|
checks remain guardrails rather than a process boundary; never rely on them to
|
|
85
|
-
protect against intentional writes. Pi Legion
|
|
86
|
-
|
|
87
|
-
than expecting v2 to launch it.
|
|
85
|
+
protect against intentional writes. Pi Legion runs `kind: pi` roles only; the
|
|
86
|
+
`kind` frontmatter key is still legal but `pi` is the only accepted value.
|
|
88
87
|
|
|
89
88
|
## Later control
|
|
90
89
|
|
package/src/agents/agents.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
stripQuotes,
|
|
31
31
|
} from "./frontmatter.ts";
|
|
32
32
|
import { getAgentDir } from "./paths.ts";
|
|
33
|
+
import { ignoredKindMessage, reportIgnoredOnce } from "../shared/ignored-kind.ts";
|
|
33
34
|
|
|
34
35
|
export const BUILTIN_AGENT_NAMES = [
|
|
35
36
|
"scout",
|
|
@@ -460,7 +461,7 @@ export function parseAgentDocument(
|
|
|
460
461
|
inheritProjectContext: bool(fm.inheritProjectContext) ?? true,
|
|
461
462
|
inheritSkills: bool(fm.inheritSkills) ?? false,
|
|
462
463
|
...(bool(fm.inheritExtensions) !== undefined ? { inheritExtensions: bool(fm.inheritExtensions) } : {}),
|
|
463
|
-
kind:
|
|
464
|
+
kind: parseKind(fm.kind, name),
|
|
464
465
|
systemPrompt: body,
|
|
465
466
|
source,
|
|
466
467
|
filePath,
|
|
@@ -496,17 +497,23 @@ function setIf<T extends object, K extends keyof T>(
|
|
|
496
497
|
}
|
|
497
498
|
|
|
498
499
|
/**
|
|
499
|
-
*
|
|
500
|
-
*
|
|
501
|
-
*
|
|
500
|
+
* Resolve the role's `kind`.
|
|
501
|
+
*
|
|
502
|
+
* v4 runs `pi` only. The key stays legal frontmatter (and `AgentConfig.kind`
|
|
503
|
+
* stays), but a value outside `AGENT_KINDS` is ignored: the role launches as
|
|
504
|
+
* `pi` and this is reported once, naming the role and the value it tried to
|
|
505
|
+
* set. Silently coercing would leave the user believing a non-pi CLI is in
|
|
506
|
+
* play, so an invalid/absent value is the only case that speaks up.
|
|
502
507
|
*/
|
|
503
|
-
function
|
|
504
|
-
value: unknown,
|
|
505
|
-
allowed: ReadonlySet<string>,
|
|
506
|
-
fallback: T,
|
|
507
|
-
): T {
|
|
508
|
+
function parseKind(value: unknown, agentName: string): AgentKind {
|
|
508
509
|
const raw = str(value);
|
|
509
|
-
|
|
510
|
+
if (raw === undefined || raw === "") return "pi";
|
|
511
|
+
if (VALID_KINDS.has(raw)) return raw as AgentKind;
|
|
512
|
+
reportIgnoredOnce(
|
|
513
|
+
`agent:${agentName}:${raw}`,
|
|
514
|
+
ignoredKindMessage({ origin: "agent", subject: `'${agentName}'`, value: raw }),
|
|
515
|
+
);
|
|
516
|
+
return "pi";
|
|
510
517
|
}
|
|
511
518
|
|
|
512
519
|
/** `systemPromptMode` is a closed set of two; anything else means `replace`. */
|
package/src/agents/overrides.ts
CHANGED
package/src/agents/presets.ts
CHANGED
|
@@ -8,15 +8,15 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Two invariants live here:
|
|
10
10
|
*
|
|
11
|
-
* 1. ATOMICITY — `kind` and `
|
|
12
|
-
*
|
|
13
|
-
* mismatched pair would silently drop the model and start the child on
|
|
14
|
-
* the CLI default. `assertKindModelCoherent` rejects that combination
|
|
15
|
-
* at expansion time instead of letting the launch proceed.
|
|
11
|
+
* 1. ATOMICITY — `kind`, `model` and `thinking` come from the same preset
|
|
12
|
+
* object, so a preset can never ship half of one bundle and half another.
|
|
16
13
|
*
|
|
17
14
|
* 2. LOUD UNDEFINED — a referenced preset name with no matching entry is
|
|
18
15
|
* an error that names the defined presets, never a silent fallback to
|
|
19
16
|
* the dispatch model (mirrors `checkModelScope`'s out-of-scope error).
|
|
17
|
+
*
|
|
18
|
+
* v4 runs `kind: pi` only: a preset that names another kind is reported once
|
|
19
|
+
* and applied as `pi` (see `parsePreset`).
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import {
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
type AgentKind,
|
|
28
28
|
type PresetConfig,
|
|
29
29
|
} from "../shared/types.ts";
|
|
30
|
-
import {
|
|
30
|
+
import { ignoredKindMessage, reportIgnoredOnce } from "../shared/ignored-kind.ts";
|
|
31
31
|
|
|
32
32
|
const VALID_KINDS: ReadonlySet<string> = new Set(AGENT_KINDS);
|
|
33
33
|
|
|
@@ -71,13 +71,17 @@ function parsePreset(
|
|
|
71
71
|
const out: PresetConfig = {};
|
|
72
72
|
|
|
73
73
|
if (input.kind !== undefined) {
|
|
74
|
+
// A non-`pi` kind is no longer a validation error (the field is still a
|
|
75
|
+
// legal key, it just only accepts one value). Report the ignored value once
|
|
76
|
+
// and apply the preset as `pi`, the single supported kind.
|
|
74
77
|
if (typeof input.kind !== "string" || !VALID_KINDS.has(input.kind)) {
|
|
75
|
-
|
|
76
|
-
`
|
|
77
|
-
|
|
78
|
+
reportIgnoredOnce(
|
|
79
|
+
`preset:${name}`,
|
|
80
|
+
ignoredKindMessage({ origin: `preset '${name}'`, subject: `in ${filePath}`, value: input.kind }),
|
|
78
81
|
);
|
|
82
|
+
} else {
|
|
83
|
+
out.kind = input.kind as AgentKind;
|
|
79
84
|
}
|
|
80
|
-
out.kind = input.kind as AgentKind;
|
|
81
85
|
}
|
|
82
86
|
|
|
83
87
|
if (input.model !== undefined) {
|
|
@@ -152,36 +156,6 @@ export function requirePreset(
|
|
|
152
156
|
);
|
|
153
157
|
}
|
|
154
158
|
|
|
155
|
-
/**
|
|
156
|
-
* Throw when `nativeModelFor(kind, model)` would silently drop the model.
|
|
157
|
-
*
|
|
158
|
-
* Reuses `nativeModelFor` rather than re-implementing the shape test, so the
|
|
159
|
-
* guard can never disagree with the argv builder about what a kind accepts.
|
|
160
|
-
*
|
|
161
|
-
* `model` is the RESOLVED model — whichever precedence level actually won —
|
|
162
|
-
* not the preset's own `model`, because a per-run tool `model`, `defaultModel`,
|
|
163
|
-
* an override or the dispatch model can all beat or fill in for the preset.
|
|
164
|
-
* The message therefore names the preset's KIND as the origin and reports
|
|
165
|
-
* where the model came from, rather than implying the preset set it.
|
|
166
|
-
*/
|
|
167
|
-
export function assertKindModelCoherent(
|
|
168
|
-
kind: AgentKind,
|
|
169
|
-
model: string | undefined,
|
|
170
|
-
presetName: string,
|
|
171
|
-
modelOrigin?: string,
|
|
172
|
-
): void {
|
|
173
|
-
if (!model) return;
|
|
174
|
-
if (nativeModelFor(kind, model) === undefined) {
|
|
175
|
-
const origin = modelOrigin ? ` (from ${modelOrigin})` : "";
|
|
176
|
-
throw new SubagentError(
|
|
177
|
-
`Preset '${presetName}' selects kind '${kind}', but the resolved model '${model}'${origin} ` +
|
|
178
|
-
`cannot be used with that kind and would be silently dropped at start. ` +
|
|
179
|
-
`Fix the preset's kind or the model source.`,
|
|
180
|
-
ErrorCodes.INVALID_PARAMS,
|
|
181
|
-
);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
159
|
/**
|
|
186
160
|
* Return a COPY of `agent` with kind/model/thinking from `preset`.
|
|
187
161
|
*
|
package/src/agents/settings.ts
CHANGED
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
AGENT_KINDS,
|
|
12
12
|
DEFAULT_LEGION_SETTINGS,
|
|
13
13
|
type AgentOverride,
|
|
14
|
-
type HerdrSettings,
|
|
15
14
|
type LegionSettings,
|
|
16
15
|
type ModelScopeConfig,
|
|
17
16
|
type OnBlockedPolicy,
|
|
@@ -28,6 +27,7 @@ import {
|
|
|
28
27
|
readConventionContext,
|
|
29
28
|
} from "./context.ts";
|
|
30
29
|
import { OVERRIDE_FIELDS } from "./overrides.ts";
|
|
30
|
+
import { ignoredKindMessage, reportIgnoredOnce } from "../shared/ignored-kind.ts";
|
|
31
31
|
import { parseModelScopeConfig } from "./model-scope.ts";
|
|
32
32
|
import { parsePresets } from "./presets.ts";
|
|
33
33
|
|
|
@@ -88,8 +88,6 @@ function nonNegativeInt(
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
const VALID_JOIN_MODES: ReadonlySet<string> = new Set(["each", "smart"]);
|
|
91
|
-
const LEGACY_PLACEMENTS = ["split-down", "split-right", "new-tab"] as const;
|
|
92
|
-
const warnedHerdrSettings = new Set<string>();
|
|
93
91
|
|
|
94
92
|
const VALID_ON_BLOCKED_POLICIES: ReadonlySet<string> = new Set([
|
|
95
93
|
"forward",
|
|
@@ -116,73 +114,6 @@ function readJoinFields(
|
|
|
116
114
|
if (flushMs !== undefined) out.joinFlushMs = flushMs;
|
|
117
115
|
}
|
|
118
116
|
|
|
119
|
-
function parseHerdrSettings(
|
|
120
|
-
value: unknown,
|
|
121
|
-
filePath: string,
|
|
122
|
-
): HerdrSettings | undefined {
|
|
123
|
-
if (value === undefined) return undefined;
|
|
124
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
125
|
-
throw new Error(
|
|
126
|
-
`Subagent settings in '${filePath}' have invalid 'herdr'; expected an object.`,
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
const input = value as Record<string, unknown>;
|
|
130
|
-
const out: HerdrSettings = {};
|
|
131
|
-
if (!warnedHerdrSettings.has(filePath)) {
|
|
132
|
-
warnedHerdrSettings.add(filePath);
|
|
133
|
-
console.warn(
|
|
134
|
-
`[pi-legion] Settings 'subagents.herdr' in ${filePath} are deprecated and ignored by the RPC supervisor; remove them when migrating from v0.16.x.`,
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const placement = input.defaultPlacement;
|
|
139
|
-
if (placement !== undefined) {
|
|
140
|
-
if (typeof placement !== "string" || !LEGACY_PLACEMENTS.includes(placement as (typeof LEGACY_PLACEMENTS)[number])) {
|
|
141
|
-
throw new Error(
|
|
142
|
-
`Subagent settings in '${filePath}' have invalid 'herdr.defaultPlacement'; ` +
|
|
143
|
-
`expected one of: ${[...LEGACY_PLACEMENTS].join(", ")}.`,
|
|
144
|
-
);
|
|
145
|
-
}
|
|
146
|
-
out.defaultPlacement = placement as NonNullable<HerdrSettings["defaultPlacement"]>;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const maxAgents = positiveInt(
|
|
150
|
-
input.maxConcurrentAgents,
|
|
151
|
-
"herdr.maxConcurrentAgents",
|
|
152
|
-
filePath,
|
|
153
|
-
);
|
|
154
|
-
if (maxAgents !== undefined) out.maxConcurrentAgents = maxAgents;
|
|
155
|
-
|
|
156
|
-
const retries = nonNegativeInt(
|
|
157
|
-
input.startRetries,
|
|
158
|
-
"herdr.startRetries",
|
|
159
|
-
filePath,
|
|
160
|
-
);
|
|
161
|
-
if (retries !== undefined) out.startRetries = retries;
|
|
162
|
-
|
|
163
|
-
const backoff = nonNegativeInt(
|
|
164
|
-
input.startRetryBackoffMs,
|
|
165
|
-
"herdr.startRetryBackoffMs",
|
|
166
|
-
filePath,
|
|
167
|
-
);
|
|
168
|
-
if (backoff !== undefined) out.startRetryBackoffMs = backoff;
|
|
169
|
-
|
|
170
|
-
const days = positiveInt(
|
|
171
|
-
input.sessionRetentionDays,
|
|
172
|
-
"herdr.sessionRetentionDays",
|
|
173
|
-
filePath,
|
|
174
|
-
);
|
|
175
|
-
if (days !== undefined) out.sessionRetentionDays = days;
|
|
176
|
-
|
|
177
|
-
const bytes = positiveInt(
|
|
178
|
-
input.sessionRetentionMaxBytesPerRun,
|
|
179
|
-
"herdr.sessionRetentionMaxBytesPerRun",
|
|
180
|
-
filePath,
|
|
181
|
-
);
|
|
182
|
-
if (bytes !== undefined) out.sessionRetentionMaxBytesPerRun = bytes;
|
|
183
|
-
|
|
184
|
-
return out;
|
|
185
|
-
}
|
|
186
117
|
|
|
187
118
|
const LEGION_PHASES = [
|
|
188
119
|
"planning",
|
|
@@ -477,8 +408,6 @@ export function parseSubagentSettings(
|
|
|
477
408
|
const modelScope = parseModelScopeConfig(input.modelScope, { filePath });
|
|
478
409
|
if (modelScope) out.modelScope = modelScope;
|
|
479
410
|
|
|
480
|
-
const herdr = parseHerdrSettings(input.herdr, filePath);
|
|
481
|
-
if (herdr) out.herdr = herdr;
|
|
482
411
|
const legion = parseLegionSettings(input.legion, filePath);
|
|
483
412
|
if (legion !== undefined) out.legion = legion;
|
|
484
413
|
|
|
@@ -604,6 +533,21 @@ function parseInProcessLimits(
|
|
|
604
533
|
filePath,
|
|
605
534
|
);
|
|
606
535
|
if (waitTimeoutMs !== undefined) out.waitTimeoutMs = waitTimeoutMs;
|
|
536
|
+
// D10: launch-admission-queue bounds. `positiveInt` on purpose — 0 is
|
|
537
|
+
// invalid, not "queue disabled" (that would silently swallow every queue).
|
|
538
|
+
// Defaults live in `src/supervisor/admission-queue.ts` (single source).
|
|
539
|
+
const maxQueuedLaunches = positiveInt(
|
|
540
|
+
value.maxQueuedLaunches,
|
|
541
|
+
"inprocess.maxQueuedLaunches",
|
|
542
|
+
filePath,
|
|
543
|
+
);
|
|
544
|
+
if (maxQueuedLaunches !== undefined) out.maxQueuedLaunches = maxQueuedLaunches;
|
|
545
|
+
const launchQueueTimeoutMs = positiveInt(
|
|
546
|
+
value.launchQueueTimeoutMs,
|
|
547
|
+
"inprocess.launchQueueTimeoutMs",
|
|
548
|
+
filePath,
|
|
549
|
+
);
|
|
550
|
+
if (launchQueueTimeoutMs !== undefined) out.launchQueueTimeoutMs = launchQueueTimeoutMs;
|
|
607
551
|
// Unknown keys are ignored (same as other settings blobs).
|
|
608
552
|
return out;
|
|
609
553
|
}
|
|
@@ -639,6 +583,15 @@ function parseAgentOverride(
|
|
|
639
583
|
// though older settings files may still carry it in an agent override.
|
|
640
584
|
const normalized: Record<string, unknown> = { ...value };
|
|
641
585
|
delete normalized.placement;
|
|
586
|
+
// A non-`pi` kind is reported once and ignored rather than rejected: the key
|
|
587
|
+
// is still legal, it only has one accepted value in v4.
|
|
588
|
+
if (normalized.kind !== undefined && !isValidOverrideField("kind", normalized.kind)) {
|
|
589
|
+
reportIgnoredOnce(
|
|
590
|
+
`override:${field}`,
|
|
591
|
+
ignoredKindMessage({ origin: `settings override '${field}'`, subject: `in ${filePath}`, value: normalized.kind }),
|
|
592
|
+
);
|
|
593
|
+
delete normalized.kind;
|
|
594
|
+
}
|
|
642
595
|
for (const [key, member] of Object.entries(normalized)) {
|
|
643
596
|
const expected = overrideFieldExpected(key);
|
|
644
597
|
if (
|
|
@@ -814,7 +767,7 @@ export function loadSubagentSettings(
|
|
|
814
767
|
|
|
815
768
|
/**
|
|
816
769
|
* Merge project settings over user settings.
|
|
817
|
-
* `
|
|
770
|
+
* `legion`, `agentOverrides`, `teams` and `presets` shallow-merge;
|
|
818
771
|
* `legion.phaseTimeouts` also merges per phase. `modelScope` is replaced
|
|
819
772
|
* wholesale. A project team with the same name replaces the user's complete
|
|
820
773
|
* team definition.
|
|
@@ -863,10 +816,6 @@ export function resolveSubagentSettings(
|
|
|
863
816
|
}
|
|
864
817
|
if (project.team !== undefined) out.team = project.team;
|
|
865
818
|
|
|
866
|
-
if (project.herdr) {
|
|
867
|
-
out.herdr = { ...(user.herdr ?? {}), ...project.herdr };
|
|
868
|
-
}
|
|
869
|
-
|
|
870
819
|
if (project.legion !== undefined || user.legion !== undefined) {
|
|
871
820
|
out.legion = mergeLegionSettings(user.legion, project.legion);
|
|
872
821
|
}
|
package/src/agents/step-model.ts
CHANGED
|
@@ -9,18 +9,14 @@
|
|
|
9
9
|
* 1. A referenced preset's kind/model/thinking reach the child, and its
|
|
10
10
|
* model beats `agentOverrides` (which `loadCatalog` folds into
|
|
11
11
|
* `agent.model` before anything here runs).
|
|
12
|
-
* 2. `kind` and the FINAL model are coherent, or the launch is refused
|
|
13
|
-
* loudly instead of dropping the model at start (`nativeModelFor`).
|
|
14
12
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* (a per-run tool `model`, `defaultModel`, the dispatch model, an override),
|
|
18
|
-
* so validating the preset's own pair would let an incoherent pair ship.
|
|
13
|
+
* v4 runs `kind: pi` only, so there is no second invariant to police here:
|
|
14
|
+
* the resolved model always belongs to the one supported kind.
|
|
19
15
|
*/
|
|
20
16
|
|
|
21
17
|
import type { AgentConfig, ModelOrigin, PresetConfig } from "../shared/types.ts";
|
|
22
18
|
import { providerOf, resolveModel, type ResolvedModel } from "./model-resolution.ts";
|
|
23
|
-
import {
|
|
19
|
+
import { expandPreset } from "./presets.ts";
|
|
24
20
|
|
|
25
21
|
export interface StepModelInput {
|
|
26
22
|
/** The catalog agent, with `agentOverrides` already folded in. */
|
|
@@ -59,9 +55,8 @@ export interface StepModelResult {
|
|
|
59
55
|
/**
|
|
60
56
|
* Resolve the model (and the effective agent) a step will launch with.
|
|
61
57
|
*
|
|
62
|
-
* Throws when a referenced preset is undefined
|
|
63
|
-
*
|
|
64
|
-
* turn those into a refusal line rather than letting them escape.
|
|
58
|
+
* Throws when a referenced preset is undefined. Callers are expected to turn
|
|
59
|
+
* that into a refusal line rather than letting it escape.
|
|
65
60
|
*/
|
|
66
61
|
export function resolveStepModel(input: StepModelInput): StepModelResult {
|
|
67
62
|
const override = input.step.model ?? input.params.model;
|
|
@@ -88,18 +83,8 @@ export function resolveStepModel(input: StepModelInput): StepModelResult {
|
|
|
88
83
|
settings: input.settings as Parameters<typeof resolveModel>[0]["settings"],
|
|
89
84
|
});
|
|
90
85
|
|
|
91
|
-
// Only a preset can
|
|
92
|
-
//
|
|
93
|
-
// pre-existing behaviour (that is an older, separate concern).
|
|
94
|
-
if (expanded) {
|
|
95
|
-
const origin = classifyModelOrigin(resolved, override);
|
|
96
|
-
assertKindModelCoherent(
|
|
97
|
-
effective.kind,
|
|
98
|
-
resolved.model,
|
|
99
|
-
expanded.presetName,
|
|
100
|
-
origin.label,
|
|
101
|
-
);
|
|
102
|
-
}
|
|
86
|
+
// Only a preset can change the kind, and v4 accepts `pi` alone, so nothing
|
|
87
|
+
// here can leave the kind and the launched model incoherent.
|
|
103
88
|
|
|
104
89
|
return {
|
|
105
90
|
resolved,
|
package/src/api.ts
CHANGED
|
@@ -16,19 +16,6 @@
|
|
|
16
16
|
* ```
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
// ── deprecated herdr transport ──────────────────────────────────────────────
|
|
20
|
-
/** @deprecated Use `RpcSupervisor` for the v2 execution path. */
|
|
21
|
-
export {
|
|
22
|
-
createHerdrClient,
|
|
23
|
-
parseHerdrResponse,
|
|
24
|
-
mapHerdrErrorCode,
|
|
25
|
-
readPaneDiagnostic,
|
|
26
|
-
SubagentError,
|
|
27
|
-
} from "./backends/legacy/client.ts";
|
|
28
|
-
/** @deprecated Only for pi-legion v0.16.x Herdr workflows. */
|
|
29
|
-
export { createCommandRunner, resolveHerdrBin } from "./backends/legacy/runner.ts";
|
|
30
|
-
export type { RunnerOptions } from "./backends/legacy/runner.ts";
|
|
31
|
-
|
|
32
19
|
// ── agent definitions ───────────────────────────────────────────────────────
|
|
33
20
|
export {
|
|
34
21
|
BUILTIN_AGENT_NAMES,
|
|
@@ -66,7 +53,6 @@ export type {
|
|
|
66
53
|
} from "./agents/model-resolution.ts";
|
|
67
54
|
export {
|
|
68
55
|
applyPreset,
|
|
69
|
-
assertKindModelCoherent,
|
|
70
56
|
expandPreset,
|
|
71
57
|
parsePresets,
|
|
72
58
|
requirePreset,
|
|
@@ -191,13 +177,6 @@ export {
|
|
|
191
177
|
THINKING_LEVELS,
|
|
192
178
|
} from "./runs/args.ts";
|
|
193
179
|
export type { BuildArgsInput, BuildArgsResult } from "./runs/args.ts";
|
|
194
|
-
export {
|
|
195
|
-
cursorModel,
|
|
196
|
-
isPiShapedModel,
|
|
197
|
-
nativeModelFor,
|
|
198
|
-
planKindStart,
|
|
199
|
-
} from "./runs/kind.ts";
|
|
200
|
-
export type { KindStartPlan } from "./runs/kind.ts";
|
|
201
180
|
export {
|
|
202
181
|
createChildWorktree,
|
|
203
182
|
isGitRepo,
|
|
@@ -292,7 +271,7 @@ export {
|
|
|
292
271
|
export type { LiveProgress } from "./shared/progress.ts";
|
|
293
272
|
|
|
294
273
|
// ── shared vocabulary ───────────────────────────────────────────────────────
|
|
295
|
-
export { AGENT_KINDS } from "./shared/types.ts";
|
|
274
|
+
export { AGENT_KINDS, SubagentError } from "./shared/types.ts";
|
|
296
275
|
export type {
|
|
297
276
|
AcceptanceConfig,
|
|
298
277
|
AcceptanceCriterion,
|
|
@@ -326,16 +305,3 @@ export type {
|
|
|
326
305
|
TurnBudgetConfig,
|
|
327
306
|
Usage,
|
|
328
307
|
} from "./shared/types.ts";
|
|
329
|
-
/** @deprecated v0.16.x pane backend compatibility types. */
|
|
330
|
-
export type {
|
|
331
|
-
AgentInfo,
|
|
332
|
-
AgentStartResult,
|
|
333
|
-
CommandRunner,
|
|
334
|
-
HerdrClient,
|
|
335
|
-
HerdrError,
|
|
336
|
-
HerdrResult,
|
|
337
|
-
PaneInfo,
|
|
338
|
-
ProcessInfo,
|
|
339
|
-
ReadSource,
|
|
340
|
-
TabInfo,
|
|
341
|
-
} from "./backends/legacy/types.ts";
|
|
@@ -2,11 +2,20 @@ import type { DatabaseSync } from "../legion/db.ts";
|
|
|
2
2
|
import {
|
|
3
3
|
LEGION_EVENT_TYPES as STORED_LEGION_EVENT_TYPES,
|
|
4
4
|
getDataVersion,
|
|
5
|
-
|
|
5
|
+
readEventsAfterTolerant,
|
|
6
6
|
} from "../legion/events.ts";
|
|
7
7
|
import type { LegionEventType as StoredLegionEventType } from "../legion/events.ts";
|
|
8
8
|
|
|
9
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* Process-only activity. These events are never written to the closed DB enum.
|
|
11
|
+
*
|
|
12
|
+
* Launch-admission-queue (issue #266, contract D12) belongs here and ONLY here:
|
|
13
|
+
* queue residency is a fact about *this* process (the queue lives in memory and
|
|
14
|
+
* dies with it), so persisting it would be meaningless. Adding a type to
|
|
15
|
+
* `src/legion/events.ts` instead would freeze the DB event cursor (#233); the
|
|
16
|
+
* mirrored pair of assertions in `test/unit/teams.test.ts` (A13) guards both
|
|
17
|
+
* directions of that boundary.
|
|
18
|
+
*/
|
|
10
19
|
export const LEGION_PROCESS_EVENT_TYPES = [
|
|
11
20
|
"node_launched",
|
|
12
21
|
"node_steered",
|
|
@@ -17,6 +26,8 @@ export const LEGION_PROCESS_EVENT_TYPES = [
|
|
|
17
26
|
"tool_call",
|
|
18
27
|
"agent_settled",
|
|
19
28
|
"turn_start",
|
|
29
|
+
"launch_queued",
|
|
30
|
+
"launch_admitted",
|
|
20
31
|
] as const;
|
|
21
32
|
export type LegionProcessEventType = (typeof LEGION_PROCESS_EVENT_TYPES)[number];
|
|
22
33
|
|
|
@@ -46,14 +57,41 @@ export type LegionEventHandler = (event: LegionEvent) => void;
|
|
|
46
57
|
export interface LegionEventBus {
|
|
47
58
|
on(type: LegionEventType, handler: LegionEventHandler): () => void;
|
|
48
59
|
emit(event: LegionEvent): void;
|
|
60
|
+
/**
|
|
61
|
+
* Report an error raised OUTSIDE a subscriber's synchronous dispatch window
|
|
62
|
+
* (a deferred repaint) through the same `onHandlerError` channel a throwing
|
|
63
|
+
* handler uses. `createLegionEventBus` isolates a handler's throw so one bad
|
|
64
|
+
* consumer cannot break delivery; a subscriber that defers work past its own
|
|
65
|
+
* dispatch window would otherwise escape that isolation entirely (#238).
|
|
66
|
+
*
|
|
67
|
+
* There is deliberately NO `event`/`handler` argument: the throw happened after
|
|
68
|
+
* dispatch, so there is no real event to attribute it to, and fabricating one
|
|
69
|
+
* (a synthetic `ui_request` on an empty nodeId) would make any consumer that
|
|
70
|
+
* groups by `event.nodeId` record a phantom event on a node that never exists.
|
|
71
|
+
* Consumers that need correlation should use `context`.
|
|
72
|
+
*
|
|
73
|
+
* Optional so any structural stand-in for a bus keeps working; a caller with no
|
|
74
|
+
* channel must contain the error itself rather than rethrow it onto the
|
|
75
|
+
* microtask queue (which is a host-killing crash, the very bug this prevents).
|
|
76
|
+
*/
|
|
77
|
+
reportError?(error: unknown, context: "deferred-handler"): void;
|
|
49
78
|
}
|
|
50
79
|
|
|
51
80
|
export interface LegionEventBusOptions {
|
|
52
81
|
/** Optional observer for isolated subscriber failures. Failures here are isolated too. */
|
|
53
82
|
onHandlerError?: (
|
|
54
83
|
error: unknown,
|
|
55
|
-
|
|
56
|
-
|
|
84
|
+
/**
|
|
85
|
+
* The event whose handler threw, or `null` for a failure reported outside a
|
|
86
|
+
* dispatch window (`reportError`, e.g. a deferred repaint). `null` means "no
|
|
87
|
+
* event", never "an event with no node": do not group it by `nodeId`.
|
|
88
|
+
*/
|
|
89
|
+
event: LegionEvent | null,
|
|
90
|
+
/**
|
|
91
|
+
* The failing handler, or a short origin tag for a deferred failure
|
|
92
|
+
* (`"deferred-handler"`) where no single handler is responsible.
|
|
93
|
+
*/
|
|
94
|
+
handler: LegionEventHandler | string,
|
|
57
95
|
) => void;
|
|
58
96
|
}
|
|
59
97
|
|
|
@@ -110,6 +148,18 @@ export function createLegionEventBus(
|
|
|
110
148
|
}
|
|
111
149
|
}
|
|
112
150
|
},
|
|
151
|
+
reportError(error, context) {
|
|
152
|
+
// No `event`/`handler` to attribute this to: the throw happened after the
|
|
153
|
+
// dispatch window closed. Pass only what is real (the error and the
|
|
154
|
+
// origin) — a synthetic event would be a fabricated fact. An unconfigured
|
|
155
|
+
// `onHandlerError` stays silent, which is what `emit()` already does for a
|
|
156
|
+
// throwing handler; `index.ts` wires one so the containment is not silent.
|
|
157
|
+
try {
|
|
158
|
+
options.onHandlerError?.(error, null, context);
|
|
159
|
+
} catch {
|
|
160
|
+
// Error reporting must never itself break delivery.
|
|
161
|
+
}
|
|
162
|
+
},
|
|
113
163
|
};
|
|
114
164
|
}
|
|
115
165
|
|
|
@@ -123,7 +173,15 @@ export interface EventSourceClock {
|
|
|
123
173
|
export interface DbEventSourceOptions {
|
|
124
174
|
/** Contract §10.2 recommends 200–500ms; default is 250ms. */
|
|
125
175
|
pollMs?: number;
|
|
126
|
-
/**
|
|
176
|
+
/**
|
|
177
|
+
* Resume after this persisted event id. Defaults to the beginning of the log.
|
|
178
|
+
*
|
|
179
|
+
* The default is deliberately "replay everything": a caller that attaches to
|
|
180
|
+
* a ledger it did not just write should seed this at the current tail instead
|
|
181
|
+
* (`maxEventId(db)` from `src/legion/events.ts`) — that is what the production
|
|
182
|
+
* attach path in `index.ts` does (#238, `createLegionRuntime`). A caller that
|
|
183
|
+
* really wants the full replay passes `cursor: 0` explicitly.
|
|
184
|
+
*/
|
|
127
185
|
cursor?: number;
|
|
128
186
|
/** Injectable interval scheduler for deterministic tests and embedding. */
|
|
129
187
|
clock?: EventSourceClock;
|
|
@@ -193,13 +251,27 @@ export function createDbEventSource(
|
|
|
193
251
|
// Drain all batches for this dirty version so the cursor cannot strand
|
|
194
252
|
// events when a busy producer commits more than one batch at once.
|
|
195
253
|
for (;;) {
|
|
196
|
-
|
|
254
|
+
let skipped = 0;
|
|
255
|
+
const batch = readEventsAfterTolerant(db, cursor, {
|
|
256
|
+
limit: EVENT_BATCH_SIZE,
|
|
257
|
+
onInvalidRow: (error, rowId) => {
|
|
258
|
+
skipped += 1;
|
|
259
|
+
reportError(error);
|
|
260
|
+
// Advance past the undecodable row so one bad row cannot stall the stream.
|
|
261
|
+
if (rowId > cursor) cursor = rowId;
|
|
262
|
+
},
|
|
263
|
+
});
|
|
197
264
|
for (const event of batch) {
|
|
198
265
|
currentBus.emit(event);
|
|
199
|
-
|
|
266
|
+
// Rows are ascending, but onInvalidRow may already have moved the cursor
|
|
267
|
+
// past a poison row in this same batch. Never let a decoded row rewind it,
|
|
268
|
+
// or the skipped row is re-read and re-reported on the next poll.
|
|
269
|
+
if (event.id > cursor) cursor = event.id;
|
|
200
270
|
if (!running || bus !== currentBus) return;
|
|
201
271
|
}
|
|
202
|
-
|
|
272
|
+
// Skips count toward fullness: a batch of only poison rows still read
|
|
273
|
+
// a full page, so it must not be mistaken for the tail of the log.
|
|
274
|
+
if (batch.length + skipped < EVENT_BATCH_SIZE) break;
|
|
203
275
|
}
|
|
204
276
|
lastDataVersion = dataVersion;
|
|
205
277
|
} catch (error) {
|