@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.
- package/README.md +160 -43
- package/package.json +8 -8
- package/src/agent-file-toggle.ts +6 -4
- package/src/agent-manager.ts +418 -118
- package/src/agent-runner.ts +155 -62
- package/src/cross-extension-rpc.ts +11 -36
- package/src/custom-agents.ts +9 -5
- package/src/default-agents.ts +2 -4
- package/src/enabled-models.ts +8 -7
- package/src/enterprise-local.ts +5 -5
- package/src/home-paths.ts +27 -0
- package/src/index.ts +231 -107
- package/src/invocation-config.ts +8 -35
- package/src/memory.ts +9 -9
- package/src/mention-clone.ts +9 -5
- package/src/model-resolver.ts +41 -11
- package/src/model-scope.ts +2 -1
- package/src/model-selection.ts +159 -0
- package/src/model-tiers.ts +171 -0
- package/src/nested-tools.ts +31 -26
- package/src/output-file.ts +23 -1
- package/src/package-resources.ts +25 -10
- package/src/process-budget.ts +346 -0
- package/src/quota-admission.ts +21 -9
- package/src/resource-loader.ts +51 -0
- package/src/schedule.ts +41 -19
- package/src/settings.ts +111 -8
- package/src/skill-loader.ts +8 -7
- package/src/stale-ctx.ts +16 -0
- package/src/types.ts +33 -0
- package/src/ui/agent-row.ts +13 -3
- package/src/ui/agent-widget.ts +15 -4
- package/src/ui/conversation-viewer.ts +6 -3
- package/src/ui/fallback-flag.ts +90 -0
- package/src/ui/fleet-list.ts +363 -53
- package/src/ui/fleet-state.ts +154 -0
- package/src/ui/notification-view.ts +18 -2
- package/src/ui/workflow-card.ts +5 -1
- package/src/ui/workflow-dialog.ts +10 -0
- package/src/workflow/host.ts +43 -45
- package/src/workflow/journal.ts +13 -1
- package/src/workflow/progress.ts +4 -0
- package/src/workflow/runtime.ts +25 -8
- package/src/workflow/saved.ts +19 -17
- package/src/workflow/task.ts +10 -0
- package/src/workflow/worker-source.ts +156 -38
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";
|
|
@@ -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
|
-
|
|
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 =>
|
|
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
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
*
|
|
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:
|
|
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
|
-
|
|
673
|
-
|
|
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
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
|
755
|
-
*
|
|
756
|
-
*
|
|
757
|
-
*
|
|
758
|
-
*
|
|
759
|
-
*
|
|
760
|
-
*
|
|
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,
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
1146
|
-
*
|
|
1147
|
-
* would otherwise stall
|
|
1148
|
-
* eligible entry keeps FIFO within each pool
|
|
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
|
-
|
|
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:
|
|
1397
|
+
private dequeue(pred: (entry: QueueEntry) => boolean): void {
|
|
1177
1398
|
const kept: typeof this.queue = [];
|
|
1178
|
-
|
|
1179
|
-
|
|
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
|
-
//
|
|
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;
|
|
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
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
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;
|
|
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
|
-
});
|
|
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
|
-
|
|
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)
|
|
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(
|
|
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
|
}
|