pi-legion 3.0.0 → 3.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +163 -2
- package/CONTRIBUTING.md +38 -25
- package/README.md +23 -18
- package/agents/worker.md +6 -6
- package/docs/contracts/vexillatio-nonblocking-launch-v1.md +161 -0
- package/docs/design.md +39 -9
- package/docs/inprocess-supervisor.md +67 -5
- package/docs/legion-v2-contract.md +6 -3
- package/docs/subagents-teams.md +8 -4
- package/docs/vexillatio-contract.md +2 -1
- package/index.ts +149 -58
- package/package.json +4 -7
- package/src/agents/agents.ts +5 -5
- package/src/api.ts +6 -1
- package/src/extension/auto-doctor.ts +21 -3
- package/src/extension/join.ts +7 -18
- package/src/extension/legion-doctor.ts +125 -38
- package/src/extension/legion-tools.ts +7 -1
- package/src/extension/playbook.ts +1 -1
- package/src/extension/runtime.ts +206 -8
- package/src/extension/spawn-hints.ts +25 -4
- package/src/runs/orchestrator.ts +78 -8
- package/src/runs/worktree.ts +5 -4
- package/src/shared/session.ts +83 -7
- package/src/shared/types.ts +17 -2
- package/src/supervisor/inprocess-supervisor.ts +449 -16
- package/src/supervisor/routing-supervisor.ts +1 -1
- package/src/supervisor/spawn-mode.ts +25 -0
package/src/extension/runtime.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
formatCompletionNotice,
|
|
13
13
|
formatGroupedNotice,
|
|
14
14
|
collectFailureInput,
|
|
15
|
+
type CompletionNotice,
|
|
15
16
|
type SendMessageApi,
|
|
16
17
|
} from "./notify.ts";
|
|
17
18
|
import {
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
DEFAULT_JOIN_MODE,
|
|
20
21
|
JoinCoordinator,
|
|
21
22
|
type JoinConfig,
|
|
23
|
+
type JoinSchedulerHandle,
|
|
22
24
|
} from "./join.ts";
|
|
23
25
|
import {
|
|
24
26
|
createStatusBoard,
|
|
@@ -144,12 +146,48 @@ export interface SessionRuntimeDeps {
|
|
|
144
146
|
) => () => void;
|
|
145
147
|
/** Batching config; applied via setJoinConfig right after construction. */
|
|
146
148
|
joinConfig?: JoinConfig;
|
|
149
|
+
/** Injectable timer for the MAX_PENDING_AGE_MS overflow (tests fire it manually); default = unref'd setTimeout. */
|
|
150
|
+
scheduleNoticeOverflow?: (ms: number, fn: () => void) => JoinSchedulerHandle;
|
|
147
151
|
}
|
|
148
152
|
|
|
149
153
|
const DEFAULT_REFRESH_MS = 500;
|
|
154
|
+
/**
|
|
155
|
+
* Hard cap on how long a completion notice may stay hidden behind a live
|
|
156
|
+
* parent agent run (D-B). 80s = 2× the (1+3)×10s that the old join window
|
|
157
|
+
* could reach while a busy parent kept extending it. Past this the notice is
|
|
158
|
+
* force-sent even though the run is still going: reverting to the old
|
|
159
|
+
* "queued as a followUp" behaviour after 80s is better than hiding it forever.
|
|
160
|
+
*/
|
|
161
|
+
export const MAX_PENDING_AGE_MS = 80_000;
|
|
150
162
|
/** Fallback per-child timeout for wait(); index.ts passes the role value. */
|
|
151
163
|
const DEFAULT_WAIT_TIMEOUT_MS = DEFAULTS.turnTimeoutMs;
|
|
152
164
|
|
|
165
|
+
/** Default timer for the MAX_PENDING_AGE_MS overflow (unref'd, like join's). */
|
|
166
|
+
function defaultNoticeOverflow(
|
|
167
|
+
ms: number,
|
|
168
|
+
fn: () => void,
|
|
169
|
+
): JoinSchedulerHandle {
|
|
170
|
+
const id = setTimeout(fn, ms);
|
|
171
|
+
id.unref?.();
|
|
172
|
+
return { cancel: () => clearTimeout(id) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A completion notice held because the parent agent run is still active (D-A). */
|
|
176
|
+
interface QueuedNotice {
|
|
177
|
+
notice: CompletionNotice;
|
|
178
|
+
triggerTurn: boolean;
|
|
179
|
+
enqueuedAt: number;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface NoticeQueueStats {
|
|
183
|
+
/** Notices currently held. */
|
|
184
|
+
pending: number;
|
|
185
|
+
/** now() - enqueuedAt of the oldest held notice; 0 when empty. */
|
|
186
|
+
oldestAgeMs: number;
|
|
187
|
+
/** Lifetime count of MAX_PENDING_AGE_MS overflows that forced a send (D-B observability). */
|
|
188
|
+
forcedFlushes: number;
|
|
189
|
+
}
|
|
190
|
+
|
|
153
191
|
/** A wait() promise that must settle by the deadline. */
|
|
154
192
|
class WaitTimeoutError extends Error {}
|
|
155
193
|
const WAIT_INTERRUPTED = Symbol("wait interrupted");
|
|
@@ -181,8 +219,21 @@ export interface LiveChildStatusInput {
|
|
|
181
219
|
usage?: { input: number; output: number; cost: number } | null;
|
|
182
220
|
};
|
|
183
221
|
acceptance?: { status: string; level?: string };
|
|
222
|
+
/**
|
|
223
|
+
* Backend the child was actually spawned on (#209). Absent for records written
|
|
224
|
+
* before run.json carried it, and for tree-only fallbacks — renders `unknown`,
|
|
225
|
+
* never a guessed `rpc`.
|
|
226
|
+
*/
|
|
227
|
+
spawnMode?: string;
|
|
184
228
|
/** RPC process is still running. */
|
|
185
229
|
alive: boolean;
|
|
230
|
+
/**
|
|
231
|
+
* Run-dir ownership verdict (#209-3), from `owner.json`.
|
|
232
|
+
* `alive` is this process's in-memory child registry, which never contains
|
|
233
|
+
* a child launched by another session — so a foreign record must be judged
|
|
234
|
+
* by the owner marker instead. Only consulted when `alive` is false.
|
|
235
|
+
*/
|
|
236
|
+
owner?: "live" | "dead" | "unknown";
|
|
186
237
|
now?: number;
|
|
187
238
|
}
|
|
188
239
|
|
|
@@ -245,6 +296,12 @@ function formatExecutionStatusLine(
|
|
|
245
296
|
return `${label}: ${execution.status}${execution.reason ? ` (${execution.reason})` : ""}`;
|
|
246
297
|
}
|
|
247
298
|
|
|
299
|
+
/** One-line ownership verdict appended to a status block (#209-3). */
|
|
300
|
+
function ownerLine(owner: "live" | "dead" | "unknown"): string {
|
|
301
|
+
if (owner === "dead") return "owner: dead (pid gone)";
|
|
302
|
+
return `owner: ${owner}`;
|
|
303
|
+
}
|
|
304
|
+
|
|
248
305
|
/**
|
|
249
306
|
* Status text for one child (`/legion status <name>` and `subagent status`).
|
|
250
307
|
*
|
|
@@ -257,7 +314,15 @@ function formatExecutionStatusLine(
|
|
|
257
314
|
*/
|
|
258
315
|
export function renderLiveChildStatus(input: LiveChildStatusInput): string {
|
|
259
316
|
const stale = persistedSnapshotIsStale(input);
|
|
260
|
-
|
|
317
|
+
// #209-3: a persisted `working` snapshot whose run-dir owner pid is gone is
|
|
318
|
+
// dead, not working. `stale` (alive process + retired/failed snapshot) keeps
|
|
319
|
+
// owning the opposite direction, so the two never fight over one record.
|
|
320
|
+
const ownerDead = input.owner === "dead" && !input.alive;
|
|
321
|
+
const state = stale
|
|
322
|
+
? "working"
|
|
323
|
+
: ownerDead && input.state === "working"
|
|
324
|
+
? "failed"
|
|
325
|
+
: input.state;
|
|
261
326
|
const execution = stale
|
|
262
327
|
? { status: "running", reason: "rpc child alive", usage: input.execution?.usage }
|
|
263
328
|
: input.execution;
|
|
@@ -268,9 +333,11 @@ export function renderLiveChildStatus(input: LiveChildStatusInput): string {
|
|
|
268
333
|
const age = lastJsonlEventAgeMs(input.sessionFile, input.now ?? Date.now());
|
|
269
334
|
lines.push(formatAliveQuiet(age));
|
|
270
335
|
}
|
|
336
|
+
// After the liveness lead: who owns the run dir, and whether that pid is gone.
|
|
337
|
+
if (input.owner !== undefined) lines.push(ownerLine(input.owner));
|
|
271
338
|
// Quiet age first; path metadata after snapshots so old store lines stay below.
|
|
272
339
|
const meta = [
|
|
273
|
-
|
|
340
|
+
`supervisor=${input.spawnMode ?? "unknown"}`,
|
|
274
341
|
`session=${input.sessionFile}`,
|
|
275
342
|
];
|
|
276
343
|
if (execution) {
|
|
@@ -356,6 +423,12 @@ export interface SessionRuntime {
|
|
|
356
423
|
statusTui(): WidgetTui | undefined;
|
|
357
424
|
/** Forward join config to the internal coordinator (settings wiring). */
|
|
358
425
|
setJoinConfig(config: JoinConfig): void;
|
|
426
|
+
/** Parent agent run started (pi `agent_start`): hold completion notices until settle (D-A/D-C). */
|
|
427
|
+
notifyParentRunStart(): void;
|
|
428
|
+
/** Parent agent run settled (pi `agent_settled`): send every held notice now, then repaint (D-A/D-D). */
|
|
429
|
+
notifyParentSettled(): void;
|
|
430
|
+
/** Introspection seam for tests/doctor: deferred-notice queue state (D-B observability). */
|
|
431
|
+
noticeQueueStats(): NoticeQueueStats;
|
|
359
432
|
/**
|
|
360
433
|
* Explicit wait: suppress auto-notify on the targets via consumedByTool
|
|
361
434
|
* and aggregate their results. A timeout restores auto-notify.
|
|
@@ -400,6 +473,92 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
|
|
|
400
473
|
mode: DEFAULT_JOIN_MODE,
|
|
401
474
|
flushMs: DEFAULT_FLUSH_MS,
|
|
402
475
|
};
|
|
476
|
+
|
|
477
|
+
// Run-scoped completion outbox (D-A). The host only drains
|
|
478
|
+
// `agent.followUpQueue` inside a live run, so a `followUp` sent while the
|
|
479
|
+
// parent run is active sits invisible until the user types again. When a
|
|
480
|
+
// run is active we therefore hold the formatted notice here and burst it
|
|
481
|
+
// out on `agent_settled` (D-D). This is the ONLY deferral point: the join
|
|
482
|
+
// window above batches stragglers and never waits on a busy parent (D-I).
|
|
483
|
+
//
|
|
484
|
+
// D-H: the default is `false`, so a host (or a test) that never signals a
|
|
485
|
+
// run start keeps today's immediate-send behaviour byte for byte.
|
|
486
|
+
let parentRunActive = false;
|
|
487
|
+
const outbox: QueuedNotice[] = [];
|
|
488
|
+
let outboxTimer: JoinSchedulerHandle | undefined;
|
|
489
|
+
let forcedFlushes = 0;
|
|
490
|
+
|
|
491
|
+
const armOutboxTimer = (): void => {
|
|
492
|
+
if (outboxTimer || outbox.length === 0 || disposed) return;
|
|
493
|
+
const oldest = outbox[0]!;
|
|
494
|
+
const delay = Math.max(0, oldest.enqueuedAt + MAX_PENDING_AGE_MS - now());
|
|
495
|
+
// `live` proves this callback is the CURRENT arm. A cancelled-but-still-
|
|
496
|
+
// invoked timer (a zombie queued before the cancel landed) must be a strict
|
|
497
|
+
// no-op: otherwise it would clear the live timer's handle and force-send a
|
|
498
|
+
// notice that is nowhere near its cap.
|
|
499
|
+
//
|
|
500
|
+
// A boolean — not a closure over the handle — because an injected scheduler
|
|
501
|
+
// is free to invoke the callback synchronously, before the `const` binding
|
|
502
|
+
// is initialized (TDZ). `live` is assigned above the call, so it is always
|
|
503
|
+
// safe to read from inside the callback.
|
|
504
|
+
let live = true;
|
|
505
|
+
const handle: JoinSchedulerHandle = (deps.scheduleNoticeOverflow ?? defaultNoticeOverflow)(delay, () => {
|
|
506
|
+
if (!live) return;
|
|
507
|
+
live = false;
|
|
508
|
+
outboxTimer = undefined;
|
|
509
|
+
if (outbox.length === 0) return;
|
|
510
|
+
const next = outbox[0]!;
|
|
511
|
+
if (now() < next.enqueuedAt + MAX_PENDING_AGE_MS) {
|
|
512
|
+
// Fired while the oldest notice is still younger than the cap (a
|
|
513
|
+
// coarse timer or a backwards clock step). Never force-send early:
|
|
514
|
+
// re-arm for the remaining age instead.
|
|
515
|
+
armOutboxTimer();
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
forcedFlushes += 1;
|
|
519
|
+
flushOutbox();
|
|
520
|
+
});
|
|
521
|
+
outboxTimer = {
|
|
522
|
+
cancel: () => {
|
|
523
|
+
live = false;
|
|
524
|
+
handle.cancel();
|
|
525
|
+
},
|
|
526
|
+
};
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
// FIFO, one message per notice: the grouped shape belongs to the join layer.
|
|
530
|
+
// N held notices = N host `_runAgentPrompt` calls; the common case is 1 per
|
|
531
|
+
// run, so the batch is not merged into a synthetic grouped notice.
|
|
532
|
+
const flushOutbox = (): void => {
|
|
533
|
+
outboxTimer?.cancel();
|
|
534
|
+
outboxTimer = undefined;
|
|
535
|
+
if (outbox.length === 0) return;
|
|
536
|
+
// One splice: anything enqueued by a re-entrant send belongs to the next
|
|
537
|
+
// batch, so a settle can never double-deliver what it already flushed.
|
|
538
|
+
const batch = outbox.splice(0);
|
|
539
|
+
for (const item of batch) {
|
|
540
|
+
// A rejected send is dropped, not requeued (same as before).
|
|
541
|
+
deliverCompletion(
|
|
542
|
+
{ sendMessage: deps.sendMessage },
|
|
543
|
+
item.notice,
|
|
544
|
+
item.triggerTurn,
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
/** The single completion exit: send now, or hold until the run settles. */
|
|
550
|
+
const sendCompletionNotice = (
|
|
551
|
+
notice: CompletionNotice,
|
|
552
|
+
triggerTurn: boolean,
|
|
553
|
+
): void => {
|
|
554
|
+
if (!parentRunActive) {
|
|
555
|
+
deliverCompletion({ sendMessage: deps.sendMessage }, notice, triggerTurn);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
outbox.push({ notice, triggerTurn, enqueuedAt: now() });
|
|
559
|
+
armOutboxTimer();
|
|
560
|
+
};
|
|
561
|
+
|
|
403
562
|
const join = new JoinCoordinator({
|
|
404
563
|
deliver: (batch) => {
|
|
405
564
|
if (disposed) return;
|
|
@@ -415,8 +574,7 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
|
|
|
415
574
|
batch.length === 1 &&
|
|
416
575
|
(joinConfig.mode === "each" || join.allSettled(first.runId))
|
|
417
576
|
) {
|
|
418
|
-
|
|
419
|
-
{ sendMessage: deps.sendMessage },
|
|
577
|
+
sendCompletionNotice(
|
|
420
578
|
formatCompletionNotice(first.input),
|
|
421
579
|
first.triggerTurn,
|
|
422
580
|
);
|
|
@@ -435,8 +593,7 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
|
|
|
435
593
|
!batch.some((e) => e.name === job.name),
|
|
436
594
|
)
|
|
437
595
|
.map((job) => job.name);
|
|
438
|
-
|
|
439
|
-
{ sendMessage: deps.sendMessage },
|
|
596
|
+
sendCompletionNotice(
|
|
440
597
|
formatGroupedNotice({
|
|
441
598
|
runId: first.runId,
|
|
442
599
|
entries: batch.map((e) => ({ ...e.input, status: e.status })),
|
|
@@ -867,6 +1024,10 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
|
|
|
867
1024
|
job.notified = false;
|
|
868
1025
|
job.consumedByTool = false;
|
|
869
1026
|
join.addPending(job.runId, job.name);
|
|
1027
|
+
// Repaint before the new watch takes over so the panel cannot
|
|
1028
|
+
// hold the old row's final second. watch() repaints too; this is
|
|
1029
|
+
// an idempotent second guarantee.
|
|
1030
|
+
refreshUi();
|
|
870
1031
|
runtime.watch(name, opts);
|
|
871
1032
|
};
|
|
872
1033
|
try {
|
|
@@ -914,6 +1075,9 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
|
|
|
914
1075
|
}
|
|
915
1076
|
job.state = "awaiting";
|
|
916
1077
|
job.settledAt = now();
|
|
1078
|
+
// Repaint immediately: the row flips to a terminal state here, and
|
|
1079
|
+
// waiting for the 500ms sampler leaves a phantom "working" row.
|
|
1080
|
+
refreshUi();
|
|
917
1081
|
if (!job.consumedByTool && !job.notified) {
|
|
918
1082
|
job.notified = true;
|
|
919
1083
|
join.onTerminal({
|
|
@@ -959,6 +1123,7 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
|
|
|
959
1123
|
}
|
|
960
1124
|
job.state = "awaiting";
|
|
961
1125
|
job.settledAt = now();
|
|
1126
|
+
refreshUi();
|
|
962
1127
|
if (!job.consumedByTool && !job.notified) {
|
|
963
1128
|
job.notified = true;
|
|
964
1129
|
// Collect failures join the batch too, as a failed entry.
|
|
@@ -1157,6 +1322,25 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
|
|
|
1157
1322
|
join.setConfig(config);
|
|
1158
1323
|
},
|
|
1159
1324
|
|
|
1325
|
+
notifyParentRunStart() {
|
|
1326
|
+
parentRunActive = true;
|
|
1327
|
+
},
|
|
1328
|
+
|
|
1329
|
+
notifyParentSettled() {
|
|
1330
|
+
parentRunActive = false;
|
|
1331
|
+
flushOutbox();
|
|
1332
|
+
refreshUi();
|
|
1333
|
+
},
|
|
1334
|
+
|
|
1335
|
+
noticeQueueStats(): NoticeQueueStats {
|
|
1336
|
+
const oldest = outbox[0];
|
|
1337
|
+
return {
|
|
1338
|
+
pending: outbox.length,
|
|
1339
|
+
oldestAgeMs: oldest ? Math.max(0, now() - oldest.enqueuedAt) : 0,
|
|
1340
|
+
forcedFlushes,
|
|
1341
|
+
};
|
|
1342
|
+
},
|
|
1343
|
+
|
|
1160
1344
|
async wait(names, opts = {}) {
|
|
1161
1345
|
return Promise.all(
|
|
1162
1346
|
names.map((name) =>
|
|
@@ -1194,6 +1378,12 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
|
|
|
1194
1378
|
jobs.clear();
|
|
1195
1379
|
finished.clear();
|
|
1196
1380
|
join.dispose();
|
|
1381
|
+
// Shutdown drops held notices unsent: the child's session file survives,
|
|
1382
|
+
// so `collect` can still recover the result from disk. The `finished`
|
|
1383
|
+
// cache does NOT survive — it was cleared two statements up.
|
|
1384
|
+
outboxTimer?.cancel();
|
|
1385
|
+
outboxTimer = undefined;
|
|
1386
|
+
outbox.length = 0;
|
|
1197
1387
|
if (timer) clearInterval(timer);
|
|
1198
1388
|
timer = undefined;
|
|
1199
1389
|
for (const unsubscribe of busUnsubscribers) unsubscribe();
|
|
@@ -1289,6 +1479,13 @@ function statusEntryFromNode(
|
|
|
1289
1479
|
activity?.turnStartedAt,
|
|
1290
1480
|
activity?.turnEndedAt,
|
|
1291
1481
|
);
|
|
1482
|
+
// An in-process child has no tracked job for its whole first run: spawnChild()
|
|
1483
|
+
// awaits the whole agent run, so the job (and its sessionFile plumbing in
|
|
1484
|
+
// statusEntryFromJob) does not exist yet. The node row does carry the session
|
|
1485
|
+
// file, so parse it directly — otherwise the ledger only has a tokens_out row
|
|
1486
|
+
// after settle and the token rate reads 0 for the agent's entire lifetime.
|
|
1487
|
+
const live = node.sessionFile ? progressFromSessionFile(node.sessionFile) : {};
|
|
1488
|
+
const tokensOut = live.tokensOut ?? node.tokensOut;
|
|
1292
1489
|
return {
|
|
1293
1490
|
name: node.name,
|
|
1294
1491
|
agent: node.role,
|
|
@@ -1300,8 +1497,9 @@ function statusEntryFromNode(
|
|
|
1300
1497
|
...(node.phase ? { phase: node.phase } : {}),
|
|
1301
1498
|
...(node.kind ? { kind: node.kind } : {}),
|
|
1302
1499
|
...(node.model ? { model: node.model } : {}),
|
|
1303
|
-
...(node.tokensTotal ? { tokensTotal: node.tokensTotal } : {}),
|
|
1304
|
-
...(
|
|
1500
|
+
...(live.usageTotal ? { tokensTotal: live.usageTotal } : node.tokensTotal ? { tokensTotal: node.tokensTotal } : {}),
|
|
1501
|
+
...(tokensOut ? { tokensOut } : {}),
|
|
1502
|
+
...(live.generationMs ? { generationMs: live.generationMs } : {}),
|
|
1305
1503
|
...(activity?.turn !== undefined ? { turns: activity.turn } : {}),
|
|
1306
1504
|
...(activity?.turnStartedAt !== undefined ? { currentTurnStartedAt: activity.turnStartedAt } : {}),
|
|
1307
1505
|
...(activity?.turnEndedAt !== undefined ? { currentTurnEndedAt: activity.turnEndedAt } : {}),
|
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
* so `/legion doctor` / `subagent action=diagnose` can show what just broke.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import type { SpawnMode } from "../shared/types.ts";
|
|
7
|
+
|
|
6
8
|
export interface SpawnFailureRecord {
|
|
7
9
|
ts: number;
|
|
8
10
|
name: string;
|
|
9
11
|
message: string;
|
|
10
12
|
stderr?: string;
|
|
11
13
|
hadNoExtensions?: boolean;
|
|
14
|
+
/** When known, doctor / hints can avoid RPC-only advice for inprocess (#212). */
|
|
15
|
+
spawnMode?: SpawnMode;
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
const MAX_FAILURES = 8;
|
|
@@ -29,21 +33,35 @@ export function clearSpawnFailures(): void {
|
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
const MODEL_NOT_FOUND =
|
|
32
|
-
/Model\s+"[^"]+"\s+not found|No models available|Use --list-models/i;
|
|
36
|
+
/Model\s+"[^"]+"\s+not found|No models available|Use --list-models|Unknown provider|inprocess model resolve failed/i;
|
|
33
37
|
|
|
34
38
|
export function looksLikeMissingModel(stderr: string | undefined): boolean {
|
|
35
39
|
return Boolean(stderr && MODEL_NOT_FOUND.test(stderr));
|
|
36
40
|
}
|
|
37
41
|
|
|
42
|
+
/** Inprocess children have no argv; point at models.json / role extensions (#212). */
|
|
43
|
+
export const INPROCESS_MODEL_HINT =
|
|
44
|
+
"Hint: default spawnMode is `inprocess` — children resolve models via ModelRuntime, " +
|
|
45
|
+
"not a `--no-extensions` argv.\n" +
|
|
46
|
+
"Fix: declare the provider statically in `models.json` (agent dir / project), " +
|
|
47
|
+
"list the extension under the role's `extensions:` frontmatter, or set " +
|
|
48
|
+
"`inheritExtensions: true` / `PI_LEGION_CHILD_INHERIT_EXTENSIONS=1`. " +
|
|
49
|
+
"Run `/legion doctor` or `subagent action=diagnose`.";
|
|
50
|
+
|
|
38
51
|
/**
|
|
39
|
-
* When a child dies because its model/provider is invisible
|
|
40
|
-
*
|
|
52
|
+
* When a child dies because its model/provider is invisible, point at the
|
|
53
|
+
* right escape hatch for the spawn backend (`rpc` argv vs `inprocess`).
|
|
41
54
|
*/
|
|
42
55
|
export function spawnFailureHint(input: {
|
|
43
56
|
stderr?: string;
|
|
44
57
|
args?: readonly string[];
|
|
58
|
+
/** Prefer this when known; inprocess never gets argv / CHILD_EXTENSIONS advice (#212). */
|
|
59
|
+
spawnMode?: SpawnMode;
|
|
45
60
|
}): string | undefined {
|
|
46
61
|
if (!looksLikeMissingModel(input.stderr)) return undefined;
|
|
62
|
+
if (input.spawnMode === "inprocess") {
|
|
63
|
+
return INPROCESS_MODEL_HINT;
|
|
64
|
+
}
|
|
47
65
|
const noExt = input.args?.includes("--no-extensions") ?? true;
|
|
48
66
|
if (!noExt) {
|
|
49
67
|
return (
|
|
@@ -67,13 +85,16 @@ export function withSpawnFailureHint(message: string, input: {
|
|
|
67
85
|
stderr?: string;
|
|
68
86
|
args?: readonly string[];
|
|
69
87
|
name?: string;
|
|
88
|
+
spawnMode?: SpawnMode;
|
|
70
89
|
}): string {
|
|
71
90
|
const hint = spawnFailureHint(input);
|
|
72
91
|
recordSpawnFailure({
|
|
73
92
|
name: input.name ?? "?",
|
|
74
93
|
message,
|
|
75
94
|
stderr: input.stderr,
|
|
76
|
-
hadNoExtensions:
|
|
95
|
+
hadNoExtensions:
|
|
96
|
+
input.spawnMode === "inprocess" ? false : input.args?.includes("--no-extensions"),
|
|
97
|
+
spawnMode: input.spawnMode,
|
|
77
98
|
});
|
|
78
99
|
return hint ? `${message}\n\n${hint}` : message;
|
|
79
100
|
}
|
package/src/runs/orchestrator.ts
CHANGED
|
@@ -74,9 +74,13 @@ import { provisionWorktree } from "./provision.ts";
|
|
|
74
74
|
|
|
75
75
|
export interface BudgetRefusedEvent {
|
|
76
76
|
type: "budget_refused";
|
|
77
|
-
requestedMaxDepth: number;
|
|
78
|
-
effectiveMaxDepth: number;
|
|
79
77
|
reason: string;
|
|
78
|
+
/** Present for maxDepth hard-cap warnings. */
|
|
79
|
+
requestedMaxDepth?: number;
|
|
80
|
+
/** Present for maxDepth hard-cap warnings. */
|
|
81
|
+
effectiveMaxDepth?: number;
|
|
82
|
+
/** Role name when reason is `worktree_denied_non_centurion` (#214). */
|
|
83
|
+
agent?: string;
|
|
80
84
|
}
|
|
81
85
|
|
|
82
86
|
export interface OrchestratorLegionContext {
|
|
@@ -128,7 +132,15 @@ export interface CollectResult {
|
|
|
128
132
|
}
|
|
129
133
|
|
|
130
134
|
export const COLLECT_GRACE_EXTENSIONS = 2;
|
|
131
|
-
/**
|
|
135
|
+
/**
|
|
136
|
+
* Shown when this collect() call's budget elapses and the child is still in-turn.
|
|
137
|
+
*
|
|
138
|
+
* The "cumulative since launch" wording below is legacy: `collect()` starts its
|
|
139
|
+
* deadline from the moment it is called (`orchestrator.ts` `let deadline = ...`),
|
|
140
|
+
* not from launch. The literal is user-visible and asserted in integration tests,
|
|
141
|
+
* so D6 of `docs/contracts/vexillatio-nonblocking-launch-v1.md` deliberately left
|
|
142
|
+
* it untouched; do not read it as the actual budget origin.
|
|
143
|
+
*/
|
|
132
144
|
export const COLLECT_DEADLINE_REASON = "collect deadline (cumulative since launch) exceeded; child still alive";
|
|
133
145
|
/** Shown when a live child's session ends on an OpenAI/GPT connection blip. */
|
|
134
146
|
export const TRANSIENT_CONNECTION_REASON = "transient connection error; RPC child still alive";
|
|
@@ -257,6 +269,7 @@ export class Orchestrator {
|
|
|
257
269
|
private readonly legion?: OrchestratorLegionContext;
|
|
258
270
|
private readonly requestedDepthCap?: number;
|
|
259
271
|
private depthWarningEmitted = false;
|
|
272
|
+
private worktreeDeniedWarningEmitted = false;
|
|
260
273
|
private spawned = 0;
|
|
261
274
|
private readonly children = new Map<string, ChildRecord>();
|
|
262
275
|
private readonly nameCounter = new Map<string, number>();
|
|
@@ -524,6 +537,26 @@ export class Orchestrator {
|
|
|
524
537
|
this.onBudgetRefused?.(event);
|
|
525
538
|
}
|
|
526
539
|
|
|
540
|
+
/**
|
|
541
|
+
* #214: only `name === "centurion"` may hold worktree isolation.
|
|
542
|
+
* Non-centurion requests warn once per run (like warnDepthCap) and are
|
|
543
|
+
* forced to false — never thrown.
|
|
544
|
+
*/
|
|
545
|
+
private warnWorktreeDenied(agentName: string): void {
|
|
546
|
+
if (this.worktreeDeniedWarningEmitted) return;
|
|
547
|
+
this.worktreeDeniedWarningEmitted = true;
|
|
548
|
+
const event: BudgetRefusedEvent = {
|
|
549
|
+
type: "budget_refused",
|
|
550
|
+
reason: "worktree_denied_non_centurion",
|
|
551
|
+
agent: agentName,
|
|
552
|
+
};
|
|
553
|
+
console.warn(
|
|
554
|
+
`[pi-legion] worktree denied for non-centurion role '${agentName}'; forced worktree: false. ` +
|
|
555
|
+
`Fix the role card (set worktree: false) or dispatch a centurion for isolated parallel writers.`,
|
|
556
|
+
);
|
|
557
|
+
this.onBudgetRefused?.(event);
|
|
558
|
+
}
|
|
559
|
+
|
|
527
560
|
async launch(input: {
|
|
528
561
|
agent: AgentConfig;
|
|
529
562
|
task: string;
|
|
@@ -562,8 +595,18 @@ export class Orchestrator {
|
|
|
562
595
|
let sessionFile = previous?.sessionFile ?? this.sessionFileFor(name);
|
|
563
596
|
const tempDir = this.runDir;
|
|
564
597
|
const parentRole = this.legion ? getNode(this.legion.db, this.legion.nodeId)?.role : undefined;
|
|
565
|
-
const
|
|
566
|
-
|
|
598
|
+
const isCenturion = input.agent.name === "centurion";
|
|
599
|
+
// #214 D4: only centurion may hold worktree. Legion path still forces
|
|
600
|
+
// centurion→true and centurion-parent→member false; non-centurion
|
|
601
|
+
// requests from any source are demoted below (D1: warn, never throw).
|
|
602
|
+
let worktree = isCenturion
|
|
603
|
+
? (this.legion
|
|
604
|
+
? true
|
|
605
|
+
: resolveLaunchWorktree({
|
|
606
|
+
roleDefault: input.agent.worktree,
|
|
607
|
+
launch: input.worktree,
|
|
608
|
+
parentInWorktree: isInsideChildWorktree(this.cwd),
|
|
609
|
+
}))
|
|
567
610
|
: this.legion && parentRole === "centurion"
|
|
568
611
|
? false
|
|
569
612
|
: resolveLaunchWorktree({
|
|
@@ -571,6 +614,10 @@ export class Orchestrator {
|
|
|
571
614
|
launch: input.worktree,
|
|
572
615
|
parentInWorktree: isInsideChildWorktree(this.cwd),
|
|
573
616
|
});
|
|
617
|
+
if (!isCenturion && worktree === true) {
|
|
618
|
+
this.warnWorktreeDenied(input.agent.name);
|
|
619
|
+
worktree = false;
|
|
620
|
+
}
|
|
574
621
|
// Spawn-mode arbitration (contract D2). `resolveSpawnMode` only reports the
|
|
575
622
|
// configured intent; the launch context decides whether an OMITTED mode can
|
|
576
623
|
// actually run in-process. An omitted mode falls back to rpc when this
|
|
@@ -614,7 +661,7 @@ export class Orchestrator {
|
|
|
614
661
|
// `launch({ worktree: false })` — that override now wins as it should.
|
|
615
662
|
if (inProcess && worktree) {
|
|
616
663
|
throw new SubagentError(
|
|
617
|
-
`spawnMode "inprocess" cannot use worktree isolation for '${name}'`,
|
|
664
|
+
`spawnMode "inprocess" cannot use worktree isolation for '${name}'; set worktree: false`,
|
|
618
665
|
ErrorCodes.INVALID_PARAMS,
|
|
619
666
|
);
|
|
620
667
|
}
|
|
@@ -656,7 +703,8 @@ export class Orchestrator {
|
|
|
656
703
|
? this.depth + 1
|
|
657
704
|
: Math.min(this.maxDepthValue, this.depth + roleDepth, MAX_NESTED_PATH_ENTRIES),
|
|
658
705
|
nodeId,
|
|
659
|
-
|
|
706
|
+
// #209: the run record carries the ARBITRATED backend, not a literal.
|
|
707
|
+
herdr: { supervisor: mode, node_id: nodeId },
|
|
660
708
|
});
|
|
661
709
|
createdRunId = run.runId;
|
|
662
710
|
sessionFile = this.legion.runStore.sessionFileFor(run.runId, name);
|
|
@@ -740,6 +788,8 @@ export class Orchestrator {
|
|
|
740
788
|
previous.acceptance = undefined;
|
|
741
789
|
previous.outputFile = undefined;
|
|
742
790
|
previous.model = startedModel;
|
|
791
|
+
// A resume re-arbitrates the backend; keep the record truthful (#209).
|
|
792
|
+
previous.spawnMode = mode;
|
|
743
793
|
this.children.set(name, previous);
|
|
744
794
|
}
|
|
745
795
|
const child = previous ?? this.recordChild({
|
|
@@ -747,6 +797,7 @@ export class Orchestrator {
|
|
|
747
797
|
...(nodeId ? { nodeId } : {}),
|
|
748
798
|
agent: input.agent,
|
|
749
799
|
sessionFile: result.sessionFile,
|
|
800
|
+
spawnMode: mode,
|
|
750
801
|
...(startedModel ? { model: startedModel } : {}),
|
|
751
802
|
...(input.thinking !== undefined ? { thinking: input.thinking } : input.agent.thinking !== undefined ? { thinking: input.agent.thinking } : {}),
|
|
752
803
|
...(worktreePath ? { worktreePath } : {}),
|
|
@@ -762,6 +813,8 @@ export class Orchestrator {
|
|
|
762
813
|
agent: input.agent.name,
|
|
763
814
|
kind: input.agent.kind,
|
|
764
815
|
child,
|
|
816
|
+
// #209: callers render this instead of assuming the RPC backend.
|
|
817
|
+
spawnMode: mode,
|
|
765
818
|
};
|
|
766
819
|
} catch (error) {
|
|
767
820
|
if (spawned) await this.supervisor.retire(name).catch(() => {});
|
|
@@ -843,6 +896,8 @@ export class Orchestrator {
|
|
|
843
896
|
nodeId?: string;
|
|
844
897
|
agent: AgentConfig;
|
|
845
898
|
sessionFile: string;
|
|
899
|
+
/** Arbitrated spawn backend (#209); persisted so later operators see the truth. */
|
|
900
|
+
spawnMode: SpawnMode;
|
|
846
901
|
model?: string;
|
|
847
902
|
thinking?: string | false;
|
|
848
903
|
worktreePath?: string;
|
|
@@ -862,6 +917,7 @@ export class Orchestrator {
|
|
|
862
917
|
...(input.agent.completionGuard ? { completionGuard: true } : {}),
|
|
863
918
|
...(input.model ? { model: input.model } : {}),
|
|
864
919
|
...(input.thinking !== undefined ? { thinking: input.thinking } : {}),
|
|
920
|
+
spawnMode: input.spawnMode,
|
|
865
921
|
...(input.worktreePath ? { worktreePath: input.worktreePath } : {}),
|
|
866
922
|
...(input.worktreeBranch ? { worktreeBranch: input.worktreeBranch } : {}),
|
|
867
923
|
};
|
|
@@ -954,9 +1010,23 @@ export class Orchestrator {
|
|
|
954
1010
|
}
|
|
955
1011
|
if (opts.signal?.aborted) return abortedSnapshot();
|
|
956
1012
|
parsed = parseSessionFile(child.sessionFile);
|
|
1013
|
+
// #227 D5: a child whose process is gone cannot produce another message.
|
|
1014
|
+
// When its last turn already ended with a terminal assistant message there
|
|
1015
|
+
// is nothing left to wait for, so finish the completed turn here.
|
|
1016
|
+
//
|
|
1017
|
+
// This is defensive rather than an observable behaviour fix: the
|
|
1018
|
+
// collect-deadline branch below is already gated on `isAlive(name)`, so a
|
|
1019
|
+
// dead child never received the `running` snapshot. Keeping the guard makes
|
|
1020
|
+
// the intent explicit instead of relying on that coincidence — see D5 and
|
|
1021
|
+
// the disclosure in the #227 contract.
|
|
1022
|
+
if (wait === "timeout" && !this.supervisor.isAlive(name) && isLastTurnComplete(parsed)) {
|
|
1023
|
+
return this.finishCollect(child, parsed, deriveOutcome(parsed), deadline);
|
|
1024
|
+
}
|
|
957
1025
|
let execution = deriveOutcome(parsed);
|
|
958
1026
|
if (wait === "timeout" && this.supervisor.isAlive(name) && !isLastTurnComplete(parsed)) {
|
|
959
|
-
// `timeoutMs` is
|
|
1027
|
+
// `timeoutMs` is measured from THIS collect call, not from the launch (D6).
|
|
1028
|
+
// The constant's "cumulative since launch" wording is legacy; see its
|
|
1029
|
+
// docstring for why it was left as-is.
|
|
960
1030
|
execution = { ...execution, status: "running", reason: COLLECT_DEADLINE_REASON };
|
|
961
1031
|
} else if (wait === "gone" && parsed.turns.length === 0) {
|
|
962
1032
|
execution = { status: "aborted", reason: "RPC child exited before writing session JSONL", model: child.model ?? null };
|
package/src/runs/worktree.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Per-child git worktree isolation.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* retire leaves the tree on disk (same
|
|
4
|
+
* Only the `centurion` role may hold `worktree: true` (#214 / contract §9).
|
|
5
|
+
* Squad members work in-place inside the centurion subtree (or parent cwd).
|
|
6
|
+
* When isolation is granted, the child gets its own branch under the run
|
|
7
|
+
* directory (`git worktree add -b`); retire leaves the tree on disk (same
|
|
8
|
+
* rationale as keeping the session file).
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import { execFileSync } from "node:child_process";
|