@nredd/pi-subagents 0.22.0-nredd.6 → 0.22.0-nredd.8

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.
Files changed (46) hide show
  1. package/README.md +160 -43
  2. package/package.json +8 -8
  3. package/src/agent-file-toggle.ts +6 -4
  4. package/src/agent-manager.ts +418 -118
  5. package/src/agent-runner.ts +155 -62
  6. package/src/cross-extension-rpc.ts +11 -36
  7. package/src/custom-agents.ts +9 -5
  8. package/src/default-agents.ts +2 -4
  9. package/src/enabled-models.ts +8 -7
  10. package/src/enterprise-local.ts +5 -5
  11. package/src/home-paths.ts +27 -0
  12. package/src/index.ts +231 -107
  13. package/src/invocation-config.ts +8 -35
  14. package/src/memory.ts +9 -9
  15. package/src/mention-clone.ts +9 -5
  16. package/src/model-resolver.ts +41 -11
  17. package/src/model-scope.ts +2 -1
  18. package/src/model-selection.ts +159 -0
  19. package/src/model-tiers.ts +171 -0
  20. package/src/nested-tools.ts +31 -26
  21. package/src/output-file.ts +23 -1
  22. package/src/package-resources.ts +25 -10
  23. package/src/process-budget.ts +346 -0
  24. package/src/quota-admission.ts +21 -9
  25. package/src/resource-loader.ts +51 -0
  26. package/src/schedule.ts +41 -19
  27. package/src/settings.ts +111 -8
  28. package/src/skill-loader.ts +8 -7
  29. package/src/stale-ctx.ts +16 -0
  30. package/src/types.ts +33 -0
  31. package/src/ui/agent-row.ts +13 -3
  32. package/src/ui/agent-widget.ts +15 -4
  33. package/src/ui/conversation-viewer.ts +6 -3
  34. package/src/ui/fallback-flag.ts +90 -0
  35. package/src/ui/fleet-list.ts +363 -53
  36. package/src/ui/fleet-state.ts +154 -0
  37. package/src/ui/notification-view.ts +18 -2
  38. package/src/ui/workflow-card.ts +5 -1
  39. package/src/ui/workflow-dialog.ts +10 -0
  40. package/src/workflow/host.ts +43 -45
  41. package/src/workflow/journal.ts +13 -1
  42. package/src/workflow/progress.ts +4 -0
  43. package/src/workflow/runtime.ts +25 -8
  44. package/src/workflow/saved.ts +19 -17
  45. package/src/workflow/task.ts +10 -0
  46. package/src/workflow/worker-source.ts +156 -38
@@ -12,6 +12,12 @@
12
12
  * session of work it could have done itself. Excess agents in either pool are
13
13
  * queued and auto-started as slots free up. Nested children take no slot in
14
14
  * either — see `occupiesPoolSlot` / `occupiesForegroundSlot`.
15
+ *
16
+ * Both pools are per manager. Above them sits the process-wide budget
17
+ * (`process-budget.ts`, `maxActiveSubagents`), which EVERY run takes a lease
18
+ * from — nested, workflow, scheduled, RPC, and resumes included — across every
19
+ * manager in the process. A start needs room in its pool (if it has one) and a
20
+ * lease; whichever is missing, it waits in `queue`.
15
21
  */
16
22
 
17
23
  import { randomUUID } from "node:crypto";
@@ -24,7 +30,22 @@ import { getAgentConfig } from "./agent-types.js";
24
30
  import { type Clock, realClock, type TimerHandle } from "./clock.js";
25
31
  import { assignHandle, handleBase } from "./mention.js";
26
32
  import type { ModelCallEvent } from "./model-call.js";
27
- import { describeModel, describePhysicalModel, describeVirtualModel, isVirtualModel } from "./model-resolver.js";
33
+ import { describeModel, describePhysicalModel, describeVirtualModel, isVirtualModel, type ModelFallback } from "./model-resolver.js";
34
+ import { admitModelSelection, describeSelection, type ModelSelection, modelSelectionNote, selectModel } from "./model-selection.js";
35
+ import { type TierDisclosure, tierDisclosure } from "./model-tiers.js";
36
+ import { appendModelFallbackEntry, appendModelTierEntry } from "./output-file.js";
37
+ import {
38
+ assertWithinBudgetDepth,
39
+ BUDGET_QUEUE_FULL_ERROR,
40
+ type BudgetLease,
41
+ budgetHasPendingRoom,
42
+ enqueueBudget,
43
+ pumpBudget,
44
+ releaseBudget,
45
+ tryAcquireBudget,
46
+ } from "./process-budget.js";
47
+ import { describeBlock } from "./quota-admission.js";
48
+ import { isStaleCtxError } from "./stale-ctx.js";
28
49
  import type { AgentInvocation, AgentRecord, AgentTombstone, IsolationMode, MentionResolution, Pool, SubagentType, ThinkingLevel } from "./types.js";
29
50
  import { addUsage, type LifetimeUsage } from "./usage.js";
30
51
  import type { CompiledSchema } from "./workflow/json-schema.js";
@@ -43,6 +64,10 @@ function relabelFromResponse(record: AgentRecord, message: AssistantMessageInfo
43
64
  if (!physical) return;
44
65
  record.invocation ??= {};
45
66
  Object.assign(record.invocation, physical);
67
+ if ((record.modelTier?.kind === "routed" || record.modelTier?.kind === "static") && record.modelTier.answered !== physical.modelId) {
68
+ record.modelTier = { ...record.modelTier, answered: physical.modelId };
69
+ if (record.outputFile) appendModelTierEntry(record.outputFile, record.id, record.modelTier);
70
+ }
46
71
  }
47
72
 
48
73
  export type OnAgentComplete = (record: AgentRecord) => void;
@@ -147,12 +172,17 @@ export interface EvictedNote {
147
172
  evictedAfterMin: number;
148
173
  /** The `.output` transcript when one was written, else the session file. */
149
174
  transcript?: string;
175
+ /** The run's model fallback, so even an evicted agent's id still discloses it. */
176
+ modelFallback?: ModelFallback;
177
+ modelTier?: TierDisclosure;
150
178
  }
151
179
 
152
180
  /** The text a tool returns for an id whose record was evicted. */
153
181
  export function describeEvicted(note: EvictedNote): string {
154
182
  const where = note.transcript ? `; transcript: ${note.transcript}` : "";
155
- return `Agent "${note.id}" (${note.description}) ${note.status}, evicted after ${note.evictedAfterMin} min${where}`;
183
+ const disclosure = modelSelectionNote(note);
184
+ const fallback = disclosure ? `\n${disclosure}` : "";
185
+ return `Agent "${note.id}" (${note.description}) ${note.status}, evicted after ${note.evictedAfterMin} min${where}${fallback}`;
156
186
  }
157
187
 
158
188
  /**
@@ -244,6 +274,19 @@ function occupiesForegroundSlot(
244
274
  return !!record.blocking && isTopLevelAgent(record);
245
275
  }
246
276
 
277
+ /** A start waiting in `AgentManager.queue`; see there. */
278
+ interface QueueEntry {
279
+ id: string;
280
+ /** The pool it needs room in, or undefined when it waits on the budget alone. */
281
+ pool?: Pool;
282
+ start: () => Promise<void>;
283
+ release: () => void;
284
+ /** Withdraw the budget waiter. A no-op once granted. */
285
+ cancel: () => void;
286
+ /** Resolves once the entry has left the queue, started (and settled its startup) or not. */
287
+ left: Promise<void>;
288
+ }
289
+
247
290
  interface SpawnArgs {
248
291
  pi: ExtensionAPI;
249
292
  ctx: ExtensionContext;
@@ -253,6 +296,8 @@ interface SpawnArgs {
253
296
  }
254
297
 
255
298
  interface SpawnOptions {
299
+ /** Home inherited by nested callers; the manager's home is the default. */
300
+ homeDir?: string;
256
301
  description: string;
257
302
  /**
258
303
  * Optional memorable name for this instance, becoming a second handle
@@ -279,7 +324,14 @@ interface SpawnOptions {
279
324
  * `spawnTopLevel` strips it from anything a caller sends.
280
325
  */
281
326
  reclaim?: { handle: string; alias?: string };
282
- model?: Model<any>;
327
+ model?: Model<any> | string;
328
+ /** Internal preflight outcome, stored on the record without resolving again. */
329
+ modelSelection?: ModelSelection;
330
+ /** Historical outcome only, never used to label the continuation. */
331
+ previousModelTier?: TierDisclosure;
332
+ /** Historical fallback only, never used to label the continuation. */
333
+ previousModelFallback?: ModelFallback;
334
+
283
335
  maxTurns?: number;
284
336
  isolated?: boolean;
285
337
  inheritContext?: boolean;
@@ -443,6 +495,7 @@ const CHILD_SHUTDOWN_TIMEOUT_MS = 3_000;
443
495
  * `assertActive()` from a bare timer callback — an uncaughtException that kills pi (#242).
444
496
  */
445
497
  async function shutdownChildSession(session: AgentSession | undefined): Promise<void> {
498
+ let deadline: ReturnType<typeof setTimeout> | undefined;
446
499
  try {
447
500
  const runner = session?.extensionRunner;
448
501
  // Optional all the way down: on a pi without the getter, or a stubbed session from a
@@ -453,14 +506,44 @@ async function shutdownChildSession(session: AgentSession | undefined): Promise<
453
506
  // already torn down — one hung handler would leave a dead terminal.
454
507
  await Promise.race([
455
508
  runner.emit({ type: "session_shutdown", reason: "quit" }),
456
- new Promise<void>(resolve => setTimeout(resolve, CHILD_SHUTDOWN_TIMEOUT_MS).unref()),
509
+ new Promise<void>(resolve => {
510
+ deadline = setTimeout(resolve, CHILD_SHUTDOWN_TIMEOUT_MS);
511
+ deadline.unref();
512
+ }),
457
513
  ]);
458
514
  }
459
515
  } catch { /* a partial session must degrade, not take the teardown down with it */ }
516
+ finally {
517
+ // Promise.race does not cancel its loser, even when a handler rejects.
518
+ if (deadline !== undefined) clearTimeout(deadline);
519
+ }
460
520
  // Always, even on timeout: disposal is what this function ultimately exists to do.
461
521
  try { session?.dispose?.(); } catch { /* ignore */ }
462
522
  }
463
523
 
524
+ /**
525
+ * Wrap an observer the child's own agent loop calls (usage, compaction) so a
526
+ * throw cannot reach that loop. pi records a throw from a session listener as
527
+ * the turn's failure, so an observer closed over a replaced session's `pi`
528
+ * ("This extension ctx is stale ...") used to end a detached child with an
529
+ * empty error turn and "No output" (#145). That failure is expected after a
530
+ * reload and dropped silently. Any other throw is a real observer defect: it
531
+ * is logged once per observer and also kept from killing the run.
532
+ */
533
+ function guardObserver<A extends unknown[]>(name: string, observer: ((...args: A) => void) | undefined): ((...args: A) => void) | undefined {
534
+ if (!observer) return undefined;
535
+ let logged = false;
536
+ return (...args: A) => {
537
+ try {
538
+ observer(...args);
539
+ } catch (error) {
540
+ if (isStaleCtxError(error) || logged) return;
541
+ logged = true;
542
+ console.error(`pi-subagents: ${name} observer failed; further failures are not logged`, error);
543
+ }
544
+ };
545
+ }
546
+
464
547
  export class AgentManager {
465
548
  private agents = new Map<string, AgentRecord>();
466
549
  private cleanupInterval: TimerHandle;
@@ -502,10 +585,13 @@ export class AgentManager {
502
585
  private evictedNotes = new Map<string, EvictedNote>();
503
586
 
504
587
  /**
505
- * Agents waiting to start, tagged with the pool they wait on. One queue for
506
- * both pools: `drainQueue` picks the earliest entry whose own pool has room,
507
- * so neither can head-of-line-block the other, and every removal path
508
- * (`abort`, `abortAll`, `dispose`) stays a single filter.
588
+ * Agents waiting to start, tagged with the pool they wait on (none for a
589
+ * start that only waits on the process budget). Each entry is also a waiter
590
+ * in the process-wide budget queue, which is what actually starts it: the
591
+ * budget picks the earliest entry, across every manager, whose depth fits
592
+ * and whose own pool has room, so neither pool can head-of-line-block the
593
+ * other, and every removal path (`abort`, `abortAll`, `dispose`) stays a
594
+ * single filter.
509
595
  *
510
596
  * `release` wakes a caller blocked in `spawnAndWait`, and is fired once the
511
597
  * entry's `start` has SETTLED rather than at drain time: startup is async
@@ -515,7 +601,7 @@ export class AgentManager {
515
601
  * promise to await, and pi has no tool-execution timeout to bail the caller
516
602
  * out.
517
603
  */
518
- private queue: { id: string; pool: Pool; start: () => Promise<void>; release: () => void }[] = [];
604
+ private queue: QueueEntry[] = [];
519
605
  /** Number of currently running background agents. */
520
606
  private runningBackground = 0;
521
607
  /** Number of currently running foreground (blocking) agents. */
@@ -529,11 +615,12 @@ export class AgentManager {
529
615
  onUsage?: OnAgentUsage,
530
616
  onSteered?: OnAgentSteered,
531
617
  private readonly clock: Clock = realClock,
618
+ private readonly homeDir?: string,
532
619
  ) {
533
620
  this.onComplete = onComplete;
534
621
  this.onStart = onStart;
535
- this.onCompact = onCompact;
536
- this.onUsage = onUsage;
622
+ this.onCompact = guardObserver("compaction", onCompact);
623
+ this.onUsage = guardObserver("usage", onUsage);
537
624
  this.onSteered = onSteered;
538
625
  this.maxConcurrent = maxConcurrent;
539
626
  // Sweep finished records past their retention window (see cleanup()).
@@ -613,6 +700,19 @@ export class AgentManager {
613
700
  // call, not minutes later at drain. Throw (not warn): programmatic callers
614
701
  // can fix and retry; the RPC layer converts throws into error envelopes.
615
702
  assertValidSpawnCwd(options.cwd);
703
+ const selection = options.modelSelection ?? selectModel({
704
+ homeDir: options.homeDir ?? this.homeDir,
705
+ agentType: type,
706
+ definitionModel: getAgentConfig(type)?.model,
707
+ paramModel: options.model,
708
+ parentModel: ctx.model,
709
+ registry: ctx.modelRegistry,
710
+ cwd: options.configCwd ?? ctx.cwd,
711
+ agentLabel: getAgentConfig(type)?.displayName,
712
+ });
713
+ if (typeof selection === "string") throw new Error(selection);
714
+ if (selection.scopeWarning && !options.modelSelection) ctx.ui.notify(selection.scopeWarning, "warning");
715
+ options = { ...options, model: selection.model, modelSelection: selection };
616
716
 
617
717
  const id = randomUUID().slice(0, 17);
618
718
  const abortController = new AbortController();
@@ -652,6 +752,11 @@ export class AgentManager {
652
752
  // `options` has stopped being the interesting object.
653
753
  blocking: options.blocking,
654
754
  invocation: options.invocation,
755
+ modelFallback: selection.fallback,
756
+ modelSelection: selection,
757
+ modelTier: selection.tier ? tierDisclosure(selection.tier) : undefined,
758
+ previousModelTier: options.previousModelTier,
759
+ previousModelFallback: options.previousModelFallback,
655
760
  depth: options.depth ?? 1,
656
761
  parentAgentId: options.parentAgentId,
657
762
  workflowId: options.workflowId,
@@ -669,8 +774,19 @@ export class AgentManager {
669
774
  const args: SpawnArgs = { pi, ctx, type, prompt, options };
670
775
 
671
776
  const pool = this.poolFor(record);
672
- if (pool !== undefined && !options.bypassQueue && !this.poolHasRoom(pool)) {
673
- // Queue it — started when a running agent in the same pool completes.
777
+ // `bypassQueue` skips the pool, never the process budget: that ceiling is
778
+ // the one guarantee every path shares.
779
+ const waitPool = options.bypassQueue ? undefined : pool;
780
+ let lease: BudgetLease | undefined;
781
+ try {
782
+ lease = this.admit(record, waitPool);
783
+ } catch (err) {
784
+ this.agents.delete(id);
785
+ throw err;
786
+ }
787
+ if (lease === undefined) {
788
+ // Queue it — started once its pool has room and the process budget a
789
+ // lease, whichever was missing.
674
790
  // Idempotent for background (already "queued"); the flip that matters is
675
791
  // a blocking foreground spawn, optimistically marked "running" above.
676
792
  record.status = "queued";
@@ -679,16 +795,15 @@ export class AgentManager {
679
795
  if (!this.armQueuedAbort(id, options.signal)) return id;
680
796
  let release!: () => void;
681
797
  record.startGate = new Promise<void>(resolve => { release = resolve; });
682
- this.queue.push({
683
- id,
684
- pool,
685
- start: () => this.launch(id, record, args, pool),
686
- release: () => release(),
687
- });
688
- options.onQueued?.(id, this.queue.filter(e => e.pool === pool).length - 1);
798
+ // Failure bookkeeping keys on whether an inline caller gets the throw,
799
+ // which is `blocking`, not on which pool (if any) it waited for.
800
+ const failAs: Pool = record.blocking ? "foreground" : "background";
801
+ this.enqueueStart(id, record, waitPool, () => this.launch(id, record, args, failAs), () => release());
802
+ options.onQueued?.(id, this.queue.filter(e => e.pool === waitPool).length - 1);
689
803
  return id;
690
804
  }
691
805
 
806
+ record.budgetLease = lease;
692
807
  this.launch(id, record, args, undefined);
693
808
  return id;
694
809
  }
@@ -740,6 +855,76 @@ export class AgentManager {
740
855
  record.detachParentAbort = undefined;
741
856
  }
742
857
 
858
+ /**
859
+ * The one admission check every start makes: a lease now when `pool` (if
860
+ * any) has room and the process budget admits `record`'s depth, else
861
+ * undefined and the caller queues. Throws, having changed nothing, when the
862
+ * start could never be admitted (deeper than the budget reserves for) or the
863
+ * budget's wait queue is full.
864
+ */
865
+ private admit(record: AgentRecord, pool: Pool | undefined): BudgetLease | undefined {
866
+ const depth = record.depth ?? 1;
867
+ assertWithinBudgetDepth(depth);
868
+ const lease = pool === undefined || this.poolHasRoom(pool) ? tryAcquireBudget(depth) : undefined;
869
+ if (lease === undefined && !budgetHasPendingRoom()) throw new Error(BUDGET_QUEUE_FULL_ERROR);
870
+ return lease;
871
+ }
872
+
873
+ /** Give back `record`'s budget lease, if it holds one. Callers drain afterwards. */
874
+ private releaseLease(record: AgentRecord): void {
875
+ releaseBudget(record.budgetLease);
876
+ record.budgetLease = undefined;
877
+ }
878
+
879
+ /**
880
+ * Park a start in `queue` and in the process budget. The budget calls
881
+ * `grant` once the record's depth fits and `pool` (if any) has room; `start`
882
+ * then runs and `release` fires when it settles. A record that stopped being
883
+ * "queued" without being dequeued hands the lease straight back.
884
+ */
885
+ private enqueueStart(
886
+ id: string,
887
+ record: AgentRecord,
888
+ pool: Pool | undefined,
889
+ start: () => Promise<void>,
890
+ release: () => void,
891
+ ): void {
892
+ let markLeft!: () => void;
893
+ const left = new Promise<void>(resolve => { markLeft = resolve; });
894
+ const entry: QueueEntry = {
895
+ id,
896
+ pool,
897
+ start,
898
+ release: () => {
899
+ release();
900
+ markLeft();
901
+ },
902
+ // enqueueBudget only registers; grant cannot run until the next pump,
903
+ // after this entry has been initialized and published in the queue.
904
+ cancel: enqueueBudget({
905
+ depth: record.depth ?? 1,
906
+ ready: () => pool === undefined || this.poolHasRoom(pool),
907
+ grant: (lease) => {
908
+ this.queue = this.queue.filter(e => e !== entry);
909
+ if (this.agents.get(id) !== record || record.status !== "queued") {
910
+ releaseBudget(lease);
911
+ entry.release();
912
+ return;
913
+ }
914
+ record.budgetLease = lease;
915
+ // Detached, and never rejects: a late failure (e.g. strict worktree
916
+ // isolation) lands on the record inside `launch`. The release waits
917
+ // for that startup to SETTLE: a release at grant time would wake a
918
+ // blocked `spawnAndWait` while `record.promise` was still undefined,
919
+ // and it would read a healthy agent as one that never ran.
920
+ void entry.start().then(() => entry.release(), () => entry.release());
921
+ },
922
+ }),
923
+ left,
924
+ };
925
+ this.queue.push(entry);
926
+ }
927
+
743
928
  /** Add `delta` to the running count of `pool`; no pool, no count. */
744
929
  private chargePool(pool: Pool | undefined, delta: 1 | -1): void {
745
930
  if (pool === "background") this.runningBackground += delta;
@@ -751,34 +936,39 @@ export class AgentManager {
751
936
  * promise never rejects — the failure is delivered through `awaitStartup`,
752
937
  * and to the record.
753
938
  *
754
- * @param queuedPool - The pool this start was QUEUED on, or undefined for an
755
- * immediate start. A queue drain can be minutes after `spawn()` returned,
756
- * and nobody is awaiting `awaitStartup` by then, so a failure has to live
757
- * on the record as status "error" — what drainQueue did when the throw was
758
- * still synchronous. An immediate start instead drops the record, exactly
759
- * as the throw out of `spawn()` did: no orphan in `listAgents()`, and the
760
- * handle goes back.
939
+ * @param failAs - For a start that QUEUED (on a pool or on the budget
940
+ * alone), how a startup failure is reported: "foreground" when an inline
941
+ * `spawnAndWait` caller gets it as a throw, else "background". Undefined
942
+ * for an immediate start. A queued start can begin minutes after `spawn()`
943
+ * returned, and nobody is awaiting `awaitStartup` by then, so its failure
944
+ * lives on the record as status "error". An immediate start instead drops
945
+ * the record, exactly as the throw out of `spawn()` did: no orphan in
946
+ * `listAgents()`, and the handle goes back.
761
947
  */
762
- private launch(id: string, record: AgentRecord, args: SpawnArgs, queuedPool: Pool | undefined): Promise<void> {
948
+ private launch(id: string, record: AgentRecord, args: SpawnArgs, failAs: Pool | undefined): Promise<void> {
763
949
  const startup = this.startAgent(id, record, args).then(
764
950
  () => { this.startups.delete(id); },
765
951
  (err) => {
766
952
  this.startups.delete(id);
767
- if (queuedPool !== undefined) {
768
- // Mirrors settleRun: an inline caller gets this failure as a throw
769
- // out of spawnAndWait, so an unconsumed record would ALSO nudge the
770
- // session about it — the same failure reported twice.
771
- if (queuedPool === "foreground") markConsumed(record, this.clock.now());
772
- record.status = "error";
773
- record.error = err instanceof Error ? err.message : String(err);
774
- record.completedAt = this.clock.now();
775
- this.onComplete?.(record);
776
- } else {
777
- this.agents.delete(id);
953
+ try {
954
+ if (failAs !== undefined) {
955
+ // Mirrors settleRun: an inline caller gets this failure as a throw
956
+ // out of spawnAndWait, so an unconsumed record would ALSO nudge the
957
+ // session about it — the same failure reported twice.
958
+ if (failAs === "foreground") markConsumed(record, this.clock.now());
959
+ record.status = "error";
960
+ record.error = err instanceof Error ? err.message : String(err);
961
+ record.completedAt = this.clock.now();
962
+ try { this.onComplete?.(record); } catch { /* the startup failure is the error worth reporting */ }
963
+ } else {
964
+ this.agents.delete(id);
965
+ }
966
+ } finally {
967
+ // The agent never kept its slot (startAgent gives it back on failure)
968
+ // or its lease, so anything queued behind it can go now.
969
+ this.releaseLease(record);
970
+ this.drainQueue();
778
971
  }
779
- // The agent never kept its slot (startAgent gives it back on failure),
780
- // so anything queued behind it can go now.
781
- this.drainQueue();
782
972
  throw err;
783
973
  },
784
974
  );
@@ -838,6 +1028,24 @@ export class AgentManager {
838
1028
  record.startGate = undefined;
839
1029
  this.chargePool(record.chargedPool, 1);
840
1030
 
1031
+ // Hold the pool slot BEFORE awaiting admission, just as for worktree creation.
1032
+ try {
1033
+ if (options.modelSelection?.tierBinding && !options.modelSelection.tierAdmitted) {
1034
+ const block = await admitModelSelection(options.modelSelection, ctx.modelRegistry, pi.events);
1035
+ record.modelTier = options.modelSelection.tier ? tierDisclosure(options.modelSelection.tier) : undefined;
1036
+ record.modelFallback = options.modelSelection.fallback;
1037
+ if (block) throw new Error(`${modelSelectionNote(record)} ${describeBlock(block)} The agent was not started.`);
1038
+ if (record.abortController?.signal.aborted || options.signal?.aborted) throw new Error("Agent aborted during model admission.");
1039
+ }
1040
+ if (record.modelTier) {
1041
+ record.invocation ??= {};
1042
+ Object.assign(record.invocation, describeSelection(options.modelSelection, ctx.modelRegistry));
1043
+ }
1044
+ } catch (error) {
1045
+ this.chargePool(record.chargedPool, -1);
1046
+ throw error;
1047
+ }
1048
+
841
1049
  // Worktree isolation: try to create a temporary git worktree. Strict —
842
1050
  // fail loud if not possible (no silent fallback to main tree). Done BEFORE
843
1051
  // the run is kicked off so a failure doesn't leave a half-running agent.
@@ -887,12 +1095,27 @@ export class AgentManager {
887
1095
  if (record.status !== "running") {
888
1096
  this.chargePool(record.chargedPool, -1);
889
1097
  record.worktreeResult = await cleanupWorktree(pi, baseCwd, wt, options.description);
1098
+ // After the cleanup, not before: the copy is still being removed until here.
1099
+ this.releaseLease(record);
890
1100
  this.drainQueue();
891
1101
  return;
892
1102
  }
893
1103
  }
894
1104
 
895
- this.onStart?.(record);
1105
+ // A throwing `onStart` fails the startup like a worktree failure: hand the
1106
+ // slot back and remove the copy, since nothing will ever settle this run.
1107
+ // The lease goes back in `launch`, after the cleanup.
1108
+ try {
1109
+ this.onStart?.(record);
1110
+ } catch (err) {
1111
+ this.chargePool(record.chargedPool, -1);
1112
+ if (record.worktree) {
1113
+ try {
1114
+ record.worktreeResult = await cleanupWorktree(pi, baseCwd, record.worktree, options.description);
1115
+ } catch { /* ignore cleanup errors */ }
1116
+ }
1117
+ throw err;
1118
+ }
896
1119
 
897
1120
  // Wire parent abort signal to stop the subagent when the parent is interrupted.
898
1121
  // A queued spawn can start minutes after the caller handed us its signal,
@@ -904,8 +1127,10 @@ export class AgentManager {
904
1127
 
905
1128
  const promise = runAgent(ctx, type, prompt, {
906
1129
  pi,
1130
+ homeDir: options.homeDir ?? this.homeDir,
907
1131
  agentId: id,
908
- model: options.model,
1132
+ model: options.modelSelection?.model,
1133
+ modelSelection: record.modelSelection!,
909
1134
  maxTurns: options.maxTurns,
910
1135
  isolated: options.isolated,
911
1136
  inheritContext: options.inheritContext,
@@ -973,7 +1198,9 @@ export class AgentManager {
973
1198
  const requested = record.invocation.requestedThinking ?? record.invocation.thinking;
974
1199
  Object.assign(
975
1200
  record.invocation,
976
- isVirtualModel(session.model) ? describeVirtualModel(session.model) : describeModel(session.model),
1201
+ record.modelTier?.kind === "routed" && record.modelTier.admitted
1202
+ ? describeSelection(options.modelSelection, ctx.modelRegistry)
1203
+ : isVirtualModel(session.model) ? describeVirtualModel(session.model) : describeModel(session.model),
977
1204
  );
978
1205
  // Guarded for the reason above: a session that reports no level keeps
979
1206
  // the request rather than losing it. Overwriting unconditionally would
@@ -986,6 +1213,9 @@ export class AgentManager {
986
1213
  }
987
1214
  }
988
1215
  }
1216
+ // The transcript says it too: it is what someone opens to find out what ran.
1217
+ if (record.modelFallback && record.outputFile) appendModelFallbackEntry(record.outputFile, record.id, record.modelFallback);
1218
+ if (record.modelTier && record.outputFile) appendModelTierEntry(record.outputFile, record.id, record.modelTier);
989
1219
  // Flush any steers that arrived before the session was ready
990
1220
  if (record.pendingSteers?.length) {
991
1221
  for (const msg of record.pendingSteers) {
@@ -1111,20 +1341,28 @@ export class AgentManager {
1111
1341
  record.blocking = false;
1112
1342
  const pool = record.chargedPool;
1113
1343
  this.chargePool(pool, -1);
1114
-
1115
- if (guardCallback) {
1116
- try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
1117
- } else {
1118
- this.onComplete?.(record);
1119
- }
1120
-
1121
1344
  // The isBackground half reproduces the pre-pool condition exactly — a
1122
1345
  // background settle has always drained, even for a nested child that held
1123
1346
  // no slot — so that path is unchanged whether or not the foreground pool is
1124
1347
  // on. The `pool` half only adds the drain a freed FOREGROUND slot needs.
1125
1348
  // A drain with nothing freed is a no-op anyway, but "no-op" is a claim
1126
1349
  // about reachability, and matching the old condition needs no such claim.
1127
- if (record.isBackground || pool !== undefined) this.drainQueue();
1350
+ // The lease half: a freed budget slot may start work in any manager.
1351
+ const drain = record.isBackground || pool !== undefined || record.budgetLease !== undefined;
1352
+ this.releaseLease(record);
1353
+
1354
+ try {
1355
+ if (guardCallback) {
1356
+ try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
1357
+ } else {
1358
+ this.onComplete?.(record);
1359
+ }
1360
+ } finally {
1361
+ // Drained even when an unguarded `onComplete` throws: the slot and lease
1362
+ // are already free, and a waiter that misses this drain may be the child
1363
+ // a running parent is blocked on, with nothing else left to pump.
1364
+ if (drain) this.drainQueue();
1365
+ }
1128
1366
  }
1129
1367
 
1130
1368
  /**
@@ -1140,32 +1378,15 @@ export class AgentManager {
1140
1378
  }
1141
1379
 
1142
1380
  /**
1143
- * Start queued agents up to each pool's concurrency limit.
1144
- *
1145
- * `findIndex` on the entry's OWN pool rather than `shift`: with one queue
1146
- * serving two independent limits, a saturated foreground pool at the head
1147
- * would otherwise stall every background agent behind it. Taking the earliest
1148
- * eligible entry keeps FIFO within each pool, which is what callers see.
1381
+ * Start queued agents up to each pool's concurrency limit and the process
1382
+ * budget. The budget owns the order (`pumpBudget`): the earliest waiter, in
1383
+ * any manager, whose depth fits and whose own pool has room. Eligibility per
1384
+ * entry rather than `shift`: with one queue serving several limits, a
1385
+ * saturated pool at the head would otherwise stall everything behind it.
1386
+ * Taking the earliest eligible entry keeps FIFO within each pool.
1149
1387
  */
1150
1388
  private drainQueue() {
1151
- for (;;) {
1152
- const i = this.queue.findIndex(e => this.poolHasRoom(e.pool));
1153
- if (i === -1) return;
1154
- const [next] = this.queue.splice(i, 1);
1155
- const record = this.agents.get(next.id);
1156
- // Stale entries (aborted while queued) are not started — but are still
1157
- // released, since nothing else will.
1158
- if (!record || record.status !== "queued") { next.release(); continue; }
1159
- // Detached, and never rejects: a late failure (e.g. strict worktree
1160
- // isolation) lands on the record inside `launch`, exactly as the
1161
- // synchronous throw did here before, and draining continues either way.
1162
- //
1163
- // The release waits for that startup to SETTLE rather than firing here.
1164
- // Startup is async now, so a release at drain time would wake a blocked
1165
- // `spawnAndWait` while `record.promise` was still undefined, and it would
1166
- // read a perfectly healthy agent as one that never ran.
1167
- void next.start().then(() => next.release(), () => next.release());
1168
- }
1389
+ pumpBudget();
1169
1390
  }
1170
1391
 
1171
1392
  /**
@@ -1173,13 +1394,15 @@ export class AgentManager {
1173
1394
  * that enforces "leaving the queue releases the waiter" — a missed release is
1174
1395
  * an unbounded hang, not a failed call.
1175
1396
  */
1176
- private dequeue(pred: (entry: { id: string; pool: Pool }) => boolean): void {
1397
+ private dequeue(pred: (entry: QueueEntry) => boolean): void {
1177
1398
  const kept: typeof this.queue = [];
1178
- for (const entry of this.queue) {
1179
- if (pred(entry)) entry.release();
1180
- else kept.push(entry);
1181
- }
1399
+ const removed: QueueEntry[] = [];
1400
+ for (const entry of this.queue) (pred(entry) ? removed : kept).push(entry);
1182
1401
  this.queue = kept;
1402
+ for (const entry of removed) {
1403
+ entry.cancel();
1404
+ entry.release();
1405
+ }
1183
1406
  }
1184
1407
 
1185
1408
  /**
@@ -1292,6 +1515,19 @@ export class AgentManager {
1292
1515
  const record = this.agents.get(id);
1293
1516
  if (!record?.session) return undefined;
1294
1517
 
1518
+ // Never re-enter a run that is still in flight, on either path. Starting a
1519
+ // second run would overwrite record.abortController — orphaning the live
1520
+ // run beyond the reach of `/agents` stop and abortAll() — take a second
1521
+ // pool slot and budget lease, and then reject from session.prompt() with
1522
+ // "Agent is already processing", whose settle path would abort the LIVE
1523
+ // run's children and report a failure for a run that is still going. A
1524
+ // detached resume makes this easy to hit (the caller gets control back
1525
+ // while the record stays "running"), and parallel tool calls make it
1526
+ // possible inline. Checked before anything below mutates the record, and
1527
+ // both paths flip the status synchronously, so a second call made in the
1528
+ // same tick is refused too. The caller decides whether to wait or steer.
1529
+ if (record.status === "running" || record.status === "queued") return undefined;
1530
+
1295
1531
  // Background resume: settle asynchronously and notify on completion exactly
1296
1532
  // like a background spawn, returning immediately with the record still
1297
1533
  // "running" — or "queued" when at the concurrency limit. Previously
@@ -1299,16 +1535,10 @@ export class AgentManager {
1299
1535
  // returned before its background branch, and resume() only ever awaited
1300
1536
  // inline), so a resumed agent always blocked the caller until it finished.
1301
1537
  if (options?.isBackground) {
1302
- // Never re-enter a run that is still in flight. Detaching means the caller
1303
- // gets control back while the record stays "running", so nothing stops the
1304
- // model from resuming the same agent again. Starting a second run would
1305
- // overwrite record.abortController — orphaning the live run beyond the
1306
- // reach of `/agents` stop and abortAll() — double-count the pool slot, and
1307
- // then reject from session.prompt() with "Agent is already processing",
1308
- // whose settle path would abort the LIVE run's children and report a
1309
- // failure for a run that is still going. Refuse instead, leaving the
1310
- // record untouched; the caller decides whether to wait or steer.
1311
- if (record.status === "running" || record.status === "queued") return undefined;
1538
+ // The pool it will be charged to once `isBackground` is set below; asked
1539
+ // before anything mutates the record, so a refusal leaves it untouched.
1540
+ const pool: Pool | undefined = occupiesPoolSlot({ ...record, isBackground: true }) ? "background" : undefined;
1541
+ const lease = this.admit(record, pool);
1312
1542
 
1313
1543
  record.isBackground = true;
1314
1544
  record.resultConsumed = false;
@@ -1318,36 +1548,49 @@ export class AgentManager {
1318
1548
  record.completedAt = undefined;
1319
1549
  record.status = "queued";
1320
1550
 
1321
- const start = () => this.startResume(id, record, prompt, signal, options);
1322
- if (occupiesPoolSlot(record) && !this.poolHasRoom("background")) {
1323
- // At the concurrency limit — queue it, drains when a slot frees. A
1551
+ // `startResume` throws only from the `onStart` callback, after it has
1552
+ // charged the pool and with the lease held: hand both back and leave the
1553
+ // record an error rather than "running" forever.
1554
+ const start = () => {
1555
+ try {
1556
+ this.startResume(id, record, prompt, signal, options);
1557
+ } catch (err) {
1558
+ if (occupiesPoolSlot(record)) this.runningBackground--;
1559
+ this.releaseLease(record);
1560
+ record.status = "error";
1561
+ record.error = err instanceof Error ? err.message : String(err);
1562
+ record.completedAt = this.clock.now();
1563
+ this.drainQueue();
1564
+ throw err;
1565
+ }
1566
+ };
1567
+ if (lease === undefined) {
1568
+ // At a limit — queue it, started when a slot and a lease free up. A
1324
1569
  // detached resume has no inline caller, hence nothing to release. The
1325
1570
  // queue is shared with spawns, whose startup is async, so entries are
1326
- // promise-shaped even though a resume starts synchronously; failures
1327
- // land on the record here, since drainQueue no longer catches.
1328
- this.queue.push({
1329
- id,
1330
- pool: "background",
1331
- start: async () => {
1332
- try {
1333
- start();
1334
- } catch (err) {
1335
- record.status = "error";
1336
- record.error = err instanceof Error ? err.message : String(err);
1337
- record.completedAt = this.clock.now();
1338
- this.onComplete?.(record);
1339
- }
1340
- },
1341
- release: () => {},
1342
- });
1571
+ // promise-shaped even though a resume starts synchronously; a failure
1572
+ // is reported here, since the budget pump swallows throws.
1573
+ this.enqueueStart(id, record, pool, async () => {
1574
+ try {
1575
+ start();
1576
+ } catch {
1577
+ this.onComplete?.(record);
1578
+ }
1579
+ }, () => {});
1343
1580
  } else {
1581
+ record.budgetLease = lease;
1344
1582
  start();
1345
1583
  }
1346
1584
  return record;
1347
1585
  }
1348
1586
 
1349
- // Foreground resume: run inline and return the settled record.
1350
- record.status = "running";
1587
+ // Foreground resume: run inline and return the settled record. Takes a
1588
+ // budget lease like everything else, waiting for one if the process is at
1589
+ // its limit; the caller's signal (or `/agents` stop) releases the wait.
1590
+ // No pool: the foreground pool does not cover resumes (see the README).
1591
+ const lease = this.admit(record, undefined);
1592
+ record.status = lease === undefined ? "queued" : "running";
1593
+ record.budgetLease = lease;
1351
1594
  record.startedAt = this.clock.now();
1352
1595
  record.completedAt = undefined;
1353
1596
  record.result = undefined;
@@ -1361,9 +1604,38 @@ export class AgentManager {
1361
1604
  this.armParentAbort(record, signal);
1362
1605
  // `addEventListener` never fires on a signal that is already aborted, and
1363
1606
  // the runner skips the prompt only when ITS signal is — stop now, so that
1364
- // holds for a resume issued on a call that was cancelled first.
1607
+ // holds for a resume issued on a call that was cancelled first. On a
1608
+ // record still waiting for a lease this stops it outright.
1365
1609
  if (signal?.aborted) this.abort(id);
1366
1610
 
1611
+ if (lease === undefined) {
1612
+ // The queue entry's `start` only marks the record running: the run itself
1613
+ // stays inline below, so the caller awaits the same `resumeAgent` call
1614
+ // as an unqueued resume, and the lease the grant handed over is released
1615
+ // by the same tail. `granted` tells a grant from a removal (abort,
1616
+ // dispose), which resolves the same promise.
1617
+ let granted = false;
1618
+ if (record.status === "queued") {
1619
+ await new Promise<void>(resolve => {
1620
+ this.enqueueStart(id, record, undefined, async () => {
1621
+ granted = true;
1622
+ record.status = "running";
1623
+ record.startedAt = this.clock.now();
1624
+ }, resolve);
1625
+ });
1626
+ }
1627
+ // Removed while it waited: it never ran, so there is nothing to settle.
1628
+ // `abort` already marked it stopped; `dispose` and `detach` did not.
1629
+ if (!granted) {
1630
+ if (record.status === "queued") {
1631
+ record.status = "stopped";
1632
+ record.completedAt = this.clock.now();
1633
+ }
1634
+ this.detachParentAbort(record);
1635
+ return record;
1636
+ }
1637
+ }
1638
+
1367
1639
  try {
1368
1640
  this.settleOutcome(record, await resumeAgent(record.session, prompt, {
1369
1641
  type: record.type,
@@ -1397,6 +1669,8 @@ export class AgentManager {
1397
1669
  // resumed turn must not outlive it — nothing else can see or reach them.
1398
1670
  this.abortOwnedChildren(id);
1399
1671
 
1672
+ this.releaseLease(record);
1673
+ this.drainQueue();
1400
1674
  return record;
1401
1675
  }
1402
1676
 
@@ -1449,7 +1723,11 @@ export class AgentManager {
1449
1723
  parentSignal: AbortSignal | undefined,
1450
1724
  options: ResumeOptions,
1451
1725
  ) {
1452
- if (!record.session) return;
1726
+ if (!record.session) {
1727
+ this.releaseLease(record);
1728
+ this.drainQueue();
1729
+ return;
1730
+ }
1453
1731
 
1454
1732
  record.status = "running";
1455
1733
  record.startedAt = this.clock.now();
@@ -1486,6 +1764,7 @@ export class AgentManager {
1486
1764
  // Children spawned during the resumed turn must not outlive it.
1487
1765
  this.abortOwnedChildren(id);
1488
1766
  if (occupiesPoolSlot(record)) this.runningBackground--;
1767
+ this.releaseLease(record);
1489
1768
  try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
1490
1769
  this.drainQueue();
1491
1770
  };
@@ -1682,6 +1961,8 @@ export class AgentManager {
1682
1961
  description: record.description,
1683
1962
  sessionFile: record.sessionFile,
1684
1963
  completedAt: record.completedAt ?? this.clock.now(),
1964
+ ...(record.modelTier ? { modelTier: record.modelTier } : {}),
1965
+ ...(record.modelFallback ? { modelFallback: record.modelFallback } : {}),
1685
1966
  });
1686
1967
  // Bound the memory a long session can accumulate. Oldest first, since the
1687
1968
  // agent someone still wants to reach is the one they used most recently.
@@ -1702,6 +1983,8 @@ export class AgentManager {
1702
1983
  status: record.status,
1703
1984
  evictedAfterMin: Math.max(0, Math.round((this.clock.now() - completedAt) / 60_000)),
1704
1985
  transcript: record.outputFile ?? record.sessionFile,
1986
+ ...(record.modelTier ? { modelTier: record.modelTier } : {}),
1987
+ ...(record.modelFallback ? { modelFallback: record.modelFallback } : {}),
1705
1988
  });
1706
1989
  while (this.evictedNotes.size > MAX_EVICTED_NOTES) {
1707
1990
  const oldest = this.evictedNotes.keys().next().value;
@@ -1769,19 +2052,26 @@ export class AgentManager {
1769
2052
  * they own their own session and report through a notification. Queued agents
1770
2053
  * would start later against a stale context, foreground agents belong to a
1771
2054
  * tool call that is being torn down, and workflow children die with their
1772
- * workflow, so all of those are aborted.
2055
+ * workflow, so all of those are aborted. A QUEUED nested child is kept: its
2056
+ * parent is a survivor waiting on it (the process budget can queue one), and
2057
+ * if the parent is stopped instead, its settle aborts the child.
1773
2058
  *
1774
2059
  * @returns how many agents keep running.
1775
2060
  */
1776
2061
  detach(): number {
2062
+ const keepQueued = (id: string): boolean => {
2063
+ const record = this.agents.get(id);
2064
+ return record !== undefined && record.parentAgentId !== undefined && record.workflowId === undefined;
2065
+ };
1777
2066
  for (const queued of this.queue) {
2067
+ if (keepQueued(queued.id)) continue;
1778
2068
  const record = this.agents.get(queued.id);
1779
2069
  if (record) {
1780
2070
  record.status = "stopped";
1781
2071
  record.completedAt = this.clock.now();
1782
2072
  }
1783
2073
  }
1784
- this.dequeue(() => true);
2074
+ this.dequeue(entry => !keepQueued(entry.id));
1785
2075
  let survivors = 0;
1786
2076
  for (const record of this.agents.values()) {
1787
2077
  if (record.status !== "running") continue;
@@ -1837,6 +2127,9 @@ export class AgentManager {
1837
2127
  if (startup) pending.push(startup);
1838
2128
  if (record.promise) pending.push(record.promise);
1839
2129
  }
2130
+ // A queued start may be waiting on another manager's leases, with
2131
+ // nothing in this one to wait on: wait for it to leave the queue.
2132
+ for (const entry of this.queue) pending.push(entry.left);
1840
2133
  if (pending.length === 0) break;
1841
2134
  await Promise.allSettled(pending);
1842
2135
  }
@@ -1870,5 +2163,12 @@ export class AgentManager {
1870
2163
  // handler and the process exits right after it returns, so anything left unawaited
1871
2164
  // here never runs at all. Bounded — each call carries its own ceiling, concurrently.
1872
2165
  await Promise.all(sessions.map(session => shutdownChildSession(session)));
2166
+ // Leases are NOT released here. `shutdownChildSession` is bounded and does
2167
+ // not wait for a run's promise, a worktree copy or its cleanup, so work can
2168
+ // still be going on; each run hands its lease back from its own settle
2169
+ // path (`settleRun`, the resume tails, the startup-failure and mid-copy
2170
+ // exits), which no longer needs this manager's maps. A run that never
2171
+ // settles keeps its slot, which is the honest count.
2172
+ this.drainQueue();
1873
2173
  }
1874
2174
  }