pi-legion 2.0.4 → 3.0.1
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 +96 -1
- package/CONTRIBUTING.md +27 -24
- package/README.md +103 -19
- package/docs/contracts/osc-busy-plugin.md +72 -0
- package/docs/contracts/spawn-default-inprocess-v3.md +322 -0
- package/docs/contracts/wait-not-stop.md +30 -0
- package/docs/design.md +2 -2
- package/docs/headless-child-contract.md +83 -0
- package/docs/inprocess-supervisor.md +140 -0
- package/docs/qa-hunting-round.md +150 -0
- package/docs/subagents-teams.md +55 -18
- package/docs/tui-qa-playbook.md +284 -0
- package/index.ts +315 -49
- package/package.json +24 -13
- package/skills/pi-legion/SKILL.md +5 -0
- package/src/agents/agents.ts +34 -6
- package/src/agents/context.ts +83 -1
- package/src/agents/overrides.ts +1 -0
- package/src/agents/settings.ts +122 -1
- package/src/api.ts +23 -0
- package/src/extension/child-guard.ts +42 -3
- package/src/extension/legion-commands.ts +9 -2
- package/src/extension/legion-doctor.ts +54 -0
- package/src/extension/legion-stop.ts +6 -2
- package/src/extension/playbook.ts +3 -3
- package/src/extension/runtime.ts +57 -8
- package/src/extension/scout-guard.ts +39 -1
- package/src/extension/session-errors.ts +191 -0
- package/src/runs/args.ts +8 -0
- package/src/runs/orchestrator.ts +125 -16
- package/src/shared/output-cap.ts +2 -2
- package/src/shared/session.ts +2 -1
- package/src/shared/types.ts +74 -0
- package/src/supervisor/child-context.ts +90 -0
- package/src/supervisor/child-memory.ts +27 -3
- package/src/supervisor/inprocess-depth.ts +71 -0
- package/src/supervisor/inprocess-supervisor.ts +1011 -0
- package/src/supervisor/routing-supervisor.ts +179 -0
- package/src/supervisor/rpc-supervisor.ts +14 -0
- package/src/supervisor/spawn-mode.ts +35 -0
- package/src/supervisor/types.ts +13 -0
- package/src/supervisor/ui-proxy.ts +78 -0
- package/src/tui/live-overlay.ts +147 -55
- package/src/tui/status.ts +71 -35
- package/src/tui/viz/model.ts +6 -0
- package/src/tui/viz/panels.ts +8 -9
- package/src/tui/viz/railway.ts +154 -21
- package/src/tui/viz/rate.ts +15 -14
package/src/runs/orchestrator.ts
CHANGED
|
@@ -12,13 +12,13 @@ import * as path from "node:path";
|
|
|
12
12
|
import { modelCandidates } from "../agents/model-resolution.ts";
|
|
13
13
|
import { DEFAULT_TEAM, TEAM_ENV, type ActiveTeam } from "../agents/teams.ts";
|
|
14
14
|
import type { LegionSettings } from "../shared/types.ts";
|
|
15
|
-
import {
|
|
15
|
+
import { LEGION_IDENTITY_ENV_KEYS, legionIdentityEnv } from "./args.ts";
|
|
16
16
|
import type { DatabaseSync } from "../legion/db.ts";
|
|
17
17
|
import type { RunStore } from "./store.ts";
|
|
18
18
|
import type { LegionEventBus, LegionProcessEventType } from "../extension/event-bus.ts";
|
|
19
19
|
import { appendEvent } from "../legion/events.ts";
|
|
20
20
|
import { withLaunchBudget, type LegionBudgetLimits } from "../legion/budget.ts";
|
|
21
|
-
import { getChildByName, getNode, insertNode, nodeIdForChild, sanitizeNodeSlug, type LegionNode, updateNode
|
|
21
|
+
import { getChildByName, getNode, insertNode, nodeIdForChild, sanitizeNodeSlug, type LegionNode, updateNode } from "../legion/nodes.ts";
|
|
22
22
|
import { upsertUsage } from "../legion/usage.ts";
|
|
23
23
|
import { evaluatePhaseTransition, isLegionPhase, isTerminalPhase } from "../legion/state-machine.ts";
|
|
24
24
|
import { CHILD_ACCEPTANCE_ROLE_ENV, CHILD_ROLE_ENV } from "../extension/child-guard.ts";
|
|
@@ -54,11 +54,13 @@ import {
|
|
|
54
54
|
type NestedPathEntry,
|
|
55
55
|
type RetireOptions,
|
|
56
56
|
type RunRecord,
|
|
57
|
+
type SpawnMode,
|
|
57
58
|
SubagentError,
|
|
58
59
|
type Usage,
|
|
59
60
|
} from "../shared/types.ts";
|
|
60
61
|
import { RpcSupervisor } from "../supervisor/rpc-supervisor.ts";
|
|
61
62
|
import type { LegionSupervisor, SettleResult, SpawnInput } from "../supervisor/types.ts";
|
|
63
|
+
import { isInProcessSpawn, resolveSpawnMode } from "../supervisor/spawn-mode.ts";
|
|
62
64
|
import type { PendingUIRequest, UIProxy } from "../supervisor/ui-proxy.ts";
|
|
63
65
|
import { nativeModelFor } from "./kind.ts";
|
|
64
66
|
import { applyVerification, type VerifyRunner } from "./acceptance.ts";
|
|
@@ -107,6 +109,11 @@ export interface OrchestratorDeps {
|
|
|
107
109
|
worktreeBootstrapTimeoutMs?: number;
|
|
108
110
|
team?: ActiveTeam;
|
|
109
111
|
childContext?: string;
|
|
112
|
+
/**
|
|
113
|
+
* When true, RPC child argv includes `--approve` (`subagents.rpc.approve`).
|
|
114
|
+
* Default false / omitted.
|
|
115
|
+
*/
|
|
116
|
+
rpcApprove?: boolean;
|
|
110
117
|
onBudgetRefused?: (event: BudgetRefusedEvent) => void;
|
|
111
118
|
}
|
|
112
119
|
|
|
@@ -132,9 +139,20 @@ const CHILD_ENV = "PI_SUBAGENT_CHILD";
|
|
|
132
139
|
const POLL_INTERVAL_MS = 500;
|
|
133
140
|
|
|
134
141
|
function parseDepthCeiling(raw: string | undefined): number | undefined {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
142
|
+
// Genuinely unset / blank → no env ceiling (settings + hard cap still apply).
|
|
143
|
+
if (raw === undefined) return undefined;
|
|
144
|
+
const t = raw.trim();
|
|
145
|
+
if (t === "") return undefined;
|
|
146
|
+
// #173: illegal values fail closed to leaf-only (0), matching contract :361 —
|
|
147
|
+
// never widen to the hard cap via `undefined` → +Infinity.
|
|
148
|
+
if (!/^\d+$/.test(t)) {
|
|
149
|
+
console.error(
|
|
150
|
+
`[pi-legion] ${MAX_DEPTH_ENV}=${JSON.stringify(t)} is not a non-negative integer; treating as 0 (leaf only).`,
|
|
151
|
+
);
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
const value = Number(t);
|
|
155
|
+
return Number.isInteger(value) && value >= 0 ? value : 0;
|
|
138
156
|
}
|
|
139
157
|
|
|
140
158
|
/** Root/process ceiling: inherited env, settings, and the saturated path cap. */
|
|
@@ -234,6 +252,7 @@ export class Orchestrator {
|
|
|
234
252
|
private readonly worktreeBootstrapTimeoutMs?: number;
|
|
235
253
|
private readonly team?: ActiveTeam;
|
|
236
254
|
private readonly childContext?: string;
|
|
255
|
+
private readonly rpcApprove: boolean;
|
|
237
256
|
private readonly onBudgetRefused?: (event: BudgetRefusedEvent) => void;
|
|
238
257
|
private readonly legion?: OrchestratorLegionContext;
|
|
239
258
|
private readonly requestedDepthCap?: number;
|
|
@@ -267,6 +286,7 @@ export class Orchestrator {
|
|
|
267
286
|
this.worktreeBootstrapTimeoutMs = deps.worktreeBootstrapTimeoutMs;
|
|
268
287
|
this.team = deps.team;
|
|
269
288
|
this.childContext = deps.childContext?.trim() || undefined;
|
|
289
|
+
this.rpcApprove = deps.rpcApprove === true;
|
|
270
290
|
this.onBudgetRefused = deps.onBudgetRefused;
|
|
271
291
|
this.legion = deps.legion;
|
|
272
292
|
}
|
|
@@ -341,11 +361,24 @@ export class Orchestrator {
|
|
|
341
361
|
const depthCeiling = input.utility
|
|
342
362
|
? parent.depth + 1
|
|
343
363
|
: Math.min(legion.settings?.maxDepth ?? this.maxDepthValue, MAX_NESTED_PATH_ENTRIES);
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
364
|
+
// Resume must NOT inflate maxChildrenPerNode (#180). Retired nodes free
|
|
365
|
+
// slots (#142); turning one live again re-charges the fanout cap — same
|
|
366
|
+
// gate as a fresh launch. activeReservation below still covers the node's
|
|
367
|
+
// own maxActiveNodes slot when it is already starting/running.
|
|
368
|
+
const maxChildren = legion.settings?.maxChildrenPerNode ?? 8;
|
|
347
369
|
const activeReservation = previous && (previous.status === "starting" || previous.status === "running") ? 1 : 0;
|
|
348
|
-
|
|
370
|
+
// In-process children use subagents.inprocess.* only — do not spend RPC legion.* slots (#197).
|
|
371
|
+
const limits: LegionBudgetLimits = isInProcessSpawn(input.agent)
|
|
372
|
+
? {
|
|
373
|
+
maxDepth: MAX_NESTED_PATH_ENTRIES,
|
|
374
|
+
maxChildrenPerNode: Number.MAX_SAFE_INTEGER,
|
|
375
|
+
maxActiveNodes: Number.MAX_SAFE_INTEGER,
|
|
376
|
+
}
|
|
377
|
+
: {
|
|
378
|
+
maxDepth: depthCeiling,
|
|
379
|
+
maxChildrenPerNode: maxChildren,
|
|
380
|
+
maxActiveNodes: (legion.settings?.maxActiveNodes ?? 30) + activeReservation,
|
|
381
|
+
};
|
|
349
382
|
const gate = withLaunchBudget(legion.db, { parentId: legion.nodeId, depth: parent.depth + 1, nodeId: input.nodeId }, () => {
|
|
350
383
|
if (previous) {
|
|
351
384
|
updateNode(legion.db, input.nodeId, { status: "starting", sessionFile: input.sessionFile, worktreePath: input.worktreePath ?? previous.worktreePath, model: input.model ?? previous.model, team: team && team !== DEFAULT_TEAM ? team : previous.team }, this.now);
|
|
@@ -520,10 +553,6 @@ export class Orchestrator {
|
|
|
520
553
|
const ledgerPrevious = nodeId ? getNode(this.legion!.db, nodeId) : null;
|
|
521
554
|
const previous = this.children.get(name) ?? (ledgerPrevious ? this.childFromNode(name, ledgerPrevious) : undefined);
|
|
522
555
|
if (previous && !this.children.has(name)) this.children.set(name, previous);
|
|
523
|
-
// A utility dispatch is a hard leaf, not a nested command-chain launch.
|
|
524
|
-
// The frozen contract therefore skips assertWithinBudgets entirely.
|
|
525
|
-
if (!input.utilityDispatch) this.assertWithinBudgets(Boolean(previous));
|
|
526
|
-
if (this.supervisor.isAlive(name)) throw new SubagentError(`child is already alive: ${name}`, ErrorCodes.NAME_TAKEN);
|
|
527
556
|
const models = this.planModelCandidates({
|
|
528
557
|
agent: input.agent,
|
|
529
558
|
...(input.model !== undefined ? { model: input.model } : {}),
|
|
@@ -542,6 +571,53 @@ export class Orchestrator {
|
|
|
542
571
|
launch: input.worktree,
|
|
543
572
|
parentInWorktree: isInsideChildWorktree(this.cwd),
|
|
544
573
|
});
|
|
574
|
+
// Spawn-mode arbitration (contract D2). `resolveSpawnMode` only reports the
|
|
575
|
+
// configured intent; the launch context decides whether an OMITTED mode can
|
|
576
|
+
// actually run in-process. An omitted mode falls back to rpc when this
|
|
577
|
+
// launch needs worktree isolation (a hard requirement of the in-process
|
|
578
|
+
// backend). An explicit value is never downgraded (invariant I6) — a bad
|
|
579
|
+
// explicit combination still throws below.
|
|
580
|
+
// `kind !== "pi"` is NOT re-tested here: the early guard above (before this
|
|
581
|
+
// arbitration) already refuses it, so such a clause would be dead code.
|
|
582
|
+
const explicitMode = input.agent.spawnMode !== undefined;
|
|
583
|
+
const mode: SpawnMode =
|
|
584
|
+
!explicitMode && worktree === true
|
|
585
|
+
? "rpc"
|
|
586
|
+
: resolveSpawnMode(input.agent);
|
|
587
|
+
const inProcess = mode === "inprocess";
|
|
588
|
+
// Downstream must see the arbitrated result, otherwise RoutingSupervisor
|
|
589
|
+
// re-resolves the omitted field to `inprocess` on its own and throws
|
|
590
|
+
// "cannot combine with worktree: true" — the arbitration would be moot.
|
|
591
|
+
// `worktree` is normalized too: a role card that pins `worktree: true`
|
|
592
|
+
// (e.g. `agents/worker.md`) keeps that raw value even when THIS launch
|
|
593
|
+
// resolved to no isolation (`parentRole === "centurion"`, an explicit
|
|
594
|
+
// `launch({ worktree: false })`, or the nested-worktree suppression), and
|
|
595
|
+
// leaking it makes `{spawnMode:"inprocess", worktree:true}` reach
|
|
596
|
+
// routing-supervisor.ts:79 / inprocess-supervisor.ts:342, which reject it
|
|
597
|
+
// with INVALID_PARAMS. That shape is exactly the "centurion dispatches
|
|
598
|
+
// worker" production path, so it must be normalized to `worktree: false`.
|
|
599
|
+
// The `&& worktree` short-circuit keeps the rpc path returning the
|
|
600
|
+
// original object (invariant I3: zero diff on the RPC backend).
|
|
601
|
+
// reserveNode() and SpawnInput.agent below both receive this clone.
|
|
602
|
+
const agent: AgentConfig = mode === input.agent.spawnMode && worktree
|
|
603
|
+
? input.agent
|
|
604
|
+
: { ...input.agent, spawnMode: mode, ...(worktree ? {} : { worktree: false }) };
|
|
605
|
+
// A utility dispatch is a hard leaf, not a nested command-chain launch.
|
|
606
|
+
// In-process children use subagents.inprocess.* budgets (never RPC keys).
|
|
607
|
+
if (!input.utilityDispatch && !inProcess) this.assertWithinBudgets(Boolean(previous));
|
|
608
|
+
if (this.supervisor.isAlive(name)) throw new SubagentError(`child is already alive: ${name}`, ErrorCodes.NAME_TAKEN);
|
|
609
|
+
// Only an EXPLICIT inprocess can reach this: the arbitration above already
|
|
610
|
+
// routed every omitted-mode launch that needs a worktree to rpc, so I1
|
|
611
|
+
// (explicit inprocess + worktree stays a loud throw) holds. This replaces
|
|
612
|
+
// the old gate, which inspected the raw `worktree` fields and therefore
|
|
613
|
+
// rejected callers that explicitly overrode a worktree role with
|
|
614
|
+
// `launch({ worktree: false })` — that override now wins as it should.
|
|
615
|
+
if (inProcess && worktree) {
|
|
616
|
+
throw new SubagentError(
|
|
617
|
+
`spawnMode "inprocess" cannot use worktree isolation for '${name}'`,
|
|
618
|
+
ErrorCodes.INVALID_PARAMS,
|
|
619
|
+
);
|
|
620
|
+
}
|
|
545
621
|
let worktreePath = previous?.worktreePath;
|
|
546
622
|
let worktreeBranch = previous?.worktreeBranch;
|
|
547
623
|
let createdWorktree = false;
|
|
@@ -588,7 +664,7 @@ export class Orchestrator {
|
|
|
588
664
|
preCreateSessionFile(sessionFile);
|
|
589
665
|
if (this.legion && nodeId) {
|
|
590
666
|
const reservationRunId = createdRunId ?? (previous ? ledgerPrevious?.runId : undefined) ?? this.legion.runId;
|
|
591
|
-
reservedNode = this.reserveNode({ nodeId, name, agent
|
|
667
|
+
reservedNode = this.reserveNode({ nodeId, name, agent, task: input.task, sessionFile, runId: reservationRunId, team: input.team, worktreePath, model: models.candidates[0], utility: Boolean(input.utilityDispatch && input.agent.utility) });
|
|
592
668
|
reserved = true;
|
|
593
669
|
parentReservationCreated = await this.persistParentReservation({ nodeId, name, agent: input.agent, sessionFile, ...(models.candidates[0] ? { model: models.candidates[0] } : {}), ...(worktreePath ? { worktreePath } : {}) });
|
|
594
670
|
}
|
|
@@ -604,7 +680,7 @@ export class Orchestrator {
|
|
|
604
680
|
const spawnInput: SpawnInput = {
|
|
605
681
|
name,
|
|
606
682
|
task: input.task,
|
|
607
|
-
agent
|
|
683
|
+
agent,
|
|
608
684
|
sessionFile,
|
|
609
685
|
tempDir,
|
|
610
686
|
cwd: worktreePath ?? this.cwd,
|
|
@@ -616,6 +692,34 @@ export class Orchestrator {
|
|
|
616
692
|
...(input.agent.allowNestedSubagents !== undefined && !input.agent.utility ? { allowNestedSubagents: input.agent.allowNestedSubagents } : {}),
|
|
617
693
|
...(worktreeBranch ? { worktreeBranch } : {}),
|
|
618
694
|
...(this.childContext ? { childContext: this.childContext } : {}),
|
|
695
|
+
...(this.rpcApprove ? { approve: true } : {}),
|
|
696
|
+
// D5: in-process children cannot inherit lineage through env (they share
|
|
697
|
+
// the parent's process.env and would read the PARENT's identity, or none
|
|
698
|
+
// at the root). Hand the arbitrator a real identity scope instead. Only
|
|
699
|
+
// the in-process backend consumes it; RPC lineage stays on env (I3).
|
|
700
|
+
//
|
|
701
|
+
// `stripLegionIdentity` (vexillatio) must mask lineage on BOTH
|
|
702
|
+
// channels: the env keys are deleted above, so the context must not
|
|
703
|
+
// hand back the same identity through ALS. Keep the scope (so the
|
|
704
|
+
// child still knows it is not the root) but omit node/db lineage.
|
|
705
|
+
...(inProcess && this.legion
|
|
706
|
+
? {
|
|
707
|
+
childLaunchContext: {
|
|
708
|
+
...(!input.stripLegionIdentity && nodeId ? { nodeId } : {}),
|
|
709
|
+
...(input.stripLegionIdentity
|
|
710
|
+
? {}
|
|
711
|
+
: { parentNodeId: this.legion.nodeId, dbPath: this.legion.dbPath }),
|
|
712
|
+
// The nested path is lineage too: LEGION_IDENTITY_ENV_KEYS
|
|
713
|
+
// strips PI_SUBAGENT_PARENT_PATH on the rpc side, so the
|
|
714
|
+
// in-process channel must not hand it back.
|
|
715
|
+
...(input.stripLegionIdentity ? {} : { parentPath: this.childPath(name) }),
|
|
716
|
+
...(agent.acceptance?.role ? { acceptanceRole: agent.acceptance.role } : {}),
|
|
717
|
+
allowNested: input.agent.allowNestedSubagents === true && !input.agent.utility,
|
|
718
|
+
utility: Boolean(input.utilityDispatch && input.agent.utility),
|
|
719
|
+
spawnMode: "inprocess" as const,
|
|
720
|
+
},
|
|
721
|
+
}
|
|
722
|
+
: {}),
|
|
619
723
|
};
|
|
620
724
|
try {
|
|
621
725
|
result = await this.supervisor.spawnChild(spawnInput);
|
|
@@ -1128,7 +1232,12 @@ export class Orchestrator {
|
|
|
1128
1232
|
if (opts?.cause === "session-shutdown" && this.supervisor.isAlive(name)) {
|
|
1129
1233
|
child.execution = attributeParentSessionShutdown(child.execution ?? deriveOutcome(parsed), parsed);
|
|
1130
1234
|
}
|
|
1131
|
-
if (this.supervisor.isAlive(name))
|
|
1235
|
+
if (this.supervisor.isAlive(name)) {
|
|
1236
|
+
await this.supervisor.retire(
|
|
1237
|
+
name,
|
|
1238
|
+
opts?.graceful === false ? { graceful: false } : {},
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1132
1241
|
if (child.nodeId && this.legion) {
|
|
1133
1242
|
if (child.execution && child.execution.status !== "unknown" && child.execution.status !== "running") {
|
|
1134
1243
|
this.recordSettled(child, child.execution);
|
package/src/shared/output-cap.ts
CHANGED
|
@@ -11,12 +11,12 @@
|
|
|
11
11
|
export const DEFAULT_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
|
|
12
12
|
export const MAX_OUTPUT_BYTES_ENV = "PI_LEGION_MAX_OUTPUT_BYTES";
|
|
13
13
|
|
|
14
|
-
/** Resolve the per-stream cap from env; garbage /
|
|
14
|
+
/** Resolve the per-stream cap from env; garbage / floored < 1 → default (#174). */
|
|
15
15
|
export function resolveMaxOutputBytes(env: NodeJS.ProcessEnv = process.env): number {
|
|
16
16
|
const raw = env[MAX_OUTPUT_BYTES_ENV]?.trim();
|
|
17
17
|
if (!raw) return DEFAULT_MAX_OUTPUT_BYTES;
|
|
18
18
|
const n = Number(raw);
|
|
19
|
-
return Number.isFinite(n) && n
|
|
19
|
+
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : DEFAULT_MAX_OUTPUT_BYTES;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export interface CappedOutput {
|
package/src/shared/session.ts
CHANGED
|
@@ -327,7 +327,8 @@ const SESSION_CACHE_MAX_ENTRIES = 64;
|
|
|
327
327
|
export function resolveSessionMaxLineBytes(env: NodeJS.ProcessEnv = process.env): number {
|
|
328
328
|
const raw = env[SESSION_MAX_LINE_BYTES_ENV]?.trim();
|
|
329
329
|
const n = raw ? Number(raw) : NaN;
|
|
330
|
-
|
|
330
|
+
// #174: require floored value >= 1 — (0,1) must not become cap=0.
|
|
331
|
+
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : DEFAULT_SESSION_MAX_LINE_BYTES;
|
|
331
332
|
}
|
|
332
333
|
|
|
333
334
|
interface SessionParseState {
|
package/src/shared/types.ts
CHANGED
|
@@ -46,6 +46,11 @@ export const AGENT_KINDS = [
|
|
|
46
46
|
|
|
47
47
|
export type AgentKind = (typeof AGENT_KINDS)[number];
|
|
48
48
|
|
|
49
|
+
/** How Legion launches a child session: omit → `inprocess` (with automatic rpc fallback when the agent needs a worktree or `kind !== "pi"`). */
|
|
50
|
+
export const SPAWN_MODES = ["rpc", "inprocess"] as const;
|
|
51
|
+
export type SpawnMode = (typeof SPAWN_MODES)[number];
|
|
52
|
+
|
|
53
|
+
|
|
49
54
|
/**
|
|
50
55
|
* How the model a child starts with was chosen.
|
|
51
56
|
*
|
|
@@ -149,6 +154,15 @@ export interface AgentConfig {
|
|
|
149
154
|
kind: AgentKind;
|
|
150
155
|
worktree?: boolean;
|
|
151
156
|
steer?: boolean;
|
|
157
|
+
/**
|
|
158
|
+
* Launch backend for this role. Omit → `inprocess` (createAgentSession in
|
|
159
|
+
* the parent Node process), with automatic rpc fallback when the resolved
|
|
160
|
+
* spawn needs a worktree (`worktree: true`, the writer/centurion default)
|
|
161
|
+
* or `kind !== "pi"`. An explicit `inprocess` + `worktree: true` is still
|
|
162
|
+
* refused with `INVALID_PARAMS` — the fallback only applies to the implicit
|
|
163
|
+
* default, never to an explicit request.
|
|
164
|
+
*/
|
|
165
|
+
spawnMode?: SpawnMode;
|
|
152
166
|
onBlocked?: OnBlockedPolicy;
|
|
153
167
|
|
|
154
168
|
// ── provenance ──
|
|
@@ -252,6 +266,12 @@ export interface Execution {
|
|
|
252
266
|
*/
|
|
253
267
|
export interface RetireOptions {
|
|
254
268
|
cause?: "session-shutdown";
|
|
269
|
+
/**
|
|
270
|
+
* When false, skip graceful stdin-close and escalate via retire hardening
|
|
271
|
+
* (SIGTERM → SIGKILL + descendant sweep). Default / omitted = graceful.
|
|
272
|
+
* Used by `subagent({ action: "stop", force: true })` (#179).
|
|
273
|
+
*/
|
|
274
|
+
graceful?: boolean;
|
|
255
275
|
}
|
|
256
276
|
|
|
257
277
|
export interface Usage {
|
|
@@ -548,6 +568,19 @@ export type TeamMember =
|
|
|
548
568
|
export interface TeamConfig {
|
|
549
569
|
description?: string;
|
|
550
570
|
members: TeamMember[];
|
|
571
|
+
/**
|
|
572
|
+
* Same forms as `subagents.parentContext` (inline / `@path` / array).
|
|
573
|
+
* Replaces the global parent context for this team; `false` disables
|
|
574
|
+
* parent injection without falling through to global/convention.
|
|
575
|
+
* Omitted → fall back to global `subagents.parentContext` (or convention).
|
|
576
|
+
*/
|
|
577
|
+
parentContext?: string | string[] | false;
|
|
578
|
+
/**
|
|
579
|
+
* Same forms as `subagents.childContext`. Replaces the global child
|
|
580
|
+
* context for this team; `false` disables child injection (no fallthrough).
|
|
581
|
+
* Omitted → fall back to global `subagents.childContext` (or convention).
|
|
582
|
+
*/
|
|
583
|
+
childContext?: string | string[] | false;
|
|
551
584
|
}
|
|
552
585
|
|
|
553
586
|
/**
|
|
@@ -566,6 +599,37 @@ export interface ScoutGuardSettings {
|
|
|
566
599
|
cooldownMs?: number;
|
|
567
600
|
}
|
|
568
601
|
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Concurrency / depth / wait limits for the in-process supervisor (#197).
|
|
605
|
+
* Keys are independent of RPC / legion.* budgets — never fall back to those.
|
|
606
|
+
*
|
|
607
|
+
* Defaults mirror `legion.*` when the whole `inprocess` block is absent:
|
|
608
|
+
* `maxDepth: 4` / `maxChildrenPerNode: 8` / `maxActive: 30`. With an explicit
|
|
609
|
+
* block that omits `maxActive`, `maxActive` is 8. `maxSpawnsPerSession` and
|
|
610
|
+
* `waitTimeoutMs` have no default (unlimited).
|
|
611
|
+
*/
|
|
612
|
+
export interface InProcessLimits {
|
|
613
|
+
maxSpawnsPerSession?: number;
|
|
614
|
+
maxDepth?: number;
|
|
615
|
+
maxActive?: number;
|
|
616
|
+
maxChildrenPerNode?: number;
|
|
617
|
+
waitTimeoutMs?: number;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* RPC-child spawn knobs (#193). Opt-in only — default stays no `--approve`.
|
|
622
|
+
* v1 does not emit `--no-approve`.
|
|
623
|
+
*/
|
|
624
|
+
export interface RpcSettings {
|
|
625
|
+
/**
|
|
626
|
+
* When true, child `pi --mode rpc` argv includes `--approve` so project
|
|
627
|
+
* `.pi/settings.json` / project resources / extension discovery can load
|
|
628
|
+
* without a prior `/trust` decision. Default false (omit the flag).
|
|
629
|
+
*/
|
|
630
|
+
approve?: boolean;
|
|
631
|
+
}
|
|
632
|
+
|
|
569
633
|
export interface SubagentsSettings {
|
|
570
634
|
/**
|
|
571
635
|
* Master switch (default true). `false` stops new launches, system-prompt
|
|
@@ -629,6 +693,16 @@ export interface SubagentsSettings {
|
|
|
629
693
|
joinFlushMs?: number;
|
|
630
694
|
/** Main-session recon reminder. Absent fields use scout-guard defaults. */
|
|
631
695
|
scoutGuard?: ScoutGuardSettings;
|
|
696
|
+
/**
|
|
697
|
+
* Limits for `spawnMode: "inprocess"` children only (#197).
|
|
698
|
+
* RPC path never reads these keys.
|
|
699
|
+
*/
|
|
700
|
+
inprocess?: InProcessLimits;
|
|
701
|
+
/**
|
|
702
|
+
* RPC-child spawn options (#193). Default omits `--approve`; set
|
|
703
|
+
* `rpc.approve: true` to append it. Not an inherit-parent-trust channel.
|
|
704
|
+
*/
|
|
705
|
+
rpc?: RpcSettings;
|
|
632
706
|
}
|
|
633
707
|
|
|
634
708
|
/**
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process child identity channel (#197 / spawn-default-inprocess-v3 D5).
|
|
3
|
+
*
|
|
4
|
+
* An in-process child session shares the host's `process.env`: it sees the
|
|
5
|
+
* *parent's* values, not an empty environment. So env cannot carry lineage
|
|
6
|
+
* identity for it — `PI_SUBAGENT_CHILD` is empty at the root and the child
|
|
7
|
+
* would be mistaken for another root. Identity therefore travels through an
|
|
8
|
+
* AsyncLocalStorage scope instead.
|
|
9
|
+
*
|
|
10
|
+
* Why the store lives on `globalThis` under `Symbol.for(...)`:
|
|
11
|
+
* `jiti` may re-evaluate this module with `moduleCache: false` (the extension
|
|
12
|
+
* host does exactly that for a child's `--extension` load). A module-level
|
|
13
|
+
* `const store = new AsyncLocalStorage()` would be a *different* instance on
|
|
14
|
+
* every re-evaluation, so a value written by one module instance would be
|
|
15
|
+
* invisible to the other — the store would look empty and identity would be
|
|
16
|
+
* silently lost. `Symbol.for` is registry-backed and `globalThis` survives
|
|
17
|
+
* module re-evaluation (measured: `childMod.store === shared` after jiti
|
|
18
|
+
* reload). Do not replace this with a module-level constant.
|
|
19
|
+
*
|
|
20
|
+
* Security property: AsyncLocalStorage does NOT cross a `spawn()` boundary, so
|
|
21
|
+
* a real subprocess (`bash npm test`, a nested `pi` CLI, …) can never inherit
|
|
22
|
+
* this identity. The env channel lacked exactly that isolation (see the
|
|
23
|
+
* healthy-ghost-nodes incident). Never re-introduce an env fallback here.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
27
|
+
import type { NestedPathEntry, SpawnMode } from "../shared/types.ts";
|
|
28
|
+
|
|
29
|
+
/** Lineage identity of one in-process child session. Never routed via process.env. */
|
|
30
|
+
export interface ChildLaunchContext {
|
|
31
|
+
/** Legion node id of this child session (undefined for a root-level in-process child). */
|
|
32
|
+
nodeId?: string;
|
|
33
|
+
/** Parent legion node id. */
|
|
34
|
+
parentNodeId?: string;
|
|
35
|
+
/** Absolute path to legion.db. */
|
|
36
|
+
dbPath?: string;
|
|
37
|
+
/**
|
|
38
|
+
* The caller's nested path segments, so the child's orchestrator can continue it.
|
|
39
|
+
* Same shape as the RPC lineage path (`NestedPathEntry`), not bare strings.
|
|
40
|
+
*/
|
|
41
|
+
parentPath?: NestedPathEntry[];
|
|
42
|
+
/** The role's `acceptance.role` (read-only / writer / …). */
|
|
43
|
+
acceptanceRole?: string;
|
|
44
|
+
/** Whether this child may spawn children of its own (maps to allowNestedSubagents). */
|
|
45
|
+
allowNested: boolean;
|
|
46
|
+
/** Utility hard leaf. */
|
|
47
|
+
utility: boolean;
|
|
48
|
+
/** Spawn backend of this child, so the child's doctor/diagnostics can self-report. */
|
|
49
|
+
spawnMode: SpawnMode;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const STORE_KEY = Symbol.for("pi-legion.child-context.als");
|
|
53
|
+
|
|
54
|
+
/** Symbol-indexed view of globalThis so the store survives module re-evaluation. */
|
|
55
|
+
type StoreHolder = {
|
|
56
|
+
[key: symbol]: AsyncLocalStorage<ChildLaunchContext> | undefined;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// SAFETY: `globalThis` is an object, so a `Symbol.for`-keyed property on it is
|
|
60
|
+
// readable; the only invariant TypeScript cannot check is that whoever wrote
|
|
61
|
+
// that slot first stored an AsyncLocalStorage under this exact symbol key. Both
|
|
62
|
+
// sides of that contract live in this file, so the slot type is authoritative.
|
|
63
|
+
const store = ((globalThis as unknown as StoreHolder)[STORE_KEY] ??=
|
|
64
|
+
new AsyncLocalStorage<ChildLaunchContext>());
|
|
65
|
+
|
|
66
|
+
/** Run `fn` under `ctx`; everything awaited inside (tool execution included) inherits it. */
|
|
67
|
+
export function runInChildContext<T>(
|
|
68
|
+
ctx: ChildLaunchContext,
|
|
69
|
+
fn: () => Promise<T>,
|
|
70
|
+
): Promise<T> {
|
|
71
|
+
return store.run(ctx, fn);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Identity of the current child session; undefined in the root process/session. */
|
|
75
|
+
export function currentChildContext(): ChildLaunchContext | undefined {
|
|
76
|
+
return store.getStore();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Run `fn` outside any child identity (REV2-7).
|
|
81
|
+
*
|
|
82
|
+
* Parent-side callbacks are reached synchronously from inside a child's await
|
|
83
|
+
* chain — an `onEvent` listener is the case in point. Letting such a callback
|
|
84
|
+
* observe the child's identity makes host code believe it *is* the child (and
|
|
85
|
+
* inherit the child's spawn permissions). Anything leaving the child's own
|
|
86
|
+
* execution path must step out of the scope first.
|
|
87
|
+
*/
|
|
88
|
+
export function runOutsideChildContext<T>(fn: () => T): T {
|
|
89
|
+
return store.exit(fn);
|
|
90
|
+
}
|
|
@@ -25,16 +25,35 @@ export const CHILD_RESERVE_ENV = "PI_LEGION_CHILD_RESERVE_MB";
|
|
|
25
25
|
/** Measured: an idle child is ~100 MB PSS, a long scout session peaks ~160 MB RSS. */
|
|
26
26
|
export const DEFAULT_CHILD_MAX_OLD_SPACE_MB = 1024;
|
|
27
27
|
export const DEFAULT_CHILD_SEMI_SPACE_MB = 1;
|
|
28
|
+
/**
|
|
29
|
+
* Empirically: old-space below ~8 MB fails to start on Node v20/v24 (rc 133/134/139).
|
|
30
|
+
* Clamp positive values up to this floor (#175); `0` remains the disable sentinel.
|
|
31
|
+
*/
|
|
32
|
+
export const MIN_CHILD_MAX_OLD_SPACE_MB = 16;
|
|
28
33
|
/** Floor of free memory that must remain after reserving the new child. */
|
|
29
34
|
export const DEFAULT_MIN_FREE_MEM_MB = 1024;
|
|
30
35
|
/** Per-child reservation for spawns that are still starting up. */
|
|
31
36
|
export const DEFAULT_CHILD_RESERVE_MB = 160;
|
|
32
37
|
|
|
33
|
-
function envMb(
|
|
38
|
+
function envMb(
|
|
39
|
+
env: NodeJS.ProcessEnv,
|
|
40
|
+
key: string,
|
|
41
|
+
fallback: number,
|
|
42
|
+
minPositive?: number,
|
|
43
|
+
): number {
|
|
34
44
|
const raw = env[key]?.trim();
|
|
35
45
|
if (!raw) return fallback;
|
|
36
46
|
const n = Number(raw);
|
|
37
|
-
|
|
47
|
+
if (!Number.isFinite(n) || n < 0) return fallback;
|
|
48
|
+
if (n === 0) return 0; // 0 = disabled
|
|
49
|
+
const floored = Math.floor(n);
|
|
50
|
+
if (minPositive !== undefined && floored < minPositive) {
|
|
51
|
+
console.error(
|
|
52
|
+
`[pi-legion] ${key}=${raw} is below the usable minimum ${minPositive} MB; using ${minPositive}.`,
|
|
53
|
+
);
|
|
54
|
+
return minPositive;
|
|
55
|
+
}
|
|
56
|
+
return floored;
|
|
38
57
|
}
|
|
39
58
|
|
|
40
59
|
/** Child NODE_OPTIONS: host options + memory flags (each disabled by 0). */
|
|
@@ -42,7 +61,12 @@ export function childNodeOptions(env: NodeJS.ProcessEnv = process.env): string |
|
|
|
42
61
|
const host = env.NODE_OPTIONS?.trim() ?? "";
|
|
43
62
|
const flags: string[] = [];
|
|
44
63
|
const semi = envMb(env, CHILD_SEMI_SPACE_ENV, DEFAULT_CHILD_SEMI_SPACE_MB);
|
|
45
|
-
const old = envMb(
|
|
64
|
+
const old = envMb(
|
|
65
|
+
env,
|
|
66
|
+
CHILD_MAX_OLD_SPACE_ENV,
|
|
67
|
+
DEFAULT_CHILD_MAX_OLD_SPACE_MB,
|
|
68
|
+
MIN_CHILD_MAX_OLD_SPACE_MB,
|
|
69
|
+
);
|
|
46
70
|
if (semi > 0 && !/--max-semi-space-size/.test(host)) flags.push(`--max-semi-space-size=${semi}`);
|
|
47
71
|
if (old > 0 && !/--max-old-space-size/.test(host)) flags.push(`--max-old-space-size=${old}`);
|
|
48
72
|
const merged = [host, ...flags].filter(Boolean).join(" ");
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-local nesting depth for in-process children (#197).
|
|
3
|
+
* Independent of RPC / PI_SUBAGENT_* depth env.
|
|
4
|
+
*
|
|
5
|
+
* The store lives on `globalThis` under `Symbol.for(...)` rather than in a
|
|
6
|
+
* module-level `const store = new AsyncLocalStorage()`: jiti may re-evaluate
|
|
7
|
+
* this module with `moduleCache: false`, and each re-evaluation would create a
|
|
8
|
+
* fresh, empty store — the depth would silently reset to 0 in a re-loaded
|
|
9
|
+
* module instance and the nesting limit would be bypassed. `globalThis` +
|
|
10
|
+
* `Symbol.for` keeps one instance across module re-evaluation (measured:
|
|
11
|
+
* `childMod.store === shared` after a jiti reload).
|
|
12
|
+
*
|
|
13
|
+
* Depth is also *not* inherited by real subprocesses: AsyncLocalStorage does
|
|
14
|
+
* not cross `spawn()`. Only in-process children (same JS process) accumulate.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
18
|
+
|
|
19
|
+
const STORE_KEY = Symbol.for("pi-legion.inprocess-depth.als");
|
|
20
|
+
|
|
21
|
+
/** Symbol-indexed view of globalThis so the store survives module re-evaluation. */
|
|
22
|
+
type StoreHolder = {
|
|
23
|
+
[key: symbol]: AsyncLocalStorage<number> | undefined;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// SAFETY: `globalThis` is an object, so a `Symbol.for`-keyed property on it is
|
|
27
|
+
// readable; the invariant TypeScript cannot check is that the slot holds a
|
|
28
|
+
// number-depth AsyncLocalStorage. Both writer and reader of that slot are in
|
|
29
|
+
// this file, so the declared slot type is authoritative.
|
|
30
|
+
const store = ((globalThis as unknown as StoreHolder)[STORE_KEY] ??=
|
|
31
|
+
new AsyncLocalStorage<number>());
|
|
32
|
+
|
|
33
|
+
/** Current in-process nesting depth (0 at the host session). */
|
|
34
|
+
export function inProcessDepth(): number {
|
|
35
|
+
return store.getStore() ?? 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Run `fn` at depth = current + 1. */
|
|
39
|
+
export function runAtInProcessChildDepth<T>(fn: () => T): T {
|
|
40
|
+
return store.run(inProcessDepth() + 1, fn);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Run async `fn` at depth = current + 1. */
|
|
44
|
+
export async function runAtInProcessChildDepthAsync<T>(
|
|
45
|
+
fn: () => Promise<T>,
|
|
46
|
+
): Promise<T> {
|
|
47
|
+
return store.run(inProcessDepth() + 1, fn);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Run async `fn` at an absolute depth (REV2-1).
|
|
52
|
+
*
|
|
53
|
+
* `runAtInProcessChildDepthAsync` computes `current + 1`, which is right only
|
|
54
|
+
* while the caller is itself nested. The Orchestrator drives every turn after
|
|
55
|
+
* the first from the **root** chain (`orchestrator.ts` `prompt`/`followUp`), so
|
|
56
|
+
* there `inProcessDepth()` is 0 and `current + 1` would always be 1 — the
|
|
57
|
+
* recorded nesting would collapse and `maxDepth` could never fire, allowing
|
|
58
|
+
* unbounded recursion across generations. Dispatching with the child's own
|
|
59
|
+
* recorded depth (its `spawnDepth`) restores the real nesting level.
|
|
60
|
+
*/
|
|
61
|
+
export async function runAtAbsoluteDepth<T>(
|
|
62
|
+
depth: number,
|
|
63
|
+
fn: () => Promise<T>,
|
|
64
|
+
): Promise<T> {
|
|
65
|
+
return store.run(depth, fn);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @internal */
|
|
69
|
+
export function resetInProcessDepthForTests(): void {
|
|
70
|
+
// AsyncLocalStorage has no global clear; tests should avoid leaking across awaits.
|
|
71
|
+
}
|