@nredd/pi-subagents 0.22.0-nredd.6 → 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.
- package/README.md +38 -8
- package/package.json +1 -1
- package/src/agent-manager.ts +307 -110
- package/src/index.ts +40 -17
- package/src/invocation-config.ts +5 -19
- package/src/nested-tools.ts +9 -0
- package/src/process-budget.ts +343 -0
- package/src/schedule.ts +5 -1
- package/src/settings.ts +75 -2
- package/src/types.ts +6 -0
package/src/agent-manager.ts
CHANGED
|
@@ -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
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
*
|
|
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:
|
|
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
|
-
|
|
673
|
-
|
|
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
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
|
755
|
-
*
|
|
756
|
-
*
|
|
757
|
-
*
|
|
758
|
-
*
|
|
759
|
-
*
|
|
760
|
-
*
|
|
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,
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
1146
|
-
*
|
|
1147
|
-
* would otherwise stall
|
|
1148
|
-
* eligible entry keeps FIFO within each pool
|
|
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
|
-
|
|
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:
|
|
1298
|
+
private dequeue(pred: (entry: QueueEntry) => boolean): void {
|
|
1177
1299
|
const kept: typeof this.queue = [];
|
|
1178
|
-
|
|
1179
|
-
|
|
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
|
-
//
|
|
1303
|
-
//
|
|
1304
|
-
|
|
1305
|
-
|
|
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
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
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;
|
|
1327
|
-
//
|
|
1328
|
-
this.
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
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
|
-
|
|
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)
|
|
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(
|
|
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
|
}
|