pi-legion 3.0.1 → 3.1.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 +186 -2
- package/CONTRIBUTING.md +38 -25
- package/README.md +27 -19
- 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/spawn-mode-visibility-v1.md +206 -0
- package/docs/subagents-teams.md +8 -4
- package/docs/vexillatio-contract.md +2 -1
- package/index.ts +227 -68
- package/package.json +15 -22
- 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-commands.ts +2 -0
- package/src/extension/legion-doctor.ts +125 -38
- package/src/extension/legion-tools.ts +7 -1
- package/src/extension/notify.ts +6 -6
- package/src/extension/playbook.ts +5 -5
- package/src/extension/recycle.ts +2 -2
- package/src/extension/runtime.ts +249 -11
- package/src/extension/spawn-hints.ts +25 -4
- package/src/extension/spawn-render.ts +183 -0
- 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/src/tui/live-overlay.ts +14 -0
- package/src/tui/status.ts +12 -3
- package/src/tui/tree-view.ts +8 -0
- package/src/tui/viz/railway.ts +28 -25
package/index.ts
CHANGED
|
@@ -83,6 +83,7 @@ import {
|
|
|
83
83
|
SUBAGENT_NOTIFY_TYPE,
|
|
84
84
|
} from "./src/extension/notify.ts";
|
|
85
85
|
import { renderSubagentNotice } from "./src/extension/notice-renderer.ts";
|
|
86
|
+
import { renderSpawnResult } from "./src/extension/spawn-render.ts";
|
|
86
87
|
import { PARENT_PLAYBOOK, TOOL_DESCRIPTION } from "./src/extension/playbook.ts";
|
|
87
88
|
import { configureSessionErrors, recordSessionError } from "./src/extension/session-errors.ts";
|
|
88
89
|
import { getAgentDir } from "./src/agents/paths.ts";
|
|
@@ -94,6 +95,7 @@ import {
|
|
|
94
95
|
type OrchestratorLegionContext,
|
|
95
96
|
} from "./src/runs/orchestrator.ts";
|
|
96
97
|
import { RunStore, pickChildByName } from "./src/runs/store.ts";
|
|
98
|
+
import { RUN_OWNER_FILE, ownerState } from "./src/runs/owner.ts";
|
|
97
99
|
import { resolveLaunchWorktree } from "./src/runs/worktree.ts";
|
|
98
100
|
import {
|
|
99
101
|
type AgentConfig,
|
|
@@ -103,6 +105,7 @@ import {
|
|
|
103
105
|
ErrorCodes,
|
|
104
106
|
type ModelOrigin,
|
|
105
107
|
type RetireOptions,
|
|
108
|
+
type SpawnMode,
|
|
106
109
|
SubagentError,
|
|
107
110
|
type SubagentsSettings,
|
|
108
111
|
} from "./src/shared/types.ts";
|
|
@@ -122,7 +125,7 @@ const TaskItem = Type.Object({
|
|
|
122
125
|
worktree: Type.Optional(
|
|
123
126
|
Type.Boolean({
|
|
124
127
|
description:
|
|
125
|
-
"Parent decides isolation for this child. true = own git branch, child opens an MR. false = write the current checkout.",
|
|
128
|
+
"Parent decides isolation for this child. true = own git branch, child opens an MR. false = write the current checkout. Reserved for `centurion` (#214): any other role asking for true is downgraded to false with a one-time warning.",
|
|
126
129
|
}),
|
|
127
130
|
),
|
|
128
131
|
verify: Type.Optional(
|
|
@@ -174,7 +177,7 @@ const SubagentParams = Type.Object({
|
|
|
174
177
|
worktree: Type.Optional(
|
|
175
178
|
Type.Boolean({
|
|
176
179
|
description:
|
|
177
|
-
"Parent decides isolation. true = isolated git branch, child opens an MR. false = edit the current checkout.
|
|
180
|
+
"Parent decides isolation. true = isolated git branch, child opens an MR. false = edit the current checkout. Reserved for `centurion` (#214): only a centurion gets a subtree worktree, every other role is downgraded to false with a one-time warning — dispatch a centurion when you need an isolated delivery unit.",
|
|
178
181
|
}),
|
|
179
182
|
),
|
|
180
183
|
verify: Type.Optional(
|
|
@@ -293,6 +296,19 @@ function runStoreFor(cwd: string, dbPathOverride?: string): RunStore {
|
|
|
293
296
|
return new RunStore({ rootDir: path.join(cwd, ".pi-subagents") });
|
|
294
297
|
}
|
|
295
298
|
|
|
299
|
+
/**
|
|
300
|
+
* Backend THIS process was started on (#209), for `run.json`'s `herdr.supervisor`.
|
|
301
|
+
*
|
|
302
|
+
* A host/root session and an RPC child are both ordinary `pi` processes, so the
|
|
303
|
+
* historic `rpc` marker is the truth for them. An in-process child shares this
|
|
304
|
+
* process, and its backend travels on the ALS child context — the one case where
|
|
305
|
+
* the truthful marker is not `rpc`. Reading it from there (instead of writing a
|
|
306
|
+
* literal) is what stops the record from claiming RPC for an inprocess child.
|
|
307
|
+
*/
|
|
308
|
+
function currentProcessSpawnMode(): SpawnMode {
|
|
309
|
+
return currentChildContext()?.spawnMode ?? "rpc";
|
|
310
|
+
}
|
|
311
|
+
|
|
296
312
|
function notice(pi: ExtensionAPI, text: string): void {
|
|
297
313
|
pi.sendMessage({ customType: SUBAGENT_NOTIFY_TYPE, content: text, display: true }, { triggerTurn: true, deliverAs: "followUp" });
|
|
298
314
|
}
|
|
@@ -399,6 +415,9 @@ function resolveLegionIdentity(input: {
|
|
|
399
415
|
const nodeRuns = input.store.listRuns().filter((run) => run.nodeId === nodeId || run.herdr.node_id === nodeId);
|
|
400
416
|
let nodeRun = nodeRuns.at(-1);
|
|
401
417
|
if (!nodeRun) {
|
|
418
|
+
// Contract D3: a run synthesized while attaching to an already-existing
|
|
419
|
+
// node has no launch arbitration behind it, so it keeps the historic
|
|
420
|
+
// marker instead of inventing a backend.
|
|
402
421
|
nodeRun = input.store.createRun({ task: node.taskSummary ?? `Resume ${node.role} node ${node.id}`, cwd: input.cwd, path: parseNestedPathEnv(process.env.PI_SUBAGENT_PARENT_PATH), maxDepth: effectiveMaxDepth(input.settings.legion?.maxDepth), nodeId, herdr: { supervisor: "rpc", node_id: nodeId } });
|
|
403
422
|
}
|
|
404
423
|
if (node.runId !== nodeRun.runId) updateLegionRunId(db, nodeId, nodeRun.runId);
|
|
@@ -442,7 +461,7 @@ async function resumeLegionTeam(input: { identity: LegionRuntimeIdentity; input:
|
|
|
442
461
|
}
|
|
443
462
|
|
|
444
463
|
function createRootLegion(input: { pi: ExtensionAPI; supervisor: LegionSupervisor; store: RunStore; cwd: string; task: string; settings: ReturnType<typeof loadSubagentSettings>; maxDepth: number; eventBus: LegionEventBus }): LegionRuntimeIdentity {
|
|
445
|
-
const rootRun = input.store.createRun({ task: input.task, cwd: input.cwd, maxDepth: input.maxDepth, nodeId: "root", herdr: { supervisor:
|
|
464
|
+
const rootRun = input.store.createRun({ task: input.task, cwd: input.cwd, maxDepth: input.maxDepth, nodeId: "root", herdr: { supervisor: currentProcessSpawnMode() } });
|
|
446
465
|
const dbPath = path.resolve(legionDbPath(input.store.runDir(rootRun.runId)));
|
|
447
466
|
const db = openLegionDb(dbPath);
|
|
448
467
|
const now = Date.now();
|
|
@@ -692,6 +711,9 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
692
711
|
label: "Vexillatio",
|
|
693
712
|
description: "Launch only read-only utility agents and return their collected output.",
|
|
694
713
|
parameters: VexillatioParams,
|
|
714
|
+
// D4: pi never renders `details` on its own, so without this the
|
|
715
|
+
// arbitrated spawnMode in `details.handles[].spawnMode` is invisible.
|
|
716
|
+
renderResult: (result, opts, theme) => renderSpawnResult(result, opts, theme),
|
|
695
717
|
async execute(_id, params, signal, _onUpdate, ctx: ExtensionContext): Promise<AgentToolResult<unknown>> {
|
|
696
718
|
if (signal?.aborted) {
|
|
697
719
|
return ok("interrupted (parent turn ended; children still running)");
|
|
@@ -722,9 +744,10 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
722
744
|
},
|
|
723
745
|
onUtilityChild: (retire) => pendingUtilityRetires.push(retire),
|
|
724
746
|
});
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
747
|
+
// `launchUtilityFamily` already folds the abort into its own return
|
|
748
|
+
// (head line + collected segments + `interrupted` details, #227 D4).
|
|
749
|
+
// Returning `result` unconditionally is what keeps that work visible;
|
|
750
|
+
// re-checking `signal.aborted` here would throw it away again.
|
|
728
751
|
return result;
|
|
729
752
|
},
|
|
730
753
|
});
|
|
@@ -760,10 +783,9 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
760
783
|
const childActivitySources = new Set<string>();
|
|
761
784
|
let statusNodeOpener: ((nodeId: string) => void) | undefined;
|
|
762
785
|
let lastConfirm: ((message: string) => Promise<boolean>) | undefined;
|
|
763
|
-
//
|
|
764
|
-
//
|
|
765
|
-
//
|
|
766
|
-
let parentTurnActive = false;
|
|
786
|
+
// Run-scoped completion deferral lives in the runtime outbox (D-A): a child
|
|
787
|
+
// finishing while this agent run is still active is held until
|
|
788
|
+
// `agent_settled` instead of being queued as an undrained followUp.
|
|
767
789
|
const rpcEventSource = supervisor as LegionSupervisor & RpcEventSource;
|
|
768
790
|
const runtime = createSessionRuntime({
|
|
769
791
|
sendMessage: (message, options) => pi.sendMessage(message, options),
|
|
@@ -957,6 +979,9 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
957
979
|
description: TOOL_DESCRIPTION,
|
|
958
980
|
parameters: SubagentParams,
|
|
959
981
|
|
|
982
|
+
// D4: the same renderer as `vexillatio` — it reads both detail shapes.
|
|
983
|
+
renderResult: (result, opts, theme) => renderSpawnResult(result, opts, theme),
|
|
984
|
+
|
|
960
985
|
// The parameter list is fixed by Pi's `ExtensionAPI.registerTool`
|
|
961
986
|
// contract — an options object is not ours to choose.
|
|
962
987
|
// pi-lens-ignore: long-parameter-list
|
|
@@ -999,7 +1024,6 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
999
1024
|
runtime.setJoinConfig({
|
|
1000
1025
|
mode: settings.joinMode ?? DEFAULT_JOIN_MODE,
|
|
1001
1026
|
flushMs: settings.joinFlushMs ?? DEFAULT_FLUSH_MS,
|
|
1002
|
-
parentBusy: () => parentTurnActive,
|
|
1003
1027
|
});
|
|
1004
1028
|
|
|
1005
1029
|
if (action === "list") return ok(renderAgentList(agents, team, teamWarnings));
|
|
@@ -1104,7 +1128,18 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
1104
1128
|
onUpdate,
|
|
1105
1129
|
});
|
|
1106
1130
|
if (signal?.aborted) {
|
|
1107
|
-
|
|
1131
|
+
// The frozen head line still leads, but the machine facts must
|
|
1132
|
+
// survive: `launchFamily` already returns its own interrupted result
|
|
1133
|
+
// (frozen head + `runId` + `spawns`) when it observes the abort; this
|
|
1134
|
+
// outer net only catches an abort seen after that check. Replacing the
|
|
1135
|
+
// whole result with a bare `ok()` used to drop `details.spawns` — the
|
|
1136
|
+
// only record of the arbitrated backend once `handles` is cleared (#209/D3).
|
|
1137
|
+
const details = result.details;
|
|
1138
|
+
const carried =
|
|
1139
|
+
details !== null && typeof details === "object" && !Array.isArray(details)
|
|
1140
|
+
? (details as Record<string, unknown>)
|
|
1141
|
+
: undefined;
|
|
1142
|
+
return ok("interrupted (parent turn ended; children still running)", carried);
|
|
1108
1143
|
}
|
|
1109
1144
|
return result;
|
|
1110
1145
|
},
|
|
@@ -1175,19 +1210,22 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
1175
1210
|
});
|
|
1176
1211
|
pi.on("session_info_changed", bindUi);
|
|
1177
1212
|
pi.on("turn_start", (event, ctx) => {
|
|
1178
|
-
parentTurnActive = true;
|
|
1179
1213
|
bindUi(event, ctx);
|
|
1180
1214
|
});
|
|
1181
1215
|
pi.on("turn_end", (event, ctx) => {
|
|
1182
|
-
parentTurnActive = false;
|
|
1183
1216
|
bindUi(event, ctx);
|
|
1184
1217
|
});
|
|
1185
1218
|
pi.on("agent_start", (event, ctx) => {
|
|
1219
|
+
runtime.notifyParentRunStart();
|
|
1186
1220
|
bindUi(event, ctx);
|
|
1187
1221
|
});
|
|
1188
1222
|
pi.on("agent_end", (event, ctx) => {
|
|
1189
1223
|
bindUi(event, ctx);
|
|
1190
1224
|
});
|
|
1225
|
+
pi.on("agent_settled", (event, ctx) => {
|
|
1226
|
+
runtime.notifyParentSettled();
|
|
1227
|
+
bindUi(event, ctx);
|
|
1228
|
+
});
|
|
1191
1229
|
pi.on("input", bindUi);
|
|
1192
1230
|
pi.on("tool_result", (_event, ctx) => {
|
|
1193
1231
|
bindUi(_event, ctx);
|
|
@@ -1317,6 +1355,7 @@ async function controlAction(input: {
|
|
|
1317
1355
|
return stopAction({
|
|
1318
1356
|
params,
|
|
1319
1357
|
runtime: input.runtime,
|
|
1358
|
+
store: input.store,
|
|
1320
1359
|
retire: async (name, opts) => {
|
|
1321
1360
|
const merged = opts ?? retireOpts;
|
|
1322
1361
|
const job = input.runtime.get(name);
|
|
@@ -1358,18 +1397,23 @@ async function controlAction(input: {
|
|
|
1358
1397
|
);
|
|
1359
1398
|
}
|
|
1360
1399
|
if (!params.name) {
|
|
1361
|
-
const
|
|
1400
|
+
const runs = store.listRuns();
|
|
1401
|
+
const live = listLiveChildrenForStatus(runs);
|
|
1362
1402
|
return ok(
|
|
1363
1403
|
renderStatusOverview(
|
|
1364
|
-
live.map((child) =>
|
|
1365
|
-
child
|
|
1366
|
-
|
|
1367
|
-
|
|
1404
|
+
live.map((child) => {
|
|
1405
|
+
const alive = input.supervisor.isAlive(child.name);
|
|
1406
|
+
const owner = ownerVerdictFor(store, runs, child.name, alive);
|
|
1407
|
+
return { child, alive, ...(owner ? { owner } : {}) };
|
|
1408
|
+
}),
|
|
1368
1409
|
),
|
|
1369
1410
|
);
|
|
1370
1411
|
}
|
|
1371
|
-
const
|
|
1412
|
+
const runs = store.listRuns();
|
|
1413
|
+
const found = findChild(runs, params.name);
|
|
1372
1414
|
if (!found) return unknownChild(params.name, store, cwd);
|
|
1415
|
+
const alive = input.supervisor.isAlive(params.name);
|
|
1416
|
+
const owner = ownerVerdictFor(store, runs, params.name, alive);
|
|
1373
1417
|
return ok(renderLiveChildStatus({
|
|
1374
1418
|
name: found.child.name,
|
|
1375
1419
|
state: found.child.state,
|
|
@@ -1378,7 +1422,9 @@ async function controlAction(input: {
|
|
|
1378
1422
|
sessionFile: found.child.sessionFile,
|
|
1379
1423
|
execution: found.child.execution,
|
|
1380
1424
|
acceptance: found.child.acceptance,
|
|
1381
|
-
|
|
1425
|
+
spawnMode: found.child.spawnMode,
|
|
1426
|
+
alive,
|
|
1427
|
+
...(owner ? { owner } : {}),
|
|
1382
1428
|
}));
|
|
1383
1429
|
}
|
|
1384
1430
|
|
|
@@ -1526,7 +1572,7 @@ async function collectChild(
|
|
|
1526
1572
|
}
|
|
1527
1573
|
}
|
|
1528
1574
|
|
|
1529
|
-
/** Retire the
|
|
1575
|
+
/** Retire the child while preserving its outcome and session for resume. */
|
|
1530
1576
|
async function retireChild(
|
|
1531
1577
|
ctx: ChildContext,
|
|
1532
1578
|
): Promise<AgentToolResult<unknown>> {
|
|
@@ -1543,7 +1589,7 @@ async function retireChild(
|
|
|
1543
1589
|
await persistChild(ctx.store, ctx.found.runId, ctx.orchestrator, ctx.name);
|
|
1544
1590
|
return ok(
|
|
1545
1591
|
`Retired ${ctx.name} (execution=${child.execution?.status ?? "unknown"}). ` +
|
|
1546
|
-
`
|
|
1592
|
+
`Child stopped; session kept for resume: ${child.sessionFile}`,
|
|
1547
1593
|
);
|
|
1548
1594
|
} catch (error) {
|
|
1549
1595
|
return fail(`retire failed: ${String(error)}`, ErrorCodes.RETIRE_FAILED);
|
|
@@ -1585,7 +1631,7 @@ async function reviveChild(
|
|
|
1585
1631
|
await orchestrator.steer(name, params.message);
|
|
1586
1632
|
followChild(ctx, { watch: true });
|
|
1587
1633
|
return ok(
|
|
1588
|
-
`${action === "resume" ? "Resumed" : "Continued"} ${name} (live
|
|
1634
|
+
`${action === "resume" ? "Resumed" : "Continued"} ${name} (live child steered).`,
|
|
1589
1635
|
);
|
|
1590
1636
|
}
|
|
1591
1637
|
|
|
@@ -1638,7 +1684,7 @@ async function launchFamily(input: {
|
|
|
1638
1684
|
content: [
|
|
1639
1685
|
{
|
|
1640
1686
|
type: "text",
|
|
1641
|
-
text: `playbook: ${plan.steps.map((s) => s.agent).join(", ")} →
|
|
1687
|
+
text: `playbook: ${plan.steps.map((s) => s.agent).join(", ")} → launch → watch`,
|
|
1642
1688
|
},
|
|
1643
1689
|
],
|
|
1644
1690
|
details: {},
|
|
@@ -1652,7 +1698,7 @@ async function launchFamily(input: {
|
|
|
1652
1698
|
task: plan.task,
|
|
1653
1699
|
cwd,
|
|
1654
1700
|
maxDepth,
|
|
1655
|
-
...(legion ? { path: parseNestedPathEnv(process.env.PI_SUBAGENT_PARENT_PATH), nodeId: legion.nodeId, herdr: { supervisor:
|
|
1701
|
+
...(legion ? { path: parseNestedPathEnv(process.env.PI_SUBAGENT_PARENT_PATH), nodeId: legion.nodeId, herdr: { supervisor: currentProcessSpawnMode(), node_id: legion.nodeId } } : { herdr: { supervisor: currentProcessSpawnMode() } }),
|
|
1656
1702
|
});
|
|
1657
1703
|
if (legion && legion.nodeId !== "root" && legion.parentRunId) {
|
|
1658
1704
|
const node = getNode(legion.db, legion.nodeId);
|
|
@@ -1714,16 +1760,26 @@ async function launchFamily(input: {
|
|
|
1714
1760
|
results: [],
|
|
1715
1761
|
handles: [],
|
|
1716
1762
|
timeoutByName: new Map(),
|
|
1763
|
+
// #209/D3: the arbitrated mode survives `handles` being cleared on an
|
|
1764
|
+
// abort (interrupted results run after that), so it is kept per name
|
|
1765
|
+
// here — same precedent as `timeoutByName`.
|
|
1766
|
+
spawnByName: new Map(),
|
|
1717
1767
|
...(nameGate.name ? { launchName: nameGate.name } : {}),
|
|
1718
1768
|
};
|
|
1719
1769
|
|
|
1770
|
+
/** `details.spawns` for the handles this request actually resolved. */
|
|
1771
|
+
const spawnDetails = () => {
|
|
1772
|
+
const spawns = [...session.spawnByName.values()];
|
|
1773
|
+
return spawns.length > 0 ? { spawns } : {};
|
|
1774
|
+
};
|
|
1775
|
+
|
|
1720
1776
|
const interruptedResult = async (): Promise<AgentToolResult<unknown> | undefined> => {
|
|
1721
1777
|
if (!input.signal?.aborted) return undefined;
|
|
1722
1778
|
for (const name of session.handles) await abandonLaunchedChild(session, name);
|
|
1723
1779
|
session.handles.length = 0;
|
|
1724
1780
|
return {
|
|
1725
1781
|
content: [{ type: "text", text: session.results.join("\n") || "interrupted (parent turn ended; children still running)" }],
|
|
1726
|
-
details: { runId: run.runId, handles: [], async: params.async ?? true },
|
|
1782
|
+
details: { runId: run.runId, handles: [], async: params.async ?? true, ...spawnDetails() },
|
|
1727
1783
|
};
|
|
1728
1784
|
};
|
|
1729
1785
|
|
|
@@ -1770,13 +1826,13 @@ async function launchFamily(input: {
|
|
|
1770
1826
|
|
|
1771
1827
|
if (isAsync && session.handles.length > 0) {
|
|
1772
1828
|
session.results.push(
|
|
1773
|
-
"↳ async: a completion message is queued when each
|
|
1829
|
+
"↳ async: a completion message is queued when each child finishes. It wakes this session when idle; otherwise it is delivered after the current turn. Use wait or collect for results. Do not tell children to message the parent.",
|
|
1774
1830
|
);
|
|
1775
1831
|
}
|
|
1776
1832
|
|
|
1777
1833
|
return {
|
|
1778
1834
|
content: [{ type: "text", text: session.results.join("\n") }],
|
|
1779
|
-
details: { runId: run.runId, handles: session.handles, async: isAsync },
|
|
1835
|
+
details: { runId: run.runId, handles: session.handles, async: isAsync, ...spawnDetails() },
|
|
1780
1836
|
};
|
|
1781
1837
|
}
|
|
1782
1838
|
|
|
@@ -1800,6 +1856,8 @@ interface LaunchSession {
|
|
|
1800
1856
|
results: string[];
|
|
1801
1857
|
handles: string[];
|
|
1802
1858
|
timeoutByName: Map<string, number>;
|
|
1859
|
+
/** Arbitrated backend per resolved handle; survives an abort clearing `handles`. */
|
|
1860
|
+
spawnByName: Map<string, { name: string; agent: string; spawnMode: SpawnMode }>;
|
|
1803
1861
|
/** Explicit child name for this single-step launch; omit to auto-allocate. */
|
|
1804
1862
|
launchName?: string;
|
|
1805
1863
|
}
|
|
@@ -1911,6 +1969,13 @@ async function launchStep(
|
|
|
1911
1969
|
await session.store.addChild(session.runId, handle.child);
|
|
1912
1970
|
}
|
|
1913
1971
|
session.handles.push(handle.name);
|
|
1972
|
+
// Recorded before the abort check below: an interrupted result runs after
|
|
1973
|
+
// `handles` is cleared and still has to name the arbitrated backend.
|
|
1974
|
+
session.spawnByName.set(handle.name, {
|
|
1975
|
+
name: handle.name,
|
|
1976
|
+
agent: effective.name,
|
|
1977
|
+
spawnMode: handle.spawnMode,
|
|
1978
|
+
});
|
|
1914
1979
|
if (effective.timeoutMs !== undefined) {
|
|
1915
1980
|
session.timeoutByName.set(handle.name, effective.timeoutMs);
|
|
1916
1981
|
}
|
|
@@ -1919,7 +1984,7 @@ async function launchStep(
|
|
|
1919
1984
|
return;
|
|
1920
1985
|
}
|
|
1921
1986
|
session.results.push(
|
|
1922
|
-
`▶ ${handle.name} (${effective.name}) supervisor
|
|
1987
|
+
`▶ ${handle.name} (${effective.name}) supervisor=${handle.spawnMode}`,
|
|
1923
1988
|
);
|
|
1924
1989
|
// An inherited model the target CLI cannot express is dropped on purpose
|
|
1925
1990
|
// (the parent's pi model means nothing to cursor). Say so, or the user
|
|
@@ -1935,7 +2000,7 @@ async function launchStep(
|
|
|
1935
2000
|
content: [
|
|
1936
2001
|
{
|
|
1937
2002
|
type: "text",
|
|
1938
|
-
text: `playbook: ${handle.name} started
|
|
2003
|
+
text: `playbook: ${handle.name} started (supervisor=${handle.spawnMode})`,
|
|
1939
2004
|
},
|
|
1940
2005
|
],
|
|
1941
2006
|
details: {},
|
|
@@ -2226,7 +2291,7 @@ async function launchUtilityFamily(input: {
|
|
|
2226
2291
|
cwd: input.cwd,
|
|
2227
2292
|
maxDepth: effectiveMaxDepth(catalog.settings.legion?.maxDepth),
|
|
2228
2293
|
path: parseNestedPathEnv(process.env.PI_SUBAGENT_PARENT_PATH),
|
|
2229
|
-
herdr: { supervisor:
|
|
2294
|
+
herdr: { supervisor: currentProcessSpawnMode() },
|
|
2230
2295
|
});
|
|
2231
2296
|
if (!run) return fail("vexillatio parent run is not available", ErrorCodes.NOT_FOUND);
|
|
2232
2297
|
const runId = run.runId;
|
|
@@ -2315,6 +2380,7 @@ async function launchUtilityFamily(input: {
|
|
|
2315
2380
|
name: handle.name,
|
|
2316
2381
|
agent: item.agent.name,
|
|
2317
2382
|
sessionFile: handle.sessionFile,
|
|
2383
|
+
spawnMode: handle.spawnMode,
|
|
2318
2384
|
orchestrator,
|
|
2319
2385
|
retire,
|
|
2320
2386
|
},
|
|
@@ -2330,6 +2396,7 @@ async function launchUtilityFamily(input: {
|
|
|
2330
2396
|
name: string;
|
|
2331
2397
|
agent: string;
|
|
2332
2398
|
sessionFile: string;
|
|
2399
|
+
spawnMode: SpawnMode;
|
|
2333
2400
|
orchestrator: Orchestrator;
|
|
2334
2401
|
retire: (opts?: RetireOptions) => Promise<void>;
|
|
2335
2402
|
}> = [];
|
|
@@ -2345,6 +2412,8 @@ async function launchUtilityFamily(input: {
|
|
|
2345
2412
|
agent: handle.agent,
|
|
2346
2413
|
sessionFile: handle.sessionFile,
|
|
2347
2414
|
timeoutMs,
|
|
2415
|
+
// Arbitrated at launch; never re-derived here (#209).
|
|
2416
|
+
spawnMode: handle.spawnMode,
|
|
2348
2417
|
orchestrator: handle.orchestrator,
|
|
2349
2418
|
store,
|
|
2350
2419
|
watch,
|
|
@@ -2386,13 +2455,19 @@ async function launchUtilityFamily(input: {
|
|
|
2386
2455
|
return `✗ ${handle.name}: collect failed: ${error instanceof Error ? error.message : String(error)}\nsessionFile: ${handle.sessionFile}`;
|
|
2387
2456
|
}
|
|
2388
2457
|
}));
|
|
2389
|
-
if (input.signal?.aborted) return
|
|
2458
|
+
if (input.signal?.aborted) return interruptedUtilityResult({
|
|
2459
|
+
runId,
|
|
2460
|
+
handles,
|
|
2461
|
+
stillRunning,
|
|
2462
|
+
results,
|
|
2463
|
+
collected,
|
|
2464
|
+
});
|
|
2390
2465
|
results.push(...collected);
|
|
2391
2466
|
return {
|
|
2392
2467
|
content: [{ type: "text", text: results.join("\n\n") || "No utility agents were launched." }],
|
|
2393
2468
|
details: {
|
|
2394
2469
|
runId,
|
|
2395
|
-
handles: handles.map(({ name, agent, sessionFile }) => ({ name, agent, sessionFile })),
|
|
2470
|
+
handles: handles.map(({ name, agent, sessionFile, spawnMode }) => ({ name, agent, sessionFile, spawnMode })),
|
|
2396
2471
|
async: false,
|
|
2397
2472
|
...(stillRunning.size > 0
|
|
2398
2473
|
? { stillRunning: handles.filter(({ name }) => stillRunning.has(name)).map(({ name }) => name) }
|
|
@@ -2401,6 +2476,46 @@ async function launchUtilityFamily(input: {
|
|
|
2401
2476
|
};
|
|
2402
2477
|
}
|
|
2403
2478
|
|
|
2479
|
+
/**
|
|
2480
|
+
* Build the interrupted vexillatio result from what the collect loop already produced.
|
|
2481
|
+
*
|
|
2482
|
+
* `#227 D4`: an abort must not throw away work that already finished. The frozen
|
|
2483
|
+
* head line stays first and verbatim (`test/unit/teams.test.ts` matches it with a
|
|
2484
|
+
* regex), the collected segments are appended below it, and `details` carries the
|
|
2485
|
+
* same machine facts a non-interrupted return publishes plus `interrupted: true`.
|
|
2486
|
+
*
|
|
2487
|
+
* With no handles (every launch failed) the text degrades to the head line plus
|
|
2488
|
+
* the failure lines only — no empty segment.
|
|
2489
|
+
*/
|
|
2490
|
+
function interruptedUtilityResult(input: {
|
|
2491
|
+
runId: string;
|
|
2492
|
+
handles: Array<{ name: string; agent: string; sessionFile: string; spawnMode?: SpawnMode }>;
|
|
2493
|
+
stillRunning: Set<string>;
|
|
2494
|
+
results: string[];
|
|
2495
|
+
collected: string[];
|
|
2496
|
+
}): AgentToolResult<unknown> {
|
|
2497
|
+
const head = "interrupted (parent turn ended; children still running)";
|
|
2498
|
+
const segments = [...input.results, ...input.collected].filter((segment) => segment.length > 0);
|
|
2499
|
+
const stillRunning = input.handles
|
|
2500
|
+
.filter(({ name }) => input.stillRunning.has(name))
|
|
2501
|
+
.map(({ name }) => name);
|
|
2502
|
+
// Routed through `ok()` so the widened `details` parameter has exactly one
|
|
2503
|
+
// real consumer instead of being a dead signature (#227 review F3).
|
|
2504
|
+
return ok(segments.length > 0 ? `${head}\n\n${segments.join("\n\n")}` : head, {
|
|
2505
|
+
runId: input.runId,
|
|
2506
|
+
handles: input.handles.map(({ name, agent, sessionFile, spawnMode }) => ({
|
|
2507
|
+
name,
|
|
2508
|
+
agent,
|
|
2509
|
+
sessionFile,
|
|
2510
|
+
// Optional on purpose: a legacy handle must render as absent, never `rpc`.
|
|
2511
|
+
...(spawnMode ? { spawnMode } : {}),
|
|
2512
|
+
})),
|
|
2513
|
+
async: false,
|
|
2514
|
+
interrupted: true,
|
|
2515
|
+
...(stillRunning.length > 0 ? { stillRunning } : {}),
|
|
2516
|
+
});
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2404
2519
|
/**
|
|
2405
2520
|
* Resolve an explicit `name=` on launch.
|
|
2406
2521
|
*
|
|
@@ -2546,12 +2661,17 @@ function followJob(
|
|
|
2546
2661
|
watch: boolean;
|
|
2547
2662
|
retire?: (opts?: RetireOptions) => Promise<void>;
|
|
2548
2663
|
treeIdentity?: { nodeId: string; parentId: string | null; depth: number };
|
|
2664
|
+
/** Arbitrated backend from `Handle.spawnMode`; omitted when unknown (#209). */
|
|
2665
|
+
spawnMode?: SpawnMode;
|
|
2549
2666
|
},
|
|
2550
2667
|
): void {
|
|
2551
2668
|
const child = input.orchestrator
|
|
2552
2669
|
.childrenSnapshot()
|
|
2553
2670
|
.find((entry) => entry.name === input.name);
|
|
2554
2671
|
const existing = runtime.get(input.name);
|
|
2672
|
+
// The caller's Handle owns the arbitration; the persisted ChildRecord records
|
|
2673
|
+
// the same decision. Neither is re-derived here — no handle, no value.
|
|
2674
|
+
const spawnMode = input.spawnMode ?? child?.spawnMode;
|
|
2555
2675
|
runtime.track({
|
|
2556
2676
|
name: input.name,
|
|
2557
2677
|
runId: input.runId,
|
|
@@ -2562,6 +2682,7 @@ function followJob(
|
|
|
2562
2682
|
...(child?.model ? { model: child.model } : {}),
|
|
2563
2683
|
...(child?.thinking !== undefined ? { thinking: child.thinking } : {}),
|
|
2564
2684
|
...(child?.worktreeBranch ? { worktreeBranch: child.worktreeBranch } : {}),
|
|
2685
|
+
...(spawnMode ? { spawnMode } : {}),
|
|
2565
2686
|
...(input.treeIdentity ? input.treeIdentity : child?.nodeId ? {
|
|
2566
2687
|
nodeId: child.nodeId,
|
|
2567
2688
|
parentId: child.nodeId.includes(".") ? child.nodeId.slice(0, child.nodeId.lastIndexOf(".")) : null,
|
|
@@ -2638,6 +2759,19 @@ function followChild(ctx: ChildContext, opts: { watch: boolean }): void {
|
|
|
2638
2759
|
});
|
|
2639
2760
|
}
|
|
2640
2761
|
|
|
2762
|
+
/**
|
|
2763
|
+
* The arbitrated backend recorded for one handled child of this request.
|
|
2764
|
+
*
|
|
2765
|
+
* Spreads to nothing when unknown, so the caller never has to invent a value.
|
|
2766
|
+
*/
|
|
2767
|
+
function spawnModeOf(
|
|
2768
|
+
session: LaunchSession,
|
|
2769
|
+
name: string,
|
|
2770
|
+
): { spawnMode?: SpawnMode } {
|
|
2771
|
+
const recorded = session.spawnByName.get(name);
|
|
2772
|
+
return recorded ? { spawnMode: recorded.spawnMode } : {};
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2641
2775
|
function followLaunched(
|
|
2642
2776
|
session: LaunchSession,
|
|
2643
2777
|
name: string,
|
|
@@ -2655,6 +2789,8 @@ function followLaunched(
|
|
|
2655
2789
|
orchestrator: session.orchestrator,
|
|
2656
2790
|
store: session.store,
|
|
2657
2791
|
watch: opts.watch,
|
|
2792
|
+
// Handle-derived, recorded before an abort can clear `handles` (#209).
|
|
2793
|
+
...spawnModeOf(session, name),
|
|
2658
2794
|
...(child?.nodeId ? {
|
|
2659
2795
|
treeIdentity: {
|
|
2660
2796
|
nodeId: child.nodeId,
|
|
@@ -2742,11 +2878,13 @@ export function renderStatusOverview(
|
|
|
2742
2878
|
children: Array<{
|
|
2743
2879
|
child: NonNullable<ReturnType<RunStore["findChild"]>>;
|
|
2744
2880
|
alive: boolean;
|
|
2881
|
+
/** Run-dir ownership verdict (#209-3); see {@link renderLiveChildStatus}. */
|
|
2882
|
+
owner?: "live" | "dead" | "unknown";
|
|
2745
2883
|
}>,
|
|
2746
2884
|
): string {
|
|
2747
2885
|
if (children.length === 0) return "No live children.";
|
|
2748
2886
|
const noun = children.length === 1 ? "child" : "children";
|
|
2749
|
-
const blocks = children.map(({ child, alive }) =>
|
|
2887
|
+
const blocks = children.map(({ child, alive, owner }) =>
|
|
2750
2888
|
renderLiveChildStatus({
|
|
2751
2889
|
name: child.name,
|
|
2752
2890
|
state: child.state,
|
|
@@ -2755,7 +2893,9 @@ export function renderStatusOverview(
|
|
|
2755
2893
|
sessionFile: child.sessionFile,
|
|
2756
2894
|
execution: child.execution,
|
|
2757
2895
|
acceptance: child.acceptance,
|
|
2896
|
+
spawnMode: child.spawnMode,
|
|
2758
2897
|
alive,
|
|
2898
|
+
...(owner ? { owner } : {}),
|
|
2759
2899
|
}),
|
|
2760
2900
|
);
|
|
2761
2901
|
return [`Status · ${children.length} live ${noun}`, "", blocks.join("\n\n")].join("\n");
|
|
@@ -2882,6 +3022,26 @@ export function renderWait(results: WaitResult[]): string {
|
|
|
2882
3022
|
return parts.join("\n");
|
|
2883
3023
|
}
|
|
2884
3024
|
|
|
3025
|
+
/**
|
|
3026
|
+
* Run-dir ownership verdict for a child THIS process does not track (#209-3).
|
|
3027
|
+
*
|
|
3028
|
+
* `supervisor.isAlive` is an in-memory registry of children spawned by this
|
|
3029
|
+
* process, so it is always false for a foreign run (another session, or a
|
|
3030
|
+
* restart). The run dir's `owner.json` pid is the durable liveness marker; it
|
|
3031
|
+
* only needs to be consulted when the in-memory answer is "not alive".
|
|
3032
|
+
*/
|
|
3033
|
+
function ownerVerdictFor(
|
|
3034
|
+
store: RunStore,
|
|
3035
|
+
runs: ReturnType<RunStore["listRuns"]>,
|
|
3036
|
+
name: string,
|
|
3037
|
+
alive: boolean,
|
|
3038
|
+
): "live" | "dead" | "unknown" | undefined {
|
|
3039
|
+
if (alive) return undefined;
|
|
3040
|
+
const picked = pickChildByName(runs, name);
|
|
3041
|
+
if (!picked) return "unknown";
|
|
3042
|
+
return ownerState(path.join(store.runDir(picked.run.runId), RUN_OWNER_FILE));
|
|
3043
|
+
}
|
|
3044
|
+
|
|
2885
3045
|
/**
|
|
2886
3046
|
* Retire a named child or every child tracked by this session.
|
|
2887
3047
|
* `force: true` → hard path (graceful:false / SIGTERM→SIGKILL); omit/false → graceful.
|
|
@@ -2889,6 +3049,11 @@ export function renderWait(results: WaitResult[]): string {
|
|
|
2889
3049
|
export async function stopAction(input: {
|
|
2890
3050
|
params: { name?: string; all?: boolean; force?: boolean };
|
|
2891
3051
|
runtime: SessionRuntime;
|
|
3052
|
+
/**
|
|
3053
|
+
* Run store used to resolve a named target this session does not track
|
|
3054
|
+
* (foreign/restarted runs). Omitted callers keep the runtime-only scope.
|
|
3055
|
+
*/
|
|
3056
|
+
store?: RunStore;
|
|
2892
3057
|
retire: (name: string, opts?: RetireOptions) => Promise<void>;
|
|
2893
3058
|
}): Promise<AgentToolResult<unknown>> {
|
|
2894
3059
|
const { params, runtime, retire } = input;
|
|
@@ -2906,11 +3071,21 @@ export async function stopAction(input: {
|
|
|
2906
3071
|
if (params.all) {
|
|
2907
3072
|
targets = active.map((job) => job.name);
|
|
2908
3073
|
} else {
|
|
2909
|
-
const
|
|
3074
|
+
// Local const so the earlier `name`/`all` guard narrows through the lookup.
|
|
3075
|
+
const name = params.name;
|
|
3076
|
+
const target = name ? jobs.find((job) => job.name === name) : undefined;
|
|
2910
3077
|
if (!target) {
|
|
2911
|
-
|
|
3078
|
+
// Not tracked by this session, but a foreign/restarted run may still
|
|
3079
|
+
// own a persisted record — the same records `status` can display. Fall
|
|
3080
|
+
// back to it so stop and retire agree on what is reachable.
|
|
3081
|
+
const found = input.store && name ? findChild(input.store.listRuns(), name) : null;
|
|
3082
|
+
if (!found) {
|
|
3083
|
+
return fail(`unknown child: ${params.name} (not tracked by this session).`, ErrorCodes.NOT_FOUND);
|
|
3084
|
+
}
|
|
3085
|
+
targets = found.child.state === "retired" ? [] : [found.child.name];
|
|
3086
|
+
} else {
|
|
3087
|
+
targets = target.state === "retired" ? [] : [target.name];
|
|
2912
3088
|
}
|
|
2913
|
-
targets = target.state === "retired" ? [] : [target.name];
|
|
2914
3089
|
}
|
|
2915
3090
|
if (targets.length === 0) return ok("No non-retired children to stop.");
|
|
2916
3091
|
|
|
@@ -3080,34 +3255,6 @@ function findChild(
|
|
|
3080
3255
|
return { runId: picked.run.runId, run: picked.run, child: picked.child };
|
|
3081
3256
|
}
|
|
3082
3257
|
|
|
3083
|
-
function renderChild(
|
|
3084
|
-
child: NonNullable<ReturnType<RunStore["findChild"]>>,
|
|
3085
|
-
action: string,
|
|
3086
|
-
): AgentToolResult<unknown> {
|
|
3087
|
-
const lines = [
|
|
3088
|
-
`${child.name} — state=${child.state} agent=${child.agent ?? "?"} kind=${child.kind ?? "?"}`,
|
|
3089
|
-
"supervisor=rpc",
|
|
3090
|
-
`session=${child.sessionFile}`,
|
|
3091
|
-
];
|
|
3092
|
-
if (child.execution) {
|
|
3093
|
-
lines.push(
|
|
3094
|
-
`execution: ${child.execution.status}${child.execution.reason ? ` (${child.execution.reason})` : ""}`,
|
|
3095
|
-
);
|
|
3096
|
-
if (child.execution.usage) {
|
|
3097
|
-
lines.push(
|
|
3098
|
-
`usage: in=${child.execution.usage.input} out=${child.execution.usage.output} cost=${child.execution.usage.cost.toFixed(4)}`,
|
|
3099
|
-
);
|
|
3100
|
-
}
|
|
3101
|
-
}
|
|
3102
|
-
if (child.acceptance)
|
|
3103
|
-
lines.push(
|
|
3104
|
-
`acceptance: ${child.acceptance.status} (${child.acceptance.level})`,
|
|
3105
|
-
);
|
|
3106
|
-
if (action === "collect" && !child.execution)
|
|
3107
|
-
lines.push("(not yet collected — call collect while the agent is alive)");
|
|
3108
|
-
return ok(lines.join("\n"));
|
|
3109
|
-
}
|
|
3110
|
-
|
|
3111
3258
|
function renderCollect(
|
|
3112
3259
|
name: string,
|
|
3113
3260
|
collected: {
|
|
@@ -3165,8 +3312,20 @@ function renderCollect(
|
|
|
3165
3312
|
return lines.join("\n");
|
|
3166
3313
|
}
|
|
3167
3314
|
|
|
3168
|
-
|
|
3169
|
-
|
|
3315
|
+
/**
|
|
3316
|
+
* Render a success/neutral result to the model.
|
|
3317
|
+
*
|
|
3318
|
+
* `details` defaults to `{}`; callers that already computed machine facts
|
|
3319
|
+
* (runId/handles for an interrupted collect) pass them through instead of
|
|
3320
|
+
* discarding them (#227 D4) — `interruptedUtilityResult` is that caller.
|
|
3321
|
+
* `isError` is deliberately never set — the rendering chain and every assertion
|
|
3322
|
+
* in this repo work without it.
|
|
3323
|
+
*/
|
|
3324
|
+
function ok(
|
|
3325
|
+
text: string,
|
|
3326
|
+
details: Record<string, unknown> = {},
|
|
3327
|
+
): AgentToolResult<unknown> {
|
|
3328
|
+
return { content: [{ type: "text", text }], details };
|
|
3170
3329
|
}
|
|
3171
3330
|
|
|
3172
3331
|
/**
|