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.
@@ -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
- const state = stale ? "working" : input.state;
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
- "supervisor=rpc",
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
- deliverCompletion(
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
- deliverCompletion(
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
- ...(node.tokensOut ? { tokensOut: node.tokensOut } : {}),
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 under
40
- * `--no-extensions`, point at the escape hatches and `/legion doctor`.
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: input.args?.includes("--no-extensions"),
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
  }
@@ -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
- /** Shown when the launch-time collect budget elapses and the child is still in-turn. */
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 worktree = this.legion && input.agent.name === "centurion"
566
- ? true
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
- herdr: { supervisor: "rpc", node_id: nodeId },
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 the budget from launch, not the length of this status check.
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 };
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Per-child git worktree isolation.
3
3
  *
4
- * Writer roles default to `worktree: true`. Each child gets its own branch
5
- * under the run directory (`git worktree add -b`) so concurrent parent Pis
6
- * do not share a dirty checkout. The child commits there and opens an MR;
7
- * retire leaves the tree on disk (same rationale as keeping the session file).
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";