@nredd/pi-subagents 0.22.0-nredd.5 → 0.22.0-nredd.7

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,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";
@@ -25,6 +31,16 @@ 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
33
  import { describeModel, describePhysicalModel, describeVirtualModel, isVirtualModel } from "./model-resolver.js";
34
+ import {
35
+ assertWithinBudgetDepth,
36
+ BUDGET_QUEUE_FULL_ERROR,
37
+ type BudgetLease,
38
+ budgetHasPendingRoom,
39
+ enqueueBudget,
40
+ pumpBudget,
41
+ releaseBudget,
42
+ tryAcquireBudget,
43
+ } from "./process-budget.js";
28
44
  import type { AgentInvocation, AgentRecord, AgentTombstone, IsolationMode, MentionResolution, Pool, SubagentType, ThinkingLevel } from "./types.js";
29
45
  import { addUsage, type LifetimeUsage } from "./usage.js";
30
46
  import type { CompiledSchema } from "./workflow/json-schema.js";
@@ -244,6 +260,19 @@ function occupiesForegroundSlot(
244
260
  return !!record.blocking && isTopLevelAgent(record);
245
261
  }
246
262
 
263
+ /** A start waiting in `AgentManager.queue`; see there. */
264
+ interface QueueEntry {
265
+ id: string;
266
+ /** The pool it needs room in, or undefined when it waits on the budget alone. */
267
+ pool?: Pool;
268
+ start: () => Promise<void>;
269
+ release: () => void;
270
+ /** Withdraw the budget waiter. A no-op once granted. */
271
+ cancel: () => void;
272
+ /** Resolves once the entry has left the queue, started (and settled its startup) or not. */
273
+ left: Promise<void>;
274
+ }
275
+
247
276
  interface SpawnArgs {
248
277
  pi: ExtensionAPI;
249
278
  ctx: ExtensionContext;
@@ -502,10 +531,13 @@ export class AgentManager {
502
531
  private evictedNotes = new Map<string, EvictedNote>();
503
532
 
504
533
  /**
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.
534
+ * Agents waiting to start, tagged with the pool they wait on (none for a
535
+ * start that only waits on the process budget). Each entry is also a waiter
536
+ * in the process-wide budget queue, which is what actually starts it: the
537
+ * budget picks the earliest entry, across every manager, whose depth fits
538
+ * and whose own pool has room, so neither pool can head-of-line-block the
539
+ * other, and every removal path (`abort`, `abortAll`, `dispose`) stays a
540
+ * single filter.
509
541
  *
510
542
  * `release` wakes a caller blocked in `spawnAndWait`, and is fired once the
511
543
  * entry's `start` has SETTLED rather than at drain time: startup is async
@@ -515,7 +547,7 @@ export class AgentManager {
515
547
  * promise to await, and pi has no tool-execution timeout to bail the caller
516
548
  * out.
517
549
  */
518
- private queue: { id: string; pool: Pool; start: () => Promise<void>; release: () => void }[] = [];
550
+ private queue: QueueEntry[] = [];
519
551
  /** Number of currently running background agents. */
520
552
  private runningBackground = 0;
521
553
  /** Number of currently running foreground (blocking) agents. */
@@ -669,8 +701,19 @@ export class AgentManager {
669
701
  const args: SpawnArgs = { pi, ctx, type, prompt, options };
670
702
 
671
703
  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.
704
+ // `bypassQueue` skips the pool, never the process budget: that ceiling is
705
+ // the one guarantee every path shares.
706
+ const waitPool = options.bypassQueue ? undefined : pool;
707
+ let lease: BudgetLease | undefined;
708
+ try {
709
+ lease = this.admit(record, waitPool);
710
+ } catch (err) {
711
+ this.agents.delete(id);
712
+ throw err;
713
+ }
714
+ if (lease === undefined) {
715
+ // Queue it — started once its pool has room and the process budget a
716
+ // lease, whichever was missing.
674
717
  // Idempotent for background (already "queued"); the flip that matters is
675
718
  // a blocking foreground spawn, optimistically marked "running" above.
676
719
  record.status = "queued";
@@ -679,16 +722,15 @@ export class AgentManager {
679
722
  if (!this.armQueuedAbort(id, options.signal)) return id;
680
723
  let release!: () => void;
681
724
  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);
725
+ // Failure bookkeeping keys on whether an inline caller gets the throw,
726
+ // which is `blocking`, not on which pool (if any) it waited for.
727
+ const failAs: Pool = record.blocking ? "foreground" : "background";
728
+ this.enqueueStart(id, record, waitPool, () => this.launch(id, record, args, failAs), () => release());
729
+ options.onQueued?.(id, this.queue.filter(e => e.pool === waitPool).length - 1);
689
730
  return id;
690
731
  }
691
732
 
733
+ record.budgetLease = lease;
692
734
  this.launch(id, record, args, undefined);
693
735
  return id;
694
736
  }
@@ -740,6 +782,75 @@ export class AgentManager {
740
782
  record.detachParentAbort = undefined;
741
783
  }
742
784
 
785
+ /**
786
+ * The one admission check every start makes: a lease now when `pool` (if
787
+ * any) has room and the process budget admits `record`'s depth, else
788
+ * undefined and the caller queues. Throws, having changed nothing, when the
789
+ * start could never be admitted (deeper than the budget reserves for) or the
790
+ * budget's wait queue is full.
791
+ */
792
+ private admit(record: AgentRecord, pool: Pool | undefined): BudgetLease | undefined {
793
+ const depth = record.depth ?? 1;
794
+ assertWithinBudgetDepth(depth);
795
+ const lease = pool === undefined || this.poolHasRoom(pool) ? tryAcquireBudget(depth) : undefined;
796
+ if (lease === undefined && !budgetHasPendingRoom()) throw new Error(BUDGET_QUEUE_FULL_ERROR);
797
+ return lease;
798
+ }
799
+
800
+ /** Give back `record`'s budget lease, if it holds one. Callers drain afterwards. */
801
+ private releaseLease(record: AgentRecord): void {
802
+ releaseBudget(record.budgetLease);
803
+ record.budgetLease = undefined;
804
+ }
805
+
806
+ /**
807
+ * Park a start in `queue` and in the process budget. The budget calls
808
+ * `grant` once the record's depth fits and `pool` (if any) has room; `start`
809
+ * then runs and `release` fires when it settles. A record that stopped being
810
+ * "queued" without being dequeued hands the lease straight back.
811
+ */
812
+ private enqueueStart(
813
+ id: string,
814
+ record: AgentRecord,
815
+ pool: Pool | undefined,
816
+ start: () => Promise<void>,
817
+ release: () => void,
818
+ ): void {
819
+ let markLeft!: () => void;
820
+ const left = new Promise<void>(resolve => { markLeft = resolve; });
821
+ const entry: QueueEntry = {
822
+ id,
823
+ pool,
824
+ start,
825
+ release: () => {
826
+ release();
827
+ markLeft();
828
+ },
829
+ cancel: () => {},
830
+ left,
831
+ };
832
+ entry.cancel = enqueueBudget({
833
+ depth: record.depth ?? 1,
834
+ ready: () => pool === undefined || this.poolHasRoom(pool),
835
+ grant: (lease) => {
836
+ this.queue = this.queue.filter(e => e !== entry);
837
+ if (this.agents.get(id) !== record || record.status !== "queued") {
838
+ releaseBudget(lease);
839
+ entry.release();
840
+ return;
841
+ }
842
+ record.budgetLease = lease;
843
+ // Detached, and never rejects: a late failure (e.g. strict worktree
844
+ // isolation) lands on the record inside `launch`. The release waits
845
+ // for that startup to SETTLE: a release at grant time would wake a
846
+ // blocked `spawnAndWait` while `record.promise` was still undefined,
847
+ // and it would read a healthy agent as one that never ran.
848
+ void entry.start().then(() => entry.release(), () => entry.release());
849
+ },
850
+ });
851
+ this.queue.push(entry);
852
+ }
853
+
743
854
  /** Add `delta` to the running count of `pool`; no pool, no count. */
744
855
  private chargePool(pool: Pool | undefined, delta: 1 | -1): void {
745
856
  if (pool === "background") this.runningBackground += delta;
@@ -751,34 +862,39 @@ export class AgentManager {
751
862
  * promise never rejects — the failure is delivered through `awaitStartup`,
752
863
  * and to the record.
753
864
  *
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.
865
+ * @param failAs - For a start that QUEUED (on a pool or on the budget
866
+ * alone), how a startup failure is reported: "foreground" when an inline
867
+ * `spawnAndWait` caller gets it as a throw, else "background". Undefined
868
+ * for an immediate start. A queued start can begin minutes after `spawn()`
869
+ * returned, and nobody is awaiting `awaitStartup` by then, so its failure
870
+ * lives on the record as status "error". An immediate start instead drops
871
+ * the record, exactly as the throw out of `spawn()` did: no orphan in
872
+ * `listAgents()`, and the handle goes back.
761
873
  */
762
- private launch(id: string, record: AgentRecord, args: SpawnArgs, queuedPool: Pool | undefined): Promise<void> {
874
+ private launch(id: string, record: AgentRecord, args: SpawnArgs, failAs: Pool | undefined): Promise<void> {
763
875
  const startup = this.startAgent(id, record, args).then(
764
876
  () => { this.startups.delete(id); },
765
877
  (err) => {
766
878
  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);
879
+ try {
880
+ if (failAs !== undefined) {
881
+ // Mirrors settleRun: an inline caller gets this failure as a throw
882
+ // out of spawnAndWait, so an unconsumed record would ALSO nudge the
883
+ // session about it — the same failure reported twice.
884
+ if (failAs === "foreground") markConsumed(record, this.clock.now());
885
+ record.status = "error";
886
+ record.error = err instanceof Error ? err.message : String(err);
887
+ record.completedAt = this.clock.now();
888
+ try { this.onComplete?.(record); } catch { /* the startup failure is the error worth reporting */ }
889
+ } else {
890
+ this.agents.delete(id);
891
+ }
892
+ } finally {
893
+ // The agent never kept its slot (startAgent gives it back on failure)
894
+ // or its lease, so anything queued behind it can go now.
895
+ this.releaseLease(record);
896
+ this.drainQueue();
778
897
  }
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
898
  throw err;
783
899
  },
784
900
  );
@@ -887,12 +1003,27 @@ export class AgentManager {
887
1003
  if (record.status !== "running") {
888
1004
  this.chargePool(record.chargedPool, -1);
889
1005
  record.worktreeResult = await cleanupWorktree(pi, baseCwd, wt, options.description);
1006
+ // After the cleanup, not before: the copy is still being removed until here.
1007
+ this.releaseLease(record);
890
1008
  this.drainQueue();
891
1009
  return;
892
1010
  }
893
1011
  }
894
1012
 
895
- this.onStart?.(record);
1013
+ // A throwing `onStart` fails the startup like a worktree failure: hand the
1014
+ // slot back and remove the copy, since nothing will ever settle this run.
1015
+ // The lease goes back in `launch`, after the cleanup.
1016
+ try {
1017
+ this.onStart?.(record);
1018
+ } catch (err) {
1019
+ this.chargePool(record.chargedPool, -1);
1020
+ if (record.worktree) {
1021
+ try {
1022
+ record.worktreeResult = await cleanupWorktree(pi, baseCwd, record.worktree, options.description);
1023
+ } catch { /* ignore cleanup errors */ }
1024
+ }
1025
+ throw err;
1026
+ }
896
1027
 
897
1028
  // Wire parent abort signal to stop the subagent when the parent is interrupted.
898
1029
  // A queued spawn can start minutes after the caller handed us its signal,
@@ -1111,20 +1242,28 @@ export class AgentManager {
1111
1242
  record.blocking = false;
1112
1243
  const pool = record.chargedPool;
1113
1244
  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
1245
  // The isBackground half reproduces the pre-pool condition exactly — a
1122
1246
  // background settle has always drained, even for a nested child that held
1123
1247
  // no slot — so that path is unchanged whether or not the foreground pool is
1124
1248
  // on. The `pool` half only adds the drain a freed FOREGROUND slot needs.
1125
1249
  // A drain with nothing freed is a no-op anyway, but "no-op" is a claim
1126
1250
  // about reachability, and matching the old condition needs no such claim.
1127
- if (record.isBackground || pool !== undefined) this.drainQueue();
1251
+ // The lease half: a freed budget slot may start work in any manager.
1252
+ const drain = record.isBackground || pool !== undefined || record.budgetLease !== undefined;
1253
+ this.releaseLease(record);
1254
+
1255
+ try {
1256
+ if (guardCallback) {
1257
+ try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
1258
+ } else {
1259
+ this.onComplete?.(record);
1260
+ }
1261
+ } finally {
1262
+ // Drained even when an unguarded `onComplete` throws: the slot and lease
1263
+ // are already free, and a waiter that misses this drain may be the child
1264
+ // a running parent is blocked on, with nothing else left to pump.
1265
+ if (drain) this.drainQueue();
1266
+ }
1128
1267
  }
1129
1268
 
1130
1269
  /**
@@ -1140,32 +1279,15 @@ export class AgentManager {
1140
1279
  }
1141
1280
 
1142
1281
  /**
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.
1282
+ * Start queued agents up to each pool's concurrency limit and the process
1283
+ * budget. The budget owns the order (`pumpBudget`): the earliest waiter, in
1284
+ * any manager, whose depth fits and whose own pool has room. Eligibility per
1285
+ * entry rather than `shift`: with one queue serving several limits, a
1286
+ * saturated pool at the head would otherwise stall everything behind it.
1287
+ * Taking the earliest eligible entry keeps FIFO within each pool.
1149
1288
  */
1150
1289
  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
- }
1290
+ pumpBudget();
1169
1291
  }
1170
1292
 
1171
1293
  /**
@@ -1173,13 +1295,15 @@ export class AgentManager {
1173
1295
  * that enforces "leaving the queue releases the waiter" — a missed release is
1174
1296
  * an unbounded hang, not a failed call.
1175
1297
  */
1176
- private dequeue(pred: (entry: { id: string; pool: Pool }) => boolean): void {
1298
+ private dequeue(pred: (entry: QueueEntry) => boolean): void {
1177
1299
  const kept: typeof this.queue = [];
1178
- for (const entry of this.queue) {
1179
- if (pred(entry)) entry.release();
1180
- else kept.push(entry);
1181
- }
1300
+ const removed: QueueEntry[] = [];
1301
+ for (const entry of this.queue) (pred(entry) ? removed : kept).push(entry);
1182
1302
  this.queue = kept;
1303
+ for (const entry of removed) {
1304
+ entry.cancel();
1305
+ entry.release();
1306
+ }
1183
1307
  }
1184
1308
 
1185
1309
  /**
@@ -1292,6 +1416,19 @@ export class AgentManager {
1292
1416
  const record = this.agents.get(id);
1293
1417
  if (!record?.session) return undefined;
1294
1418
 
1419
+ // Never re-enter a run that is still in flight, on either path. Starting a
1420
+ // second run would overwrite record.abortController — orphaning the live
1421
+ // run beyond the reach of `/agents` stop and abortAll() — take a second
1422
+ // pool slot and budget lease, and then reject from session.prompt() with
1423
+ // "Agent is already processing", whose settle path would abort the LIVE
1424
+ // run's children and report a failure for a run that is still going. A
1425
+ // detached resume makes this easy to hit (the caller gets control back
1426
+ // while the record stays "running"), and parallel tool calls make it
1427
+ // possible inline. Checked before anything below mutates the record, and
1428
+ // both paths flip the status synchronously, so a second call made in the
1429
+ // same tick is refused too. The caller decides whether to wait or steer.
1430
+ if (record.status === "running" || record.status === "queued") return undefined;
1431
+
1295
1432
  // Background resume: settle asynchronously and notify on completion exactly
1296
1433
  // like a background spawn, returning immediately with the record still
1297
1434
  // "running" — or "queued" when at the concurrency limit. Previously
@@ -1299,16 +1436,10 @@ export class AgentManager {
1299
1436
  // returned before its background branch, and resume() only ever awaited
1300
1437
  // inline), so a resumed agent always blocked the caller until it finished.
1301
1438
  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;
1439
+ // The pool it will be charged to once `isBackground` is set below; asked
1440
+ // before anything mutates the record, so a refusal leaves it untouched.
1441
+ const pool: Pool | undefined = occupiesPoolSlot({ ...record, isBackground: true }) ? "background" : undefined;
1442
+ const lease = this.admit(record, pool);
1312
1443
 
1313
1444
  record.isBackground = true;
1314
1445
  record.resultConsumed = false;
@@ -1318,36 +1449,49 @@ export class AgentManager {
1318
1449
  record.completedAt = undefined;
1319
1450
  record.status = "queued";
1320
1451
 
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
1452
+ // `startResume` throws only from the `onStart` callback, after it has
1453
+ // charged the pool and with the lease held: hand both back and leave the
1454
+ // record an error rather than "running" forever.
1455
+ const start = () => {
1456
+ try {
1457
+ this.startResume(id, record, prompt, signal, options);
1458
+ } catch (err) {
1459
+ if (occupiesPoolSlot(record)) this.runningBackground--;
1460
+ this.releaseLease(record);
1461
+ record.status = "error";
1462
+ record.error = err instanceof Error ? err.message : String(err);
1463
+ record.completedAt = this.clock.now();
1464
+ this.drainQueue();
1465
+ throw err;
1466
+ }
1467
+ };
1468
+ if (lease === undefined) {
1469
+ // At a limit — queue it, started when a slot and a lease free up. A
1324
1470
  // detached resume has no inline caller, hence nothing to release. The
1325
1471
  // 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
- });
1472
+ // promise-shaped even though a resume starts synchronously; a failure
1473
+ // is reported here, since the budget pump swallows throws.
1474
+ this.enqueueStart(id, record, pool, async () => {
1475
+ try {
1476
+ start();
1477
+ } catch {
1478
+ this.onComplete?.(record);
1479
+ }
1480
+ }, () => {});
1343
1481
  } else {
1482
+ record.budgetLease = lease;
1344
1483
  start();
1345
1484
  }
1346
1485
  return record;
1347
1486
  }
1348
1487
 
1349
- // Foreground resume: run inline and return the settled record.
1350
- record.status = "running";
1488
+ // Foreground resume: run inline and return the settled record. Takes a
1489
+ // budget lease like everything else, waiting for one if the process is at
1490
+ // its limit; the caller's signal (or `/agents` stop) releases the wait.
1491
+ // No pool: the foreground pool does not cover resumes (see the README).
1492
+ const lease = this.admit(record, undefined);
1493
+ record.status = lease === undefined ? "queued" : "running";
1494
+ record.budgetLease = lease;
1351
1495
  record.startedAt = this.clock.now();
1352
1496
  record.completedAt = undefined;
1353
1497
  record.result = undefined;
@@ -1361,9 +1505,38 @@ export class AgentManager {
1361
1505
  this.armParentAbort(record, signal);
1362
1506
  // `addEventListener` never fires on a signal that is already aborted, and
1363
1507
  // 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.
1508
+ // holds for a resume issued on a call that was cancelled first. On a
1509
+ // record still waiting for a lease this stops it outright.
1365
1510
  if (signal?.aborted) this.abort(id);
1366
1511
 
1512
+ if (lease === undefined) {
1513
+ // The queue entry's `start` only marks the record running: the run itself
1514
+ // stays inline below, so the caller awaits the same `resumeAgent` call
1515
+ // as an unqueued resume, and the lease the grant handed over is released
1516
+ // by the same tail. `granted` tells a grant from a removal (abort,
1517
+ // dispose), which resolves the same promise.
1518
+ let granted = false;
1519
+ if (record.status === "queued") {
1520
+ await new Promise<void>(resolve => {
1521
+ this.enqueueStart(id, record, undefined, async () => {
1522
+ granted = true;
1523
+ record.status = "running";
1524
+ record.startedAt = this.clock.now();
1525
+ }, resolve);
1526
+ });
1527
+ }
1528
+ // Removed while it waited: it never ran, so there is nothing to settle.
1529
+ // `abort` already marked it stopped; `dispose` and `detach` did not.
1530
+ if (!granted) {
1531
+ if (record.status === "queued") {
1532
+ record.status = "stopped";
1533
+ record.completedAt = this.clock.now();
1534
+ }
1535
+ this.detachParentAbort(record);
1536
+ return record;
1537
+ }
1538
+ }
1539
+
1367
1540
  try {
1368
1541
  this.settleOutcome(record, await resumeAgent(record.session, prompt, {
1369
1542
  type: record.type,
@@ -1397,6 +1570,8 @@ export class AgentManager {
1397
1570
  // resumed turn must not outlive it — nothing else can see or reach them.
1398
1571
  this.abortOwnedChildren(id);
1399
1572
 
1573
+ this.releaseLease(record);
1574
+ this.drainQueue();
1400
1575
  return record;
1401
1576
  }
1402
1577
 
@@ -1449,7 +1624,11 @@ export class AgentManager {
1449
1624
  parentSignal: AbortSignal | undefined,
1450
1625
  options: ResumeOptions,
1451
1626
  ) {
1452
- if (!record.session) return;
1627
+ if (!record.session) {
1628
+ this.releaseLease(record);
1629
+ this.drainQueue();
1630
+ return;
1631
+ }
1453
1632
 
1454
1633
  record.status = "running";
1455
1634
  record.startedAt = this.clock.now();
@@ -1486,6 +1665,7 @@ export class AgentManager {
1486
1665
  // Children spawned during the resumed turn must not outlive it.
1487
1666
  this.abortOwnedChildren(id);
1488
1667
  if (occupiesPoolSlot(record)) this.runningBackground--;
1668
+ this.releaseLease(record);
1489
1669
  try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
1490
1670
  this.drainQueue();
1491
1671
  };
@@ -1769,19 +1949,26 @@ export class AgentManager {
1769
1949
  * they own their own session and report through a notification. Queued agents
1770
1950
  * would start later against a stale context, foreground agents belong to a
1771
1951
  * tool call that is being torn down, and workflow children die with their
1772
- * workflow, so all of those are aborted.
1952
+ * workflow, so all of those are aborted. A QUEUED nested child is kept: its
1953
+ * parent is a survivor waiting on it (the process budget can queue one), and
1954
+ * if the parent is stopped instead, its settle aborts the child.
1773
1955
  *
1774
1956
  * @returns how many agents keep running.
1775
1957
  */
1776
1958
  detach(): number {
1959
+ const keepQueued = (id: string): boolean => {
1960
+ const record = this.agents.get(id);
1961
+ return record !== undefined && record.parentAgentId !== undefined && record.workflowId === undefined;
1962
+ };
1777
1963
  for (const queued of this.queue) {
1964
+ if (keepQueued(queued.id)) continue;
1778
1965
  const record = this.agents.get(queued.id);
1779
1966
  if (record) {
1780
1967
  record.status = "stopped";
1781
1968
  record.completedAt = this.clock.now();
1782
1969
  }
1783
1970
  }
1784
- this.dequeue(() => true);
1971
+ this.dequeue(entry => !keepQueued(entry.id));
1785
1972
  let survivors = 0;
1786
1973
  for (const record of this.agents.values()) {
1787
1974
  if (record.status !== "running") continue;
@@ -1837,6 +2024,9 @@ export class AgentManager {
1837
2024
  if (startup) pending.push(startup);
1838
2025
  if (record.promise) pending.push(record.promise);
1839
2026
  }
2027
+ // A queued start may be waiting on another manager's leases, with
2028
+ // nothing in this one to wait on: wait for it to leave the queue.
2029
+ for (const entry of this.queue) pending.push(entry.left);
1840
2030
  if (pending.length === 0) break;
1841
2031
  await Promise.allSettled(pending);
1842
2032
  }
@@ -1870,5 +2060,12 @@ export class AgentManager {
1870
2060
  // handler and the process exits right after it returns, so anything left unawaited
1871
2061
  // here never runs at all. Bounded — each call carries its own ceiling, concurrently.
1872
2062
  await Promise.all(sessions.map(session => shutdownChildSession(session)));
2063
+ // Leases are NOT released here. `shutdownChildSession` is bounded and does
2064
+ // not wait for a run's promise, a worktree copy or its cleanup, so work can
2065
+ // still be going on; each run hands its lease back from its own settle
2066
+ // path (`settleRun`, the resume tails, the startup-failure and mid-copy
2067
+ // exits), which no longer needs this manager's maps. A run that never
2068
+ // settles keeps its slot, which is the honest count.
2069
+ this.drainQueue();
1873
2070
  }
1874
2071
  }