@nredd/pi-subagents 0.22.0-nredd.7 → 0.22.0-nredd.9
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 +245 -77
- package/package.json +16 -13
- package/src/abortable.ts +1 -1
- package/src/agent-file-toggle.ts +6 -5
- package/src/agent-manager.ts +489 -129
- package/src/agent-runner.ts +244 -93
- package/src/child-context.ts +19 -1
- package/src/completion-outbox.ts +22 -3
- package/src/cross-extension-rpc.ts +21 -42
- package/src/custom-agents.ts +11 -6
- package/src/default-agents.ts +21 -21
- package/src/detached-results.ts +18 -2
- package/src/enabled-models.ts +8 -7
- package/src/enterprise-local.ts +7 -6
- package/src/error-message.ts +11 -0
- package/src/git-path.ts +66 -0
- package/src/group-join.ts +4 -10
- package/src/home-paths.ts +27 -0
- package/src/index.ts +898 -297
- package/src/invocation-config.ts +4 -21
- package/src/memory.ts +9 -9
- package/src/mention-clone.ts +13 -8
- package/src/model-resolver.ts +92 -25
- package/src/model-scope.ts +2 -1
- package/src/model-selection.ts +170 -0
- package/src/model-tiers.ts +176 -0
- package/src/nested-tools.ts +99 -44
- package/src/notification-result.ts +118 -0
- package/src/output-file.ts +23 -1
- package/src/package-resources.ts +25 -10
- package/src/plan-mode-children.ts +176 -0
- package/src/process-budget.ts +6 -3
- package/src/prompts.ts +7 -2
- package/src/quota-admission.ts +35 -13
- package/src/resource-loader.ts +65 -0
- package/src/schedule.ts +94 -30
- package/src/settings.ts +63 -13
- package/src/skill-loader.ts +244 -93
- package/src/stale-ctx.ts +16 -0
- package/src/status-note.ts +28 -5
- package/src/types.ts +79 -2
- package/src/ui/agent-mention.ts +5 -2
- package/src/ui/agent-row.ts +13 -3
- package/src/ui/agent-widget.ts +55 -25
- package/src/ui/conversation-viewer.ts +10 -5
- package/src/ui/fallback-flag.ts +114 -0
- package/src/ui/fleet-list.ts +431 -86
- package/src/ui/fleet-state.ts +154 -0
- package/src/ui/notification-view.ts +22 -3
- package/src/ui/workflow-card.ts +6 -9
- package/src/ui/workflow-dialog.ts +36 -13
- package/src/workflow/host.ts +47 -50
- package/src/workflow/journal.ts +13 -1
- package/src/workflow/meta.ts +7 -4
- package/src/workflow/progress.ts +8 -3
- package/src/workflow/runtime.ts +48 -15
- package/src/workflow/saved.ts +21 -21
- package/src/workflow/task.ts +22 -7
- package/src/workflow/tool-description.ts +2 -2
- package/src/workflow/worker-source.ts +162 -40
- package/src/worktree.ts +309 -19
package/src/agent-manager.ts
CHANGED
|
@@ -21,16 +21,21 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { randomUUID } from "node:crypto";
|
|
24
|
-
import { statSync } from "node:fs";
|
|
24
|
+
import { accessSync, constants, opendirSync, statSync } from "node:fs";
|
|
25
25
|
import { isAbsolute } from "node:path";
|
|
26
26
|
import type { Model } from "@earendil-works/pi-ai";
|
|
27
27
|
import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
28
28
|
import { type AssistantMessageInfo, resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
|
|
29
29
|
import { getAgentConfig } from "./agent-types.js";
|
|
30
30
|
import { type Clock, realClock, type TimerHandle } from "./clock.js";
|
|
31
|
+
import { errorMessage } from "./error-message.js";
|
|
31
32
|
import { assignHandle, handleBase } from "./mention.js";
|
|
32
33
|
import type { ModelCallEvent } from "./model-call.js";
|
|
33
|
-
import { describeModel, describePhysicalModel, describeVirtualModel, isVirtualModel } from "./model-resolver.js";
|
|
34
|
+
import { describeModel, describePhysicalModel, describeVirtualModel, isVirtualModel, type ModelFallback } from "./model-resolver.js";
|
|
35
|
+
import { admitModelSelection, describeSelection, type ModelSelection, modelSelectionNote, selectModel } from "./model-selection.js";
|
|
36
|
+
import { type TierDisclosure, tierDisclosure } from "./model-tiers.js";
|
|
37
|
+
import { appendModelFallbackEntry, appendModelTierEntry } from "./output-file.js";
|
|
38
|
+
import { type PlanToolPolicy, queryPlanPolicy } from "./plan-mode-children.js";
|
|
34
39
|
import {
|
|
35
40
|
assertWithinBudgetDepth,
|
|
36
41
|
BUDGET_QUEUE_FULL_ERROR,
|
|
@@ -41,10 +46,12 @@ import {
|
|
|
41
46
|
releaseBudget,
|
|
42
47
|
tryAcquireBudget,
|
|
43
48
|
} from "./process-budget.js";
|
|
49
|
+
import { describeBlock } from "./quota-admission.js";
|
|
50
|
+
import { isStaleCtxError } from "./stale-ctx.js";
|
|
44
51
|
import type { AgentInvocation, AgentRecord, AgentTombstone, IsolationMode, MentionResolution, Pool, SubagentType, ThinkingLevel } from "./types.js";
|
|
45
52
|
import { addUsage, type LifetimeUsage } from "./usage.js";
|
|
46
53
|
import type { CompiledSchema } from "./workflow/json-schema.js";
|
|
47
|
-
import { cleanupWorktree, createWorktree, isWorktreeIsolationEnabled, pruneWorktrees, type WorktreeInfo } from "./worktree.js";
|
|
54
|
+
import { cleanupWorktree, createWorktree, isWorktreeIsolationEnabled, pruneWorktrees, type WorktreeCleanupResult, type WorktreeInfo } from "./worktree.js";
|
|
48
55
|
|
|
49
56
|
/**
|
|
50
57
|
* Relabel a record's model from the response that just finished. A virtual
|
|
@@ -59,10 +66,32 @@ function relabelFromResponse(record: AgentRecord, message: AssistantMessageInfo
|
|
|
59
66
|
if (!physical) return;
|
|
60
67
|
record.invocation ??= {};
|
|
61
68
|
Object.assign(record.invocation, physical);
|
|
69
|
+
if ((record.modelTier?.kind === "routed" || record.modelTier?.kind === "static") && record.modelTier.answered !== physical.modelId) {
|
|
70
|
+
record.modelTier = { ...record.modelTier, answered: physical.modelId };
|
|
71
|
+
if (record.outputFile) appendModelTierEntry(record.outputFile, record.id, record.modelTier);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Keep a cleanup failure visible on every result, including errors and stopped workflows. */
|
|
76
|
+
function recordWorktreeCleanup(record: AgentRecord, result: WorktreeCleanupResult): void {
|
|
77
|
+
record.worktreeResult = result;
|
|
78
|
+
if (!result.error) return;
|
|
79
|
+
const note = `Worktree cleanup failed: ${result.error}. Worktree retained at \`${result.path}\`${result.branch ? `; branch \`${result.branch}\` was created` : ""}. Inspect and recover it before removing it.`;
|
|
80
|
+
record.error = record.error ? `${record.error}\n${note}` : note;
|
|
81
|
+
record.result = record.result ? `${record.result}\n\n${note}` : note;
|
|
82
|
+
if (record.status !== "stopped") record.status = "error";
|
|
62
83
|
}
|
|
63
84
|
|
|
64
85
|
export type OnAgentComplete = (record: AgentRecord) => void;
|
|
65
86
|
export type OnAgentStart = (record: AgentRecord) => void;
|
|
87
|
+
/**
|
|
88
|
+
* Fired when `abort`, `detach` or `abortAll` stops a record that was still queued. Such a record
|
|
89
|
+
* never reaches `onComplete` (it never ran, so there is no result to announce), but a listener
|
|
90
|
+
* that heard it spawn needs to learn it ended.
|
|
91
|
+
*/
|
|
92
|
+
export type OnAgentQueuedStop = (record: AgentRecord) => void;
|
|
93
|
+
/** Fired when the retention sweep evicts a top-level agent whose result was never read (#330). */
|
|
94
|
+
export type OnAgentReaped = (note: EvictedNote) => void;
|
|
66
95
|
/**
|
|
67
96
|
* Fired by `steer()` once a message is delivered or queued. Every steer path
|
|
68
97
|
* goes through it except the steer tools (`steer_subagent` in index.ts, and its
|
|
@@ -163,12 +192,19 @@ export interface EvictedNote {
|
|
|
163
192
|
evictedAfterMin: number;
|
|
164
193
|
/** The `.output` transcript when one was written, else the session file. */
|
|
165
194
|
transcript?: string;
|
|
195
|
+
/** The run's model fallback, so even an evicted agent's id still discloses it. */
|
|
196
|
+
modelFallback?: ModelFallback;
|
|
197
|
+
modelTier?: TierDisclosure;
|
|
198
|
+
/** Set when a saved session exists but its name now belongs to another agent, so it was not kept reopenable. */
|
|
199
|
+
nameTaken?: string;
|
|
166
200
|
}
|
|
167
201
|
|
|
168
202
|
/** The text a tool returns for an id whose record was evicted. */
|
|
169
203
|
export function describeEvicted(note: EvictedNote): string {
|
|
170
204
|
const where = note.transcript ? `; transcript: ${note.transcript}` : "";
|
|
171
|
-
|
|
205
|
+
const disclosure = modelSelectionNote(note);
|
|
206
|
+
const fallback = disclosure ? `\n${disclosure}` : "";
|
|
207
|
+
return `Agent "${note.id}" (${note.description}) ${note.status}, evicted after ${note.evictedAfterMin} min${where}${fallback}`;
|
|
172
208
|
}
|
|
173
209
|
|
|
174
210
|
/**
|
|
@@ -200,6 +236,50 @@ function assertValidSpawnCwd(cwd: unknown): asserts cwd is string | undefined |
|
|
|
200
236
|
}
|
|
201
237
|
}
|
|
202
238
|
|
|
239
|
+
/**
|
|
240
|
+
* Fail fast when a working directory exists but cannot be opened (#436).
|
|
241
|
+
* `stat` succeeds on such a directory (POSIX `chmod 000`, or a macOS privacy
|
|
242
|
+
* denial of an external volume), so without this the child starts and every
|
|
243
|
+
* read fails with a bare EPERM/EACCES. Only those two codes are rejected: a
|
|
244
|
+
* missing directory stays the caller's concern (`assertValidSpawnCwd`).
|
|
245
|
+
*/
|
|
246
|
+
function assertReadableCwd(cwd: string, label: string): void {
|
|
247
|
+
try {
|
|
248
|
+
opendirSync(cwd).closeSync();
|
|
249
|
+
// opendir needs only the read bit; children also need search (x) to reach anything inside.
|
|
250
|
+
accessSync(cwd, constants.R_OK | constants.X_OK);
|
|
251
|
+
} catch (err) {
|
|
252
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
253
|
+
if (code !== "EACCES" && code !== "EPERM") return;
|
|
254
|
+
throw new Error(
|
|
255
|
+
`${label} is not readable (${code}): "${cwd}". Check its permissions; on macOS also check Privacy & Security -> Files & Folders / Full Disk Access for the terminal app (external volumes need it).`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Validate the cwd a child will run in: caller-supplied `SpawnOptions.cwd`, else the session cwd. */
|
|
261
|
+
function assertSpawnCwd(cwd: unknown, sessionCwd: string): void {
|
|
262
|
+
assertValidSpawnCwd(cwd);
|
|
263
|
+
if (cwd == null) assertReadableCwd(sessionCwd, "The session cwd");
|
|
264
|
+
else assertReadableCwd(cwd, "SpawnOptions.cwd");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The session's own pending agents, split by whether their completion wakes the parent. */
|
|
268
|
+
export interface OwnedPending {
|
|
269
|
+
/** Background agents (Agent tool, `@handle`, scheduler, background resume) whose completion notice starts a parent turn. */
|
|
270
|
+
notifying: number;
|
|
271
|
+
/** Foreground agents and RPC spawns without `isBackground`, whose settle notifies nobody. */
|
|
272
|
+
silent: number;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** A record whose run is over. Every settle and stop stamps `completedAt`; only a (re)start clears it. */
|
|
276
|
+
type SettledRecord = AgentRecord & { completedAt: number };
|
|
277
|
+
|
|
278
|
+
/** Whether `record` is past its run (neither running nor queued), and so carries `completedAt`. */
|
|
279
|
+
function isSettled(record: AgentRecord): record is SettledRecord {
|
|
280
|
+
return record.status !== "running" && record.status !== "queued";
|
|
281
|
+
}
|
|
282
|
+
|
|
203
283
|
/**
|
|
204
284
|
* Whether a record occupies one of the `maxConcurrent` background slots.
|
|
205
285
|
* Nested children don't: their parent already holds a slot, so counting (and
|
|
@@ -263,6 +343,8 @@ function occupiesForegroundSlot(
|
|
|
263
343
|
/** A start waiting in `AgentManager.queue`; see there. */
|
|
264
344
|
interface QueueEntry {
|
|
265
345
|
id: string;
|
|
346
|
+
/** The queued record; it stays in `agents` while queued, since only settled records are evicted. */
|
|
347
|
+
record: AgentRecord;
|
|
266
348
|
/** The pool it needs room in, or undefined when it waits on the budget alone. */
|
|
267
349
|
pool?: Pool;
|
|
268
350
|
start: () => Promise<void>;
|
|
@@ -282,6 +364,8 @@ interface SpawnArgs {
|
|
|
282
364
|
}
|
|
283
365
|
|
|
284
366
|
interface SpawnOptions {
|
|
367
|
+
/** Home inherited by nested callers; the manager's home is the default. */
|
|
368
|
+
homeDir?: string;
|
|
285
369
|
description: string;
|
|
286
370
|
/**
|
|
287
371
|
* Optional memorable name for this instance, becoming a second handle
|
|
@@ -308,7 +392,16 @@ interface SpawnOptions {
|
|
|
308
392
|
* `spawnTopLevel` strips it from anything a caller sends.
|
|
309
393
|
*/
|
|
310
394
|
reclaim?: { handle: string; alias?: string };
|
|
311
|
-
model?: Model<any
|
|
395
|
+
model?: Model<any> | string;
|
|
396
|
+
/** Internal preflight outcome, stored on the record without resolving again. */
|
|
397
|
+
modelSelection?: ModelSelection;
|
|
398
|
+
/** Historical outcome only, never used to label the continuation. */
|
|
399
|
+
previousModelTier?: TierDisclosure;
|
|
400
|
+
/** Historical fallback only, never used to label the continuation. */
|
|
401
|
+
previousModelFallback?: ModelFallback;
|
|
402
|
+
/** Internal, like `reclaim`: the evicted id a reopen continues. */
|
|
403
|
+
previousId?: string;
|
|
404
|
+
|
|
312
405
|
maxTurns?: number;
|
|
313
406
|
isolated?: boolean;
|
|
314
407
|
inheritContext?: boolean;
|
|
@@ -378,9 +471,11 @@ interface SpawnOptions {
|
|
|
378
471
|
invocation?: AgentInvocation;
|
|
379
472
|
/** Parent abort signal — when aborted, the subagent is also stopped. */
|
|
380
473
|
signal?: AbortSignal;
|
|
474
|
+
/** Prepare the record before it can be queued or started. A throw rejects the spawn. */
|
|
475
|
+
onPrepare?: (id: string) => void;
|
|
381
476
|
/**
|
|
382
477
|
* Called synchronously once the record is in the map and its promise is set,
|
|
383
|
-
* before `onSessionCreated` fires
|
|
478
|
+
* before `onSessionCreated` fires.
|
|
384
479
|
*
|
|
385
480
|
* Carried on the options rather than parked on the manager for the duration
|
|
386
481
|
* of a spawn: with a foreground queue, `startAgent` can run at drain time,
|
|
@@ -472,6 +567,7 @@ const CHILD_SHUTDOWN_TIMEOUT_MS = 3_000;
|
|
|
472
567
|
* `assertActive()` from a bare timer callback — an uncaughtException that kills pi (#242).
|
|
473
568
|
*/
|
|
474
569
|
async function shutdownChildSession(session: AgentSession | undefined): Promise<void> {
|
|
570
|
+
let deadline: ReturnType<typeof setTimeout> | undefined;
|
|
475
571
|
try {
|
|
476
572
|
const runner = session?.extensionRunner;
|
|
477
573
|
// Optional all the way down: on a pi without the getter, or a stubbed session from a
|
|
@@ -482,14 +578,44 @@ async function shutdownChildSession(session: AgentSession | undefined): Promise<
|
|
|
482
578
|
// already torn down — one hung handler would leave a dead terminal.
|
|
483
579
|
await Promise.race([
|
|
484
580
|
runner.emit({ type: "session_shutdown", reason: "quit" }),
|
|
485
|
-
new Promise<void>(resolve =>
|
|
581
|
+
new Promise<void>(resolve => {
|
|
582
|
+
deadline = setTimeout(resolve, CHILD_SHUTDOWN_TIMEOUT_MS);
|
|
583
|
+
deadline.unref();
|
|
584
|
+
}),
|
|
486
585
|
]);
|
|
487
586
|
}
|
|
488
587
|
} catch { /* a partial session must degrade, not take the teardown down with it */ }
|
|
588
|
+
finally {
|
|
589
|
+
// Promise.race does not cancel its loser, even when a handler rejects.
|
|
590
|
+
if (deadline !== undefined) clearTimeout(deadline);
|
|
591
|
+
}
|
|
489
592
|
// Always, even on timeout: disposal is what this function ultimately exists to do.
|
|
490
593
|
try { session?.dispose?.(); } catch { /* ignore */ }
|
|
491
594
|
}
|
|
492
595
|
|
|
596
|
+
/**
|
|
597
|
+
* Wrap an observer the child's own agent loop calls (usage, compaction) so a
|
|
598
|
+
* throw cannot reach that loop. pi records a throw from a session listener as
|
|
599
|
+
* the turn's failure, so an observer closed over a replaced session's `pi`
|
|
600
|
+
* ("This extension ctx is stale ...") used to end a detached child with an
|
|
601
|
+
* empty error turn and "No output" (#145). That failure is expected after a
|
|
602
|
+
* reload and dropped silently. Any other throw is a real observer defect: it
|
|
603
|
+
* is logged once per observer and also kept from killing the run.
|
|
604
|
+
*/
|
|
605
|
+
function guardObserver<A extends unknown[]>(name: string, observer: ((...args: A) => void) | undefined): ((...args: A) => void) | undefined {
|
|
606
|
+
if (!observer) return undefined;
|
|
607
|
+
let logged = false;
|
|
608
|
+
return (...args: A) => {
|
|
609
|
+
try {
|
|
610
|
+
observer(...args);
|
|
611
|
+
} catch (error) {
|
|
612
|
+
if (isStaleCtxError(error) || logged) return;
|
|
613
|
+
logged = true;
|
|
614
|
+
console.error(`pi-subagents: ${name} observer failed; further failures are not logged`, error);
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
|
|
493
619
|
export class AgentManager {
|
|
494
620
|
private agents = new Map<string, AgentRecord>();
|
|
495
621
|
private cleanupInterval: TimerHandle;
|
|
@@ -499,6 +625,8 @@ export class AgentManager {
|
|
|
499
625
|
private readonly unreadTtlMs = retentionMs("PI_SUBAGENTS_UNREAD_TTL_MIN", UNCONSUMED_RECORD_TTL_MS);
|
|
500
626
|
private onComplete?: OnAgentComplete;
|
|
501
627
|
private onStart?: OnAgentStart;
|
|
628
|
+
private onQueuedStop?: OnAgentQueuedStop;
|
|
629
|
+
private onReaped?: OnAgentReaped;
|
|
502
630
|
private onCompact?: OnAgentCompact;
|
|
503
631
|
private onUsage?: OnAgentUsage;
|
|
504
632
|
private onSteered?: OnAgentSteered;
|
|
@@ -529,6 +657,8 @@ export class AgentManager {
|
|
|
529
657
|
|
|
530
658
|
/** Evicted records by id, oldest first (insertion order). */
|
|
531
659
|
private evictedNotes = new Map<string, EvictedNote>();
|
|
660
|
+
/** Evicted id -> the id its reopened conversation runs under, so the old id keeps resolving (#330). */
|
|
661
|
+
private successors = new Map<string, string>();
|
|
532
662
|
|
|
533
663
|
/**
|
|
534
664
|
* Agents waiting to start, tagged with the pool they wait on (none for a
|
|
@@ -561,11 +691,16 @@ export class AgentManager {
|
|
|
561
691
|
onUsage?: OnAgentUsage,
|
|
562
692
|
onSteered?: OnAgentSteered,
|
|
563
693
|
private readonly clock: Clock = realClock,
|
|
694
|
+
private readonly homeDir?: string,
|
|
695
|
+
onQueuedStop?: OnAgentQueuedStop,
|
|
696
|
+
onReaped?: OnAgentReaped,
|
|
564
697
|
) {
|
|
565
698
|
this.onComplete = onComplete;
|
|
566
699
|
this.onStart = onStart;
|
|
567
|
-
this.
|
|
568
|
-
this.
|
|
700
|
+
this.onQueuedStop = guardObserver("queued stop", onQueuedStop);
|
|
701
|
+
this.onReaped = guardObserver("reaped", onReaped);
|
|
702
|
+
this.onCompact = guardObserver("compaction", onCompact);
|
|
703
|
+
this.onUsage = guardObserver("usage", onUsage);
|
|
569
704
|
this.onSteered = onSteered;
|
|
570
705
|
this.maxConcurrent = maxConcurrent;
|
|
571
706
|
// Sweep finished records past their retention window (see cleanup()).
|
|
@@ -577,6 +712,31 @@ export class AgentManager {
|
|
|
577
712
|
this.cleanupInterval.unref?.();
|
|
578
713
|
}
|
|
579
714
|
|
|
715
|
+
/**
|
|
716
|
+
* Put a run under Plan mode's policy when its owning agent's run is (a nested
|
|
717
|
+
* child) or the parent session is planning right now (everything else, a
|
|
718
|
+
* workflow stage included). The gate object is kept, since the child's hooks
|
|
719
|
+
* hold it.
|
|
720
|
+
*/
|
|
721
|
+
private decidePlanGate(record: AgentRecord, query: () => PlanToolPolicy | undefined): void {
|
|
722
|
+
const owner = record.parentAgentId === undefined ? undefined : this.agents.get(record.parentAgentId);
|
|
723
|
+
const policy = owner?.planGate?.policy ?? query();
|
|
724
|
+
record.planGate ??= {};
|
|
725
|
+
record.planGate.policy = policy;
|
|
726
|
+
if (policy || record.invocation?.planMode) {
|
|
727
|
+
record.invocation ??= {};
|
|
728
|
+
record.invocation.planMode = policy ? true : undefined;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** Spawns and settles, in order: `spawnSeq` / `settleSeq` on lifecycle events. */
|
|
733
|
+
private lifecycleSequence = 0;
|
|
734
|
+
|
|
735
|
+
/** The next lifecycle sequence number; the host stamps `settleSeq` with it at settle. */
|
|
736
|
+
nextSequence(): number {
|
|
737
|
+
return ++this.lifecycleSequence;
|
|
738
|
+
}
|
|
739
|
+
|
|
580
740
|
/** Update the max concurrent background agents limit. */
|
|
581
741
|
setMaxConcurrent(n: number) {
|
|
582
742
|
this.maxConcurrent = Math.max(1, n);
|
|
@@ -644,7 +804,20 @@ export class AgentManager {
|
|
|
644
804
|
// Validate before the queue branch — a queued spawn should fail at the
|
|
645
805
|
// call, not minutes later at drain. Throw (not warn): programmatic callers
|
|
646
806
|
// can fix and retry; the RPC layer converts throws into error envelopes.
|
|
647
|
-
|
|
807
|
+
assertSpawnCwd(options.cwd, ctx.cwd);
|
|
808
|
+
const selection = options.modelSelection ?? selectModel({
|
|
809
|
+
homeDir: options.homeDir ?? this.homeDir,
|
|
810
|
+
agentType: type,
|
|
811
|
+
definitionModel: getAgentConfig(type)?.model,
|
|
812
|
+
paramModel: options.model,
|
|
813
|
+
parentModel: ctx.model,
|
|
814
|
+
registry: ctx.modelRegistry,
|
|
815
|
+
cwd: options.configCwd ?? ctx.cwd,
|
|
816
|
+
agentLabel: getAgentConfig(type)?.displayName,
|
|
817
|
+
});
|
|
818
|
+
if (typeof selection === "string") throw new Error(selection);
|
|
819
|
+
if (selection.scopeWarning && !options.modelSelection) ctx.ui.notify(selection.scopeWarning, "warning");
|
|
820
|
+
options = { ...options, model: selection.model, modelSelection: selection };
|
|
648
821
|
|
|
649
822
|
const id = randomUUID().slice(0, 17);
|
|
650
823
|
const abortController = new AbortController();
|
|
@@ -672,6 +845,7 @@ export class AgentManager {
|
|
|
672
845
|
startedAt: this.clock.now(),
|
|
673
846
|
abortController,
|
|
674
847
|
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0, cost: 0 },
|
|
848
|
+
spawnSeq: this.nextSequence(),
|
|
675
849
|
compactionCount: 0,
|
|
676
850
|
// Raw tri-state (not coerced to a boolean): true = background, false =
|
|
677
851
|
// foreground (has an inline tool-result surface), undefined = caller never
|
|
@@ -684,18 +858,27 @@ export class AgentManager {
|
|
|
684
858
|
// `options` has stopped being the interesting object.
|
|
685
859
|
blocking: options.blocking,
|
|
686
860
|
invocation: options.invocation,
|
|
861
|
+
modelFallback: selection.fallback,
|
|
862
|
+
modelSelection: selection,
|
|
863
|
+
modelTier: selection.tier ? tierDisclosure(selection.tier) : undefined,
|
|
864
|
+
previousModelTier: options.previousModelTier,
|
|
865
|
+
previousModelFallback: options.previousModelFallback,
|
|
866
|
+
previousId: options.previousId,
|
|
687
867
|
depth: options.depth ?? 1,
|
|
688
868
|
parentAgentId: options.parentAgentId,
|
|
689
869
|
workflowId: options.workflowId,
|
|
690
870
|
maxSubagentDepth: options.maxSubagentDepth,
|
|
691
871
|
rootSessionId: options.rootSessionId,
|
|
692
872
|
};
|
|
873
|
+
this.decidePlanGate(record, () => queryPlanPolicy(pi.events));
|
|
874
|
+
record.planQuery = () => queryPlanPolicy(pi.events, "restrict");
|
|
693
875
|
this.agents.set(id, record);
|
|
694
876
|
// After the insert, so `takenHandles()` already counts this record's own
|
|
695
877
|
// handle — a spawn named after its own type gets `explore-2`, not a
|
|
696
878
|
// duplicate `explore` that would make resolution ambiguous.
|
|
697
879
|
if (record.handle !== undefined && record.alias === undefined && options.name !== undefined) {
|
|
698
|
-
record.
|
|
880
|
+
record.nameBase = handleBase(options.name);
|
|
881
|
+
record.alias = assignHandle(record.nameBase, this.takenHandles());
|
|
699
882
|
}
|
|
700
883
|
|
|
701
884
|
const args: SpawnArgs = { pi, ctx, type, prompt, options };
|
|
@@ -711,6 +894,14 @@ export class AgentManager {
|
|
|
711
894
|
this.agents.delete(id);
|
|
712
895
|
throw err;
|
|
713
896
|
}
|
|
897
|
+
try {
|
|
898
|
+
options.onPrepare?.(id);
|
|
899
|
+
} catch (err) {
|
|
900
|
+
this.agents.delete(id);
|
|
901
|
+
releaseBudget(lease);
|
|
902
|
+
this.drainQueue();
|
|
903
|
+
throw err;
|
|
904
|
+
}
|
|
714
905
|
if (lease === undefined) {
|
|
715
906
|
// Queue it — started once its pool has room and the process budget a
|
|
716
907
|
// lease, whichever was missing.
|
|
@@ -719,7 +910,7 @@ export class AgentManager {
|
|
|
719
910
|
record.status = "queued";
|
|
720
911
|
// A queued record never reaches startAgent's signal wiring, so arm the
|
|
721
912
|
// parent abort here or Esc could not release the position.
|
|
722
|
-
if (!this.armQueuedAbort(
|
|
913
|
+
if (!this.armQueuedAbort(record, options.signal)) return id;
|
|
723
914
|
let release!: () => void;
|
|
724
915
|
record.startGate = new Promise<void>(resolve => { release = resolve; });
|
|
725
916
|
// Failure bookkeeping keys on whether an inline caller gets the throw,
|
|
@@ -749,18 +940,14 @@ export class AgentManager {
|
|
|
749
940
|
* replaces it with its own, and `sendToBackground` must be able to take it
|
|
750
941
|
* off, or the caller that armed it could stop a run it no longer owns.
|
|
751
942
|
*/
|
|
752
|
-
private armQueuedAbort(
|
|
943
|
+
private armQueuedAbort(record: AgentRecord, signal?: AbortSignal): boolean {
|
|
753
944
|
if (signal === undefined) return true;
|
|
754
945
|
if (signal.aborted) {
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
record.status = "stopped";
|
|
758
|
-
record.completedAt = this.clock.now();
|
|
759
|
-
}
|
|
946
|
+
record.status = "stopped";
|
|
947
|
+
record.completedAt = this.clock.now();
|
|
760
948
|
return false;
|
|
761
949
|
}
|
|
762
|
-
|
|
763
|
-
if (record) this.armParentAbort(record, signal);
|
|
950
|
+
this.armParentAbort(record, signal);
|
|
764
951
|
return true;
|
|
765
952
|
}
|
|
766
953
|
|
|
@@ -790,9 +977,8 @@ export class AgentManager {
|
|
|
790
977
|
* budget's wait queue is full.
|
|
791
978
|
*/
|
|
792
979
|
private admit(record: AgentRecord, pool: Pool | undefined): BudgetLease | undefined {
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
const lease = pool === undefined || this.poolHasRoom(pool) ? tryAcquireBudget(depth) : undefined;
|
|
980
|
+
assertWithinBudgetDepth(record.depth);
|
|
981
|
+
const lease = pool === undefined || this.poolHasRoom(pool) ? tryAcquireBudget(record.depth) : undefined;
|
|
796
982
|
if (lease === undefined && !budgetHasPendingRoom()) throw new Error(BUDGET_QUEUE_FULL_ERROR);
|
|
797
983
|
return lease;
|
|
798
984
|
}
|
|
@@ -820,34 +1006,36 @@ export class AgentManager {
|
|
|
820
1006
|
const left = new Promise<void>(resolve => { markLeft = resolve; });
|
|
821
1007
|
const entry: QueueEntry = {
|
|
822
1008
|
id,
|
|
1009
|
+
record,
|
|
823
1010
|
pool,
|
|
824
1011
|
start,
|
|
825
1012
|
release: () => {
|
|
826
1013
|
release();
|
|
827
1014
|
markLeft();
|
|
828
1015
|
},
|
|
829
|
-
|
|
1016
|
+
// enqueueBudget only registers; grant cannot run until the next pump,
|
|
1017
|
+
// after this entry has been initialized and published in the queue.
|
|
1018
|
+
cancel: enqueueBudget({
|
|
1019
|
+
depth: record.depth,
|
|
1020
|
+
ready: () => pool === undefined || this.poolHasRoom(pool),
|
|
1021
|
+
grant: (lease) => {
|
|
1022
|
+
this.queue = this.queue.filter(e => e !== entry);
|
|
1023
|
+
if (this.agents.get(id) !== record || record.status !== "queued") {
|
|
1024
|
+
releaseBudget(lease);
|
|
1025
|
+
entry.release();
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
record.budgetLease = lease;
|
|
1029
|
+
// Detached, and never rejects: a late failure (e.g. strict worktree
|
|
1030
|
+
// isolation) lands on the record inside `launch`. The release waits
|
|
1031
|
+
// for that startup to SETTLE: a release at grant time would wake a
|
|
1032
|
+
// blocked `spawnAndWait` while `record.promise` was still undefined,
|
|
1033
|
+
// and it would read a healthy agent as one that never ran.
|
|
1034
|
+
void entry.start().then(() => entry.release(), () => entry.release());
|
|
1035
|
+
},
|
|
1036
|
+
}),
|
|
830
1037
|
left,
|
|
831
1038
|
};
|
|
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
1039
|
this.queue.push(entry);
|
|
852
1040
|
}
|
|
853
1041
|
|
|
@@ -883,7 +1071,7 @@ export class AgentManager {
|
|
|
883
1071
|
// session about it — the same failure reported twice.
|
|
884
1072
|
if (failAs === "foreground") markConsumed(record, this.clock.now());
|
|
885
1073
|
record.status = "error";
|
|
886
|
-
record.error =
|
|
1074
|
+
record.error = errorMessage(err);
|
|
887
1075
|
record.completedAt = this.clock.now();
|
|
888
1076
|
try { this.onComplete?.(record); } catch { /* the startup failure is the error worth reporting */ }
|
|
889
1077
|
} else {
|
|
@@ -924,10 +1112,10 @@ export class AgentManager {
|
|
|
924
1112
|
record: AgentRecord,
|
|
925
1113
|
{ pi, ctx, type, prompt, options }: SpawnArgs,
|
|
926
1114
|
) {
|
|
927
|
-
// Re-validate
|
|
1115
|
+
// Re-validate the cwd (caller-supplied, else the session's): queued spawns can start minutes after
|
|
928
1116
|
// spawn()'s check, and the directory may be gone by then (TOCTOU). Same
|
|
929
1117
|
// curated errors; drainQueue parks a throw on the record as an error.
|
|
930
|
-
|
|
1118
|
+
assertSpawnCwd(options.cwd, ctx.cwd);
|
|
931
1119
|
// Single resolution point for the caller-supplied cwd — the worktree base
|
|
932
1120
|
// repo and both cleanup calls below MUST agree on this value forever.
|
|
933
1121
|
const customCwd = options.cwd ?? undefined; // null (RPC "unset") → undefined
|
|
@@ -954,6 +1142,25 @@ export class AgentManager {
|
|
|
954
1142
|
record.startGate = undefined;
|
|
955
1143
|
this.chargePool(record.chargedPool, 1);
|
|
956
1144
|
|
|
1145
|
+
// Hold the pool slot BEFORE awaiting admission, just as for worktree creation.
|
|
1146
|
+
try {
|
|
1147
|
+
if (options.modelSelection?.tierBinding && !options.modelSelection.tierAdmitted) {
|
|
1148
|
+
const block = await admitModelSelection(options.modelSelection, ctx.modelRegistry, pi.events);
|
|
1149
|
+
// Admitting an unadmitted binding always resolves its `tier`.
|
|
1150
|
+
record.modelTier = tierDisclosure(options.modelSelection.tier!);
|
|
1151
|
+
record.modelFallback = options.modelSelection.fallback;
|
|
1152
|
+
if (block) throw new Error(`${modelSelectionNote(record)} ${describeBlock(block)} The agent was not started.`);
|
|
1153
|
+
if (record.abortController?.signal.aborted || options.signal?.aborted) throw new Error("Agent aborted during model admission.");
|
|
1154
|
+
}
|
|
1155
|
+
if (record.modelTier) {
|
|
1156
|
+
record.invocation ??= {};
|
|
1157
|
+
Object.assign(record.invocation, describeSelection(options.modelSelection, ctx.modelRegistry));
|
|
1158
|
+
}
|
|
1159
|
+
} catch (error) {
|
|
1160
|
+
this.chargePool(record.chargedPool, -1);
|
|
1161
|
+
throw error;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
957
1164
|
// Worktree isolation: try to create a temporary git worktree. Strict —
|
|
958
1165
|
// fail loud if not possible (no silent fallback to main tree). Done BEFORE
|
|
959
1166
|
// the run is kicked off so a failure doesn't leave a half-running agent.
|
|
@@ -967,6 +1174,11 @@ export class AgentManager {
|
|
|
967
1174
|
const isolation = options.workflowId !== undefined
|
|
968
1175
|
? options.isolation
|
|
969
1176
|
: getAgentConfig(type)?.isolation ?? options.isolation;
|
|
1177
|
+
// A worktree exists to make edits, which a child started while planning may not.
|
|
1178
|
+
if (isolation === "worktree" && record.planGate?.policy) {
|
|
1179
|
+
this.chargePool(record.chargedPool, -1);
|
|
1180
|
+
throw new Error('Cannot run with isolation: "worktree" while Plan mode is active: a worktree exists to make edits. Omit `isolation`, or leave Plan mode first.');
|
|
1181
|
+
}
|
|
970
1182
|
let worktreeCwd: string | undefined;
|
|
971
1183
|
if (isolation === "worktree" && isWorktreeIsolationEnabled()) {
|
|
972
1184
|
let wt: WorktreeInfo;
|
|
@@ -974,7 +1186,7 @@ export class AgentManager {
|
|
|
974
1186
|
wt = await createWorktree(pi, baseCwd, id);
|
|
975
1187
|
} catch (err) {
|
|
976
1188
|
this.chargePool(record.chargedPool, -1);
|
|
977
|
-
const cause =
|
|
1189
|
+
const cause = errorMessage(err);
|
|
978
1190
|
// Only a parent-cwd spawn can be fixed by pointing `cwd` elsewhere.
|
|
979
1191
|
const hint = customCwd === undefined
|
|
980
1192
|
? " To isolate another repo, pass its path as the `Agent` tool's `cwd`; or omit `isolation`."
|
|
@@ -1002,7 +1214,11 @@ export class AgentManager {
|
|
|
1002
1214
|
// definition unchanged) worktree instead.
|
|
1003
1215
|
if (record.status !== "running") {
|
|
1004
1216
|
this.chargePool(record.chargedPool, -1);
|
|
1005
|
-
record
|
|
1217
|
+
recordWorktreeCleanup(record, await cleanupWorktree(pi, baseCwd, wt, options.description));
|
|
1218
|
+
if (record.worktreeResult?.error) {
|
|
1219
|
+
if (!record.isBackground) markConsumed(record, this.clock.now());
|
|
1220
|
+
try { this.onComplete?.(record); } catch { /* do not strand the lease */ }
|
|
1221
|
+
}
|
|
1006
1222
|
// After the cleanup, not before: the copy is still being removed until here.
|
|
1007
1223
|
this.releaseLease(record);
|
|
1008
1224
|
this.drainQueue();
|
|
@@ -1019,8 +1235,12 @@ export class AgentManager {
|
|
|
1019
1235
|
this.chargePool(record.chargedPool, -1);
|
|
1020
1236
|
if (record.worktree) {
|
|
1021
1237
|
try {
|
|
1022
|
-
record
|
|
1023
|
-
} catch { /* ignore cleanup errors */ }
|
|
1238
|
+
recordWorktreeCleanup(record, await cleanupWorktree(pi, baseCwd, record.worktree, options.description));
|
|
1239
|
+
} catch { /* ignore unexpected cleanup errors */ }
|
|
1240
|
+
}
|
|
1241
|
+
if (record.worktreeResult?.error) {
|
|
1242
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1243
|
+
throw new Error(`${message}\n${record.error}`);
|
|
1024
1244
|
}
|
|
1025
1245
|
throw err;
|
|
1026
1246
|
}
|
|
@@ -1033,15 +1253,20 @@ export class AgentManager {
|
|
|
1033
1253
|
else this.armParentAbort(record, options.signal);
|
|
1034
1254
|
const detach = () => this.detachParentAbort(record);
|
|
1035
1255
|
|
|
1256
|
+
record.ranThisRun = record.status === "running";
|
|
1036
1257
|
const promise = runAgent(ctx, type, prompt, {
|
|
1037
1258
|
pi,
|
|
1259
|
+
clock: this.clock,
|
|
1260
|
+
homeDir: options.homeDir ?? this.homeDir,
|
|
1038
1261
|
agentId: id,
|
|
1039
|
-
model: options.model,
|
|
1262
|
+
model: options.modelSelection?.model,
|
|
1263
|
+
modelSelection: record.modelSelection!,
|
|
1040
1264
|
maxTurns: options.maxTurns,
|
|
1041
1265
|
isolated: options.isolated,
|
|
1042
1266
|
inheritContext: options.inheritContext,
|
|
1043
1267
|
thinkingLevel: options.thinkingLevel,
|
|
1044
1268
|
structuredOutput: options.structuredOutput,
|
|
1269
|
+
planGate: record.planGate,
|
|
1045
1270
|
resumeSessionFile: options.resumeSessionFile,
|
|
1046
1271
|
nested: options.parentAgentId !== undefined,
|
|
1047
1272
|
workflow: options.workflowId !== undefined,
|
|
@@ -1078,7 +1303,7 @@ export class AgentManager {
|
|
|
1078
1303
|
nestedRuntime: {
|
|
1079
1304
|
manager: this,
|
|
1080
1305
|
parentAgentId: id,
|
|
1081
|
-
depth: record.depth
|
|
1306
|
+
depth: record.depth,
|
|
1082
1307
|
maxSubagentDepth: record.maxSubagentDepth,
|
|
1083
1308
|
},
|
|
1084
1309
|
onSessionCreated: (session) => {
|
|
@@ -1104,7 +1329,9 @@ export class AgentManager {
|
|
|
1104
1329
|
const requested = record.invocation.requestedThinking ?? record.invocation.thinking;
|
|
1105
1330
|
Object.assign(
|
|
1106
1331
|
record.invocation,
|
|
1107
|
-
|
|
1332
|
+
record.modelTier?.kind === "routed" && record.modelTier.admitted
|
|
1333
|
+
? describeSelection(options.modelSelection, ctx.modelRegistry)
|
|
1334
|
+
: isVirtualModel(session.model) ? describeVirtualModel(session.model) : describeModel(session.model),
|
|
1108
1335
|
);
|
|
1109
1336
|
// Guarded for the reason above: a session that reports no level keeps
|
|
1110
1337
|
// the request rather than losing it. Overwriting unconditionally would
|
|
@@ -1117,6 +1344,9 @@ export class AgentManager {
|
|
|
1117
1344
|
}
|
|
1118
1345
|
}
|
|
1119
1346
|
}
|
|
1347
|
+
// The transcript says it too: it is what someone opens to find out what ran.
|
|
1348
|
+
if (record.modelFallback && record.outputFile) appendModelFallbackEntry(record.outputFile, record.id, record.modelFallback);
|
|
1349
|
+
if (record.modelTier && record.outputFile) appendModelTierEntry(record.outputFile, record.id, record.modelTier);
|
|
1120
1350
|
// Flush any steers that arrived before the session was ready
|
|
1121
1351
|
if (record.pendingSteers?.length) {
|
|
1122
1352
|
for (const msg of record.pendingSteers) {
|
|
@@ -1155,8 +1385,8 @@ export class AgentManager {
|
|
|
1155
1385
|
} catch { /* ignore — never block cleanup */ }
|
|
1156
1386
|
}
|
|
1157
1387
|
const wtResult = await cleanupWorktree(pi, baseCwd, record.worktree, options.description);
|
|
1158
|
-
record
|
|
1159
|
-
if (wtResult.hasChanges && wtResult.branch) {
|
|
1388
|
+
recordWorktreeCleanup(record, wtResult);
|
|
1389
|
+
if (!wtResult.error && wtResult.hasChanges && wtResult.branch) {
|
|
1160
1390
|
// With a caller-supplied cwd the branch lives in THAT repo, not the
|
|
1161
1391
|
// parent session's — say so, or the orchestrator merges in the wrong repo.
|
|
1162
1392
|
// Named by its toplevel: the cwd may be a subdirectory of it.
|
|
@@ -1165,7 +1395,7 @@ export class AgentManager {
|
|
|
1165
1395
|
// Appended to the prose only. A structured child's caller parses
|
|
1166
1396
|
// `structuredJson`, which stays untouched — but `result` is also
|
|
1167
1397
|
// what a human reads, so the note still belongs on it.
|
|
1168
|
-
record.result =
|
|
1398
|
+
record.result = responseText +
|
|
1169
1399
|
`\n\n---\nChanges saved to branch \`${wtResult.branch}\`${repoNote}. Merge with: \`git merge ${wtResult.branch}\`${customCwd !== undefined ? ` (run in \`${repo}\`)` : ""}`;
|
|
1170
1400
|
}
|
|
1171
1401
|
}
|
|
@@ -1180,7 +1410,7 @@ export class AgentManager {
|
|
|
1180
1410
|
if (record.status !== "stopped") {
|
|
1181
1411
|
record.status = "error";
|
|
1182
1412
|
}
|
|
1183
|
-
record.error =
|
|
1413
|
+
record.error = errorMessage(err);
|
|
1184
1414
|
record.completedAt ??= this.clock.now();
|
|
1185
1415
|
|
|
1186
1416
|
detach();
|
|
@@ -1195,7 +1425,7 @@ export class AgentManager {
|
|
|
1195
1425
|
if (record.worktree) {
|
|
1196
1426
|
try {
|
|
1197
1427
|
const wtResult = await cleanupWorktree(pi, baseCwd, record.worktree, options.description);
|
|
1198
|
-
record
|
|
1428
|
+
recordWorktreeCleanup(record, wtResult);
|
|
1199
1429
|
} catch { /* ignore cleanup errors */ }
|
|
1200
1430
|
}
|
|
1201
1431
|
|
|
@@ -1240,16 +1470,9 @@ export class AgentManager {
|
|
|
1240
1470
|
// Nobody waits on a settled run; a later foreground resume must not look
|
|
1241
1471
|
// like a spawn `sendToBackground` could hand off.
|
|
1242
1472
|
record.blocking = false;
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
//
|
|
1246
|
-
// background settle has always drained, even for a nested child that held
|
|
1247
|
-
// no slot — so that path is unchanged whether or not the foreground pool is
|
|
1248
|
-
// on. The `pool` half only adds the drain a freed FOREGROUND slot needs.
|
|
1249
|
-
// A drain with nothing freed is a no-op anyway, but "no-op" is a claim
|
|
1250
|
-
// about reachability, and matching the old condition needs no such claim.
|
|
1251
|
-
// The lease half: a freed budget slot may start work in any manager.
|
|
1252
|
-
const drain = record.isBackground || pool !== undefined || record.budgetLease !== undefined;
|
|
1473
|
+
this.chargePool(record.chargedPool, -1);
|
|
1474
|
+
// Every run that reaches here holds a budget lease (each start takes one),
|
|
1475
|
+
// so every settle frees a slot some queued start, in any manager, may need.
|
|
1253
1476
|
this.releaseLease(record);
|
|
1254
1477
|
|
|
1255
1478
|
try {
|
|
@@ -1262,7 +1485,7 @@ export class AgentManager {
|
|
|
1262
1485
|
// Drained even when an unguarded `onComplete` throws: the slot and lease
|
|
1263
1486
|
// are already free, and a waiter that misses this drain may be the child
|
|
1264
1487
|
// a running parent is blocked on, with nothing else left to pump.
|
|
1265
|
-
|
|
1488
|
+
this.drainQueue();
|
|
1266
1489
|
}
|
|
1267
1490
|
}
|
|
1268
1491
|
|
|
@@ -1313,8 +1536,7 @@ export class AgentManager {
|
|
|
1313
1536
|
* Returns { id, record } so callers can access the agent ID.
|
|
1314
1537
|
*
|
|
1315
1538
|
* @param onSpawned - Called synchronously once the run is kicked off, before
|
|
1316
|
-
* onSessionCreated fires.
|
|
1317
|
-
* streamToOutputFile can pick it up.
|
|
1539
|
+
* onSessionCreated fires.
|
|
1318
1540
|
*/
|
|
1319
1541
|
async spawnAndWait(
|
|
1320
1542
|
pi: ExtensionAPI,
|
|
@@ -1362,7 +1584,7 @@ export class AgentManager {
|
|
|
1362
1584
|
// one contract rather than letting queue pressure decide whether a strict
|
|
1363
1585
|
// worktree failure throws or returns as a result.
|
|
1364
1586
|
if (record.promise === undefined && record.status === "error") {
|
|
1365
|
-
throw new Error(record.error
|
|
1587
|
+
throw new Error(record.error);
|
|
1366
1588
|
}
|
|
1367
1589
|
return { id, record };
|
|
1368
1590
|
}
|
|
@@ -1428,6 +1650,9 @@ export class AgentManager {
|
|
|
1428
1650
|
// both paths flip the status synchronously, so a second call made in the
|
|
1429
1651
|
// same tick is refused too. The caller decides whether to wait or steer.
|
|
1430
1652
|
if (record.status === "running" || record.status === "queued") return undefined;
|
|
1653
|
+
// Decided per run: a resume after the parent left Plan gets its full surface back.
|
|
1654
|
+
// `spawn` sets `planQuery` on every record this manager holds.
|
|
1655
|
+
this.decidePlanGate(record, record.planQuery!);
|
|
1431
1656
|
|
|
1432
1657
|
// Background resume: settle asynchronously and notify on completion exactly
|
|
1433
1658
|
// like a background spawn, returning immediately with the record still
|
|
@@ -1443,6 +1668,8 @@ export class AgentManager {
|
|
|
1443
1668
|
|
|
1444
1669
|
record.isBackground = true;
|
|
1445
1670
|
record.resultConsumed = false;
|
|
1671
|
+
record.noticeDelivered = undefined;
|
|
1672
|
+
record.ranThisRun = false;
|
|
1446
1673
|
record.consumedAt = undefined;
|
|
1447
1674
|
record.result = undefined;
|
|
1448
1675
|
record.error = undefined;
|
|
@@ -1459,7 +1686,7 @@ export class AgentManager {
|
|
|
1459
1686
|
if (occupiesPoolSlot(record)) this.runningBackground--;
|
|
1460
1687
|
this.releaseLease(record);
|
|
1461
1688
|
record.status = "error";
|
|
1462
|
-
record.error =
|
|
1689
|
+
record.error = errorMessage(err);
|
|
1463
1690
|
record.completedAt = this.clock.now();
|
|
1464
1691
|
this.drainQueue();
|
|
1465
1692
|
throw err;
|
|
@@ -1490,6 +1717,8 @@ export class AgentManager {
|
|
|
1490
1717
|
// its limit; the caller's signal (or `/agents` stop) releases the wait.
|
|
1491
1718
|
// No pool: the foreground pool does not cover resumes (see the README).
|
|
1492
1719
|
const lease = this.admit(record, undefined);
|
|
1720
|
+
record.ranThisRun = false;
|
|
1721
|
+
record.noticeDelivered = undefined;
|
|
1493
1722
|
record.status = lease === undefined ? "queued" : "running";
|
|
1494
1723
|
record.budgetLease = lease;
|
|
1495
1724
|
record.startedAt = this.clock.now();
|
|
@@ -1532,13 +1761,17 @@ export class AgentManager {
|
|
|
1532
1761
|
record.status = "stopped";
|
|
1533
1762
|
record.completedAt = this.clock.now();
|
|
1534
1763
|
}
|
|
1764
|
+
markConsumed(record, this.clock.now());
|
|
1535
1765
|
this.detachParentAbort(record);
|
|
1536
1766
|
return record;
|
|
1537
1767
|
}
|
|
1538
1768
|
}
|
|
1539
1769
|
|
|
1540
1770
|
try {
|
|
1771
|
+
// Positive evidence for the reap notice: only a live signal means the prompt runs.
|
|
1772
|
+
record.ranThisRun = record.status === "running";
|
|
1541
1773
|
this.settleOutcome(record, await resumeAgent(record.session, prompt, {
|
|
1774
|
+
clock: this.clock,
|
|
1542
1775
|
type: record.type,
|
|
1543
1776
|
maxTurns: options?.maxTurns ?? record.invocation?.maxTurns,
|
|
1544
1777
|
onTurnEnd: options?.onTurnEnd,
|
|
@@ -1565,6 +1798,8 @@ export class AgentManager {
|
|
|
1565
1798
|
this.failResume(record, err);
|
|
1566
1799
|
}
|
|
1567
1800
|
this.detachParentAbort(record);
|
|
1801
|
+
// The caller gets the settled record inline, whatever the outcome.
|
|
1802
|
+
markConsumed(record, this.clock.now());
|
|
1568
1803
|
|
|
1569
1804
|
// Same contract as the spawn settle paths: children spawned during the
|
|
1570
1805
|
// resumed turn must not outlive it — nothing else can see or reach them.
|
|
@@ -1605,7 +1840,7 @@ export class AgentManager {
|
|
|
1605
1840
|
private failResume(record: AgentRecord, err: unknown): void {
|
|
1606
1841
|
if (record.status !== "stopped") {
|
|
1607
1842
|
record.status = "error";
|
|
1608
|
-
record.error =
|
|
1843
|
+
record.error = errorMessage(err);
|
|
1609
1844
|
}
|
|
1610
1845
|
record.completedAt ??= this.clock.now();
|
|
1611
1846
|
}
|
|
@@ -1670,7 +1905,9 @@ export class AgentManager {
|
|
|
1670
1905
|
this.drainQueue();
|
|
1671
1906
|
};
|
|
1672
1907
|
|
|
1908
|
+
record.ranThisRun = true;
|
|
1673
1909
|
const promise = resumeAgent(record.session, prompt, {
|
|
1910
|
+
clock: this.clock,
|
|
1674
1911
|
type: record.type,
|
|
1675
1912
|
maxTurns: options.maxTurns ?? record.invocation?.maxTurns,
|
|
1676
1913
|
onTurnEnd: options.onTurnEnd,
|
|
@@ -1736,9 +1973,15 @@ export class AgentManager {
|
|
|
1736
1973
|
return this.agents.get(id);
|
|
1737
1974
|
}
|
|
1738
1975
|
|
|
1976
|
+
/**
|
|
1977
|
+
* Handles held outside this manager, e.g. by live survivors of an orphaned one, which a fresh
|
|
1978
|
+
* spawn must not reuse: a listener keys lifecycle events by handle, so a twin would mix the two up.
|
|
1979
|
+
*/
|
|
1980
|
+
reservedHandles: () => Iterable<string> = () => [];
|
|
1981
|
+
|
|
1739
1982
|
/** Handles already in use, so a fresh spawn can pick an unclaimed one. */
|
|
1740
1983
|
private takenHandles(): Set<string> {
|
|
1741
|
-
const taken = new Set<string>();
|
|
1984
|
+
const taken = new Set<string>(this.reservedHandles());
|
|
1742
1985
|
for (const record of this.agents.values()) {
|
|
1743
1986
|
if (record.handle) taken.add(record.handle);
|
|
1744
1987
|
if (record.alias) taken.add(record.alias);
|
|
@@ -1754,23 +1997,19 @@ export class AgentManager {
|
|
|
1754
1997
|
}
|
|
1755
1998
|
|
|
1756
1999
|
/**
|
|
1757
|
-
* Resolve an `@name` from the prompt. Matches a top-level agent's handle
|
|
1758
|
-
* case-insensitively,
|
|
1759
|
-
*
|
|
1760
|
-
* falls back to an exact agent id so `@<agentId>` works too.
|
|
2000
|
+
* Resolve an `@name` from the prompt. Matches a top-level agent's handle or
|
|
2001
|
+
* alias case-insensitively, then falls back to an exact agent id so
|
|
2002
|
+
* `@<agentId>` works too.
|
|
1761
2003
|
*/
|
|
1762
2004
|
resolveMention(name: string): MentionResolution | undefined {
|
|
1763
2005
|
const wanted = name.toLowerCase();
|
|
1764
|
-
let fallback: AgentRecord | undefined;
|
|
1765
2006
|
for (const record of this.agents.values()) {
|
|
1766
2007
|
if (record.parentAgentId !== undefined) continue;
|
|
1767
|
-
// Handle and alias share one namespace
|
|
1768
|
-
//
|
|
1769
|
-
|
|
1770
|
-
if (record.
|
|
1771
|
-
if (!fallback || record.startedAt > fallback.startedAt) fallback = record;
|
|
2008
|
+
// Handle and alias share one lowercase namespace (`takenHandles`, and a
|
|
2009
|
+
// reopen reclaims the evicted record's own names), so at most one agent
|
|
2010
|
+
// answers a name and it makes no difference which of the two matched.
|
|
2011
|
+
if (record.handle === wanted || record.alias === wanted) return { kind: "live", record };
|
|
1772
2012
|
}
|
|
1773
|
-
if (fallback) return { kind: "live", record: fallback };
|
|
1774
2013
|
const byId = this.agents.get(name);
|
|
1775
2014
|
if (byId?.parentAgentId === undefined && byId !== undefined) return { kind: "live", record: byId };
|
|
1776
2015
|
// Only once nothing live answers: a tombstone is a conversation to reopen,
|
|
@@ -1807,7 +2046,14 @@ export class AgentManager {
|
|
|
1807
2046
|
);
|
|
1808
2047
|
}
|
|
1809
2048
|
|
|
1810
|
-
|
|
2049
|
+
/**
|
|
2050
|
+
* Stop a running or queued agent; false when it was neither.
|
|
2051
|
+
*
|
|
2052
|
+
* @param by `"parent"` when the agent that spawned it asked (`stop_subagent`);
|
|
2053
|
+
* omitted for a human stop. Written on every stop, so a resumed record never
|
|
2054
|
+
* carries an earlier run's answer.
|
|
2055
|
+
*/
|
|
2056
|
+
abort(id: string, by?: AgentRecord["stoppedBy"]): boolean {
|
|
1811
2057
|
const record = this.agents.get(id);
|
|
1812
2058
|
if (!record) return false;
|
|
1813
2059
|
|
|
@@ -1816,20 +2062,29 @@ export class AgentManager {
|
|
|
1816
2062
|
// done; a blocking caller learns of the stop from its own tool result.
|
|
1817
2063
|
if (record.status === "queued") {
|
|
1818
2064
|
this.dequeue(q => q.id === id);
|
|
1819
|
-
record.
|
|
1820
|
-
|
|
2065
|
+
record.stoppedBy = by;
|
|
2066
|
+
this.stopQueued(record);
|
|
1821
2067
|
return true;
|
|
1822
2068
|
}
|
|
1823
2069
|
|
|
1824
2070
|
if (record.status !== "running") return false;
|
|
2071
|
+
// Before the abort: the run settles asynchronously and its notice reads this.
|
|
2072
|
+
record.stoppedBy = by;
|
|
1825
2073
|
record.abortController?.abort();
|
|
1826
2074
|
record.status = "stopped";
|
|
1827
2075
|
record.completedAt = this.clock.now();
|
|
1828
2076
|
return true;
|
|
1829
2077
|
}
|
|
1830
2078
|
|
|
2079
|
+
/** Mark a dequeued record stopped and tell `onQueuedStop`; the queue entry is already gone. */
|
|
2080
|
+
private stopQueued(record: AgentRecord): void {
|
|
2081
|
+
record.status = "stopped";
|
|
2082
|
+
record.completedAt = this.clock.now();
|
|
2083
|
+
this.onQueuedStop?.(record);
|
|
2084
|
+
}
|
|
2085
|
+
|
|
1831
2086
|
/** Dispose a record's session and remove it from the map. */
|
|
1832
|
-
private removeRecord(id: string, record:
|
|
2087
|
+
private removeRecord(id: string, record: SettledRecord): void {
|
|
1833
2088
|
this.tombstone(record);
|
|
1834
2089
|
this.noteEviction(record);
|
|
1835
2090
|
const session = record.session;
|
|
@@ -1852,17 +2107,63 @@ export class AgentManager {
|
|
|
1852
2107
|
* addressed by and a session file to reopen — an in-memory session leaves no
|
|
1853
2108
|
* transcript, so the mention would have nothing to continue from.
|
|
1854
2109
|
*/
|
|
1855
|
-
private tombstone(record:
|
|
1856
|
-
|
|
1857
|
-
this.
|
|
2110
|
+
private tombstone(record: SettledRecord): void {
|
|
2111
|
+
const entry = this.tombstoneOf(record);
|
|
2112
|
+
if (entry) this.storeTombstone(entry);
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
/** What `@handle` needs to reopen `record`'s conversation; undefined without a handle and a session file. */
|
|
2116
|
+
private tombstoneOf(record: SettledRecord): AgentTombstone | undefined {
|
|
2117
|
+
if (!record.handle || !record.sessionFile) return undefined;
|
|
2118
|
+
return {
|
|
1858
2119
|
handle: record.handle,
|
|
1859
2120
|
alias: record.alias,
|
|
1860
2121
|
id: record.id,
|
|
1861
2122
|
type: record.type,
|
|
1862
2123
|
description: record.description,
|
|
1863
2124
|
sessionFile: record.sessionFile,
|
|
1864
|
-
completedAt: record.completedAt
|
|
1865
|
-
|
|
2125
|
+
completedAt: record.completedAt,
|
|
2126
|
+
...(record.modelTier ? { modelTier: record.modelTier } : {}),
|
|
2127
|
+
...(record.modelFallback ? { modelFallback: record.modelFallback } : {}),
|
|
2128
|
+
};
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
/** Tombstones for whichever of `ids` are settled here and reopenable, to hand to a session that will never see these records. */
|
|
2132
|
+
evictionTombstones(ids: readonly string[]): AgentTombstone[] {
|
|
2133
|
+
return [...this.agents.values()]
|
|
2134
|
+
.filter(isSettled)
|
|
2135
|
+
.filter(record => isTopLevelAgent(record) && ids.includes(record.id))
|
|
2136
|
+
.flatMap(record => this.tombstoneOf(record) ?? []);
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
/**
|
|
2140
|
+
* Keep `entry` reopenable by handle, alias and id. Also how an agent that settled in an
|
|
2141
|
+
* orphaned manager (`/new`, `/reload`) stays reopenable here (#330). A name already held
|
|
2142
|
+
* by another live agent, an evicted conversation or a handle reserved for another manager's
|
|
2143
|
+
* survivor stays with its holder (as `takenHandles` has it): two conversations must not
|
|
2144
|
+
* answer to one `@name`. The entry is then not kept and its note says why.
|
|
2145
|
+
*/
|
|
2146
|
+
rememberTombstone(entry: AgentTombstone): void {
|
|
2147
|
+
const names = [entry.handle, ...(entry.alias ? [entry.alias] : [])];
|
|
2148
|
+
const reserved = new Set<string>(this.reservedHandles());
|
|
2149
|
+
const owns = (own: { id: string; handle?: string; alias?: string }, name: string) =>
|
|
2150
|
+
own.id !== entry.id && (name === own.handle || name === own.alias);
|
|
2151
|
+
const taken = names.find(
|
|
2152
|
+
name =>
|
|
2153
|
+
reserved.has(name) ||
|
|
2154
|
+
[...this.agents.values()].some(own => owns(own, name)) ||
|
|
2155
|
+
[...this.tombstones.values()].some(own => owns(own, name)),
|
|
2156
|
+
);
|
|
2157
|
+
if (taken !== undefined) {
|
|
2158
|
+
const note = this.evictedNotes.get(entry.id);
|
|
2159
|
+
if (note) note.nameTaken = taken;
|
|
2160
|
+
return;
|
|
2161
|
+
}
|
|
2162
|
+
this.storeTombstone(entry);
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
private storeTombstone(entry: AgentTombstone): void {
|
|
2166
|
+
this.tombstones.set(entry.handle, entry);
|
|
1866
2167
|
// Bound the memory a long session can accumulate. Oldest first, since the
|
|
1867
2168
|
// agent someone still wants to reach is the one they used most recently.
|
|
1868
2169
|
while (this.tombstones.size > MAX_TOMBSTONES) {
|
|
@@ -1872,24 +2173,68 @@ export class AgentManager {
|
|
|
1872
2173
|
}
|
|
1873
2174
|
|
|
1874
2175
|
/** Remember that `record` existed, so its id still answers with something better than "not found". */
|
|
1875
|
-
private noteEviction(record:
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
2176
|
+
private noteEviction(record: SettledRecord): void {
|
|
2177
|
+
// Only the top-level tools read notes, and an owned child (nested or a
|
|
2178
|
+
// workflow's) must stay unreachable from them after eviction as before it.
|
|
2179
|
+
if (!isTopLevelAgent(record)) return;
|
|
2180
|
+
this.rememberEvicted(this.evictionNote(record));
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
/** Notes for whichever of `ids` are settled here, to hand to a session that will never see these records. */
|
|
2184
|
+
evictionNotes(ids: readonly string[]): EvictedNote[] {
|
|
2185
|
+
return [...this.agents.values()].filter(isSettled).filter(record => isTopLevelAgent(record) && ids.includes(record.id)).map(record => this.evictionNote(record));
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
/** What a settled record leaves behind once it is gone from this manager. */
|
|
2189
|
+
private evictionNote(record: SettledRecord): EvictedNote {
|
|
2190
|
+
return {
|
|
1879
2191
|
id: record.id,
|
|
1880
2192
|
type: record.type,
|
|
1881
2193
|
description: record.description,
|
|
1882
2194
|
status: record.status,
|
|
1883
|
-
evictedAfterMin: Math.max(0, Math.round((this.clock.now() - completedAt) / 60_000)),
|
|
2195
|
+
evictedAfterMin: Math.max(0, Math.round((this.clock.now() - record.completedAt) / 60_000)),
|
|
1884
2196
|
transcript: record.outputFile ?? record.sessionFile,
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
2197
|
+
...(record.modelTier ? { modelTier: record.modelTier } : {}),
|
|
2198
|
+
...(record.modelFallback ? { modelFallback: record.modelFallback } : {}),
|
|
2199
|
+
};
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
/**
|
|
2203
|
+
* Keep `note` addressable by its id, bounded by age. Also how a result handed
|
|
2204
|
+
* over by an orphaned manager (`/new`, `/reload`) stays answerable here: the
|
|
2205
|
+
* record settled in a manager this one never saw (#330).
|
|
2206
|
+
*/
|
|
2207
|
+
rememberEvicted(note: EvictedNote): void {
|
|
2208
|
+
this.evictedNotes.delete(note.id);
|
|
2209
|
+
this.evictedNotes.set(note.id, note);
|
|
2210
|
+
// Insertion order is age order (the note was just re-inserted above).
|
|
2211
|
+
for (const oldest of this.evictedNotes.keys()) {
|
|
2212
|
+
if (this.evictedNotes.size <= MAX_EVICTED_NOTES) break;
|
|
1889
2213
|
this.evictedNotes.delete(oldest);
|
|
1890
2214
|
}
|
|
1891
2215
|
}
|
|
1892
2216
|
|
|
2217
|
+
/** Record that evicted `id` was reopened as `next`; bounded like the eviction notes. */
|
|
2218
|
+
linkSuccessor(id: string, next: string): void {
|
|
2219
|
+
this.successors.delete(id);
|
|
2220
|
+
this.successors.set(id, next);
|
|
2221
|
+
// One link per call, so at most one over the bound: drop the oldest.
|
|
2222
|
+
if (this.successors.size > MAX_EVICTED_NOTES) this.successors.delete(this.successors.keys().next().value as string);
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
/** Forget a reopen that never started, so `id` resolves to its tombstone again. */
|
|
2226
|
+
unlinkSuccessor(id: string): void {
|
|
2227
|
+
this.successors.delete(id);
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
/** The newest id `id` continues under, following every reopen; `id` itself when never reopened. */
|
|
2231
|
+
latestId(id: string): string {
|
|
2232
|
+
// Acyclic: every successor is a freshly generated id.
|
|
2233
|
+
let current = id;
|
|
2234
|
+
for (let next = this.successors.get(current); next !== undefined; next = this.successors.get(current)) current = next;
|
|
2235
|
+
return current;
|
|
2236
|
+
}
|
|
2237
|
+
|
|
1893
2238
|
/** What became of an evicted agent, by id; undefined for an id this session never evicted. */
|
|
1894
2239
|
getEvictedNote(id: string): EvictedNote | undefined {
|
|
1895
2240
|
return this.evictedNotes.get(id);
|
|
@@ -1904,12 +2249,21 @@ export class AgentManager {
|
|
|
1904
2249
|
private cleanup() {
|
|
1905
2250
|
const now = this.clock.now();
|
|
1906
2251
|
for (const [id, record] of this.agents) {
|
|
1907
|
-
if (record
|
|
1908
|
-
const completedAt = record
|
|
2252
|
+
if (!isSettled(record)) continue;
|
|
2253
|
+
const { completedAt } = record;
|
|
1909
2254
|
const expired = record.resultConsumed
|
|
1910
2255
|
? now - (record.consumedAt ?? completedAt) >= this.consumedTtlMs
|
|
1911
2256
|
: now - completedAt >= this.unreadTtlMs;
|
|
1912
|
-
if (expired)
|
|
2257
|
+
if (!expired) continue;
|
|
2258
|
+
const ran = record.ranThisRun === true && (record.result !== undefined || record.error !== undefined);
|
|
2259
|
+
this.removeRecord(id, record);
|
|
2260
|
+
// Evicting a result nobody read is a loss the parent must hear about. Owned
|
|
2261
|
+
// children never leave a note, and only their owner could have read them.
|
|
2262
|
+
// Nothing was lost when the parent saw the completion notice (a failure's
|
|
2263
|
+
// stays unread in FleetView), or when no run produced a result (stopped in
|
|
2264
|
+
// the queue, refused, or failed before the runner started).
|
|
2265
|
+
const note = record.resultConsumed || record.noticeDelivered || !ran ? undefined : this.evictedNotes.get(id);
|
|
2266
|
+
if (note) this.onReaped?.(note);
|
|
1913
2267
|
}
|
|
1914
2268
|
}
|
|
1915
2269
|
|
|
@@ -1921,7 +2275,7 @@ export class AgentManager {
|
|
|
1921
2275
|
*/
|
|
1922
2276
|
clearCompleted(skipUnconsumed = false): void {
|
|
1923
2277
|
for (const [id, record] of this.agents) {
|
|
1924
|
-
if (record
|
|
2278
|
+
if (!isSettled(record)) continue;
|
|
1925
2279
|
if (skipUnconsumed && !record.resultConsumed) continue;
|
|
1926
2280
|
this.removeRecord(id, record);
|
|
1927
2281
|
}
|
|
@@ -1934,6 +2288,7 @@ export class AgentManager {
|
|
|
1934
2288
|
this.tombstones.clear();
|
|
1935
2289
|
// Ids are only meaningful within the session that issued them.
|
|
1936
2290
|
this.evictedNotes.clear();
|
|
2291
|
+
this.successors.clear();
|
|
1937
2292
|
}
|
|
1938
2293
|
|
|
1939
2294
|
/** Whether any agents are still running or queued. */
|
|
@@ -1943,6 +2298,22 @@ export class AgentManager {
|
|
|
1943
2298
|
);
|
|
1944
2299
|
}
|
|
1945
2300
|
|
|
2301
|
+
/**
|
|
2302
|
+
* The session's own agents (see `isTopLevelAgent`) still running or queued,
|
|
2303
|
+
* split by whether their completion wakes the parent. A background agent's
|
|
2304
|
+
* settle sends a completion notice that starts a turn, in every join mode;
|
|
2305
|
+
* a foreground one, or an RPC spawn that omits `isBackground`, is marked
|
|
2306
|
+
* consumed at settle and notifies nobody. Owned children report through their owner, so counting
|
|
2307
|
+
* them would double-count the same work.
|
|
2308
|
+
*/
|
|
2309
|
+
ownedPending(): OwnedPending {
|
|
2310
|
+
const pending = [...this.agents.values()].filter(
|
|
2311
|
+
r => isTopLevelAgent(r) && (r.status === "running" || r.status === "queued"),
|
|
2312
|
+
);
|
|
2313
|
+
const notifying = pending.filter(r => r.isBackground).length;
|
|
2314
|
+
return { notifying, silent: pending.length - notifying };
|
|
2315
|
+
}
|
|
2316
|
+
|
|
1946
2317
|
/**
|
|
1947
2318
|
* Abort everything that cannot outlive its session and let the rest finish.
|
|
1948
2319
|
* Survivors are running top-level background agents and their nested children:
|
|
@@ -1956,19 +2327,11 @@ export class AgentManager {
|
|
|
1956
2327
|
* @returns how many agents keep running.
|
|
1957
2328
|
*/
|
|
1958
2329
|
detach(): number {
|
|
1959
|
-
const keepQueued = (
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
for (const
|
|
1964
|
-
if (keepQueued(queued.id)) continue;
|
|
1965
|
-
const record = this.agents.get(queued.id);
|
|
1966
|
-
if (record) {
|
|
1967
|
-
record.status = "stopped";
|
|
1968
|
-
record.completedAt = this.clock.now();
|
|
1969
|
-
}
|
|
1970
|
-
}
|
|
1971
|
-
this.dequeue(entry => !keepQueued(entry.id));
|
|
2330
|
+
const keepQueued = ({ record }: QueueEntry): boolean =>
|
|
2331
|
+
record.parentAgentId !== undefined && record.workflowId === undefined;
|
|
2332
|
+
const dropped = this.queue.filter(entry => !keepQueued(entry)).map(({ record }) => record);
|
|
2333
|
+
this.dequeue(entry => !keepQueued(entry));
|
|
2334
|
+
for (const record of dropped) this.stopQueued(record);
|
|
1972
2335
|
let survivors = 0;
|
|
1973
2336
|
for (const record of this.agents.values()) {
|
|
1974
2337
|
if (record.status !== "running") continue;
|
|
@@ -1988,15 +2351,12 @@ export class AgentManager {
|
|
|
1988
2351
|
abortAll(): number {
|
|
1989
2352
|
let count = 0;
|
|
1990
2353
|
// Clear queued agents first
|
|
1991
|
-
|
|
1992
|
-
const record = this.agents.get(queued.id);
|
|
1993
|
-
if (record) {
|
|
1994
|
-
record.status = "stopped";
|
|
1995
|
-
record.completedAt = this.clock.now();
|
|
1996
|
-
count++;
|
|
1997
|
-
}
|
|
1998
|
-
}
|
|
2354
|
+
const dropped = this.queue.map(({ record }) => record);
|
|
1999
2355
|
this.dequeue(() => true);
|
|
2356
|
+
for (const record of dropped) {
|
|
2357
|
+
this.stopQueued(record);
|
|
2358
|
+
count++;
|
|
2359
|
+
}
|
|
2000
2360
|
// Abort running agents
|
|
2001
2361
|
for (const record of this.agents.values()) {
|
|
2002
2362
|
if (record.status === "running") {
|