@nredd/pi-subagents 0.22.0-nredd.8 → 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 +136 -55
- package/package.json +14 -11
- package/src/abortable.ts +1 -1
- package/src/agent-file-toggle.ts +0 -1
- package/src/agent-manager.ts +360 -103
- package/src/agent-runner.ts +121 -63
- package/src/child-context.ts +19 -1
- package/src/completion-outbox.ts +22 -3
- package/src/cross-extension-rpc.ts +10 -6
- package/src/custom-agents.ts +2 -1
- package/src/default-agents.ts +19 -17
- package/src/detached-results.ts +18 -2
- package/src/enterprise-local.ts +2 -1
- package/src/error-message.ts +11 -0
- package/src/git-path.ts +66 -0
- package/src/group-join.ts +4 -10
- package/src/index.ts +731 -231
- package/src/invocation-config.ts +1 -5
- package/src/mention-clone.ts +4 -3
- package/src/model-resolver.ts +51 -14
- package/src/model-selection.ts +17 -6
- package/src/model-tiers.ts +5 -0
- package/src/nested-tools.ts +80 -21
- package/src/notification-result.ts +118 -0
- package/src/plan-mode-children.ts +176 -0
- package/src/prompts.ts +7 -2
- package/src/quota-admission.ts +16 -6
- package/src/resource-loader.ts +20 -6
- package/src/schedule.ts +65 -19
- package/src/settings.ts +21 -1
- package/src/skill-loader.ts +244 -94
- package/src/status-note.ts +28 -5
- package/src/types.ts +52 -2
- package/src/ui/agent-mention.ts +5 -2
- package/src/ui/agent-widget.ts +41 -22
- package/src/ui/conversation-viewer.ts +4 -2
- package/src/ui/fallback-flag.ts +28 -4
- package/src/ui/fleet-list.ts +89 -54
- package/src/ui/notification-view.ts +4 -1
- package/src/ui/workflow-card.ts +1 -8
- package/src/ui/workflow-dialog.ts +31 -18
- package/src/workflow/host.ts +4 -5
- package/src/workflow/meta.ts +7 -4
- package/src/workflow/progress.ts +4 -3
- package/src/workflow/runtime.ts +23 -7
- package/src/workflow/saved.ts +2 -4
- package/src/workflow/task.ts +12 -7
- package/src/workflow/tool-description.ts +2 -2
- package/src/workflow/worker-source.ts +6 -2
- package/src/worktree.ts +309 -19
package/src/agent-manager.ts
CHANGED
|
@@ -21,19 +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
34
|
import { describeModel, describePhysicalModel, describeVirtualModel, isVirtualModel, type ModelFallback } from "./model-resolver.js";
|
|
34
35
|
import { admitModelSelection, describeSelection, type ModelSelection, modelSelectionNote, selectModel } from "./model-selection.js";
|
|
35
36
|
import { type TierDisclosure, tierDisclosure } from "./model-tiers.js";
|
|
36
37
|
import { appendModelFallbackEntry, appendModelTierEntry } from "./output-file.js";
|
|
38
|
+
import { type PlanToolPolicy, queryPlanPolicy } from "./plan-mode-children.js";
|
|
37
39
|
import {
|
|
38
40
|
assertWithinBudgetDepth,
|
|
39
41
|
BUDGET_QUEUE_FULL_ERROR,
|
|
@@ -49,7 +51,7 @@ import { isStaleCtxError } from "./stale-ctx.js";
|
|
|
49
51
|
import type { AgentInvocation, AgentRecord, AgentTombstone, IsolationMode, MentionResolution, Pool, SubagentType, ThinkingLevel } from "./types.js";
|
|
50
52
|
import { addUsage, type LifetimeUsage } from "./usage.js";
|
|
51
53
|
import type { CompiledSchema } from "./workflow/json-schema.js";
|
|
52
|
-
import { cleanupWorktree, createWorktree, isWorktreeIsolationEnabled, pruneWorktrees, type WorktreeInfo } from "./worktree.js";
|
|
54
|
+
import { cleanupWorktree, createWorktree, isWorktreeIsolationEnabled, pruneWorktrees, type WorktreeCleanupResult, type WorktreeInfo } from "./worktree.js";
|
|
53
55
|
|
|
54
56
|
/**
|
|
55
57
|
* Relabel a record's model from the response that just finished. A virtual
|
|
@@ -70,8 +72,26 @@ function relabelFromResponse(record: AgentRecord, message: AssistantMessageInfo
|
|
|
70
72
|
}
|
|
71
73
|
}
|
|
72
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";
|
|
83
|
+
}
|
|
84
|
+
|
|
73
85
|
export type OnAgentComplete = (record: AgentRecord) => void;
|
|
74
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;
|
|
75
95
|
/**
|
|
76
96
|
* Fired by `steer()` once a message is delivered or queued. Every steer path
|
|
77
97
|
* goes through it except the steer tools (`steer_subagent` in index.ts, and its
|
|
@@ -175,6 +195,8 @@ export interface EvictedNote {
|
|
|
175
195
|
/** The run's model fallback, so even an evicted agent's id still discloses it. */
|
|
176
196
|
modelFallback?: ModelFallback;
|
|
177
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;
|
|
178
200
|
}
|
|
179
201
|
|
|
180
202
|
/** The text a tool returns for an id whose record was evicted. */
|
|
@@ -214,6 +236,50 @@ function assertValidSpawnCwd(cwd: unknown): asserts cwd is string | undefined |
|
|
|
214
236
|
}
|
|
215
237
|
}
|
|
216
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
|
+
|
|
217
283
|
/**
|
|
218
284
|
* Whether a record occupies one of the `maxConcurrent` background slots.
|
|
219
285
|
* Nested children don't: their parent already holds a slot, so counting (and
|
|
@@ -277,6 +343,8 @@ function occupiesForegroundSlot(
|
|
|
277
343
|
/** A start waiting in `AgentManager.queue`; see there. */
|
|
278
344
|
interface QueueEntry {
|
|
279
345
|
id: string;
|
|
346
|
+
/** The queued record; it stays in `agents` while queued, since only settled records are evicted. */
|
|
347
|
+
record: AgentRecord;
|
|
280
348
|
/** The pool it needs room in, or undefined when it waits on the budget alone. */
|
|
281
349
|
pool?: Pool;
|
|
282
350
|
start: () => Promise<void>;
|
|
@@ -331,6 +399,8 @@ interface SpawnOptions {
|
|
|
331
399
|
previousModelTier?: TierDisclosure;
|
|
332
400
|
/** Historical fallback only, never used to label the continuation. */
|
|
333
401
|
previousModelFallback?: ModelFallback;
|
|
402
|
+
/** Internal, like `reclaim`: the evicted id a reopen continues. */
|
|
403
|
+
previousId?: string;
|
|
334
404
|
|
|
335
405
|
maxTurns?: number;
|
|
336
406
|
isolated?: boolean;
|
|
@@ -401,9 +471,11 @@ interface SpawnOptions {
|
|
|
401
471
|
invocation?: AgentInvocation;
|
|
402
472
|
/** Parent abort signal — when aborted, the subagent is also stopped. */
|
|
403
473
|
signal?: AbortSignal;
|
|
474
|
+
/** Prepare the record before it can be queued or started. A throw rejects the spawn. */
|
|
475
|
+
onPrepare?: (id: string) => void;
|
|
404
476
|
/**
|
|
405
477
|
* Called synchronously once the record is in the map and its promise is set,
|
|
406
|
-
* before `onSessionCreated` fires
|
|
478
|
+
* before `onSessionCreated` fires.
|
|
407
479
|
*
|
|
408
480
|
* Carried on the options rather than parked on the manager for the duration
|
|
409
481
|
* of a spawn: with a foreground queue, `startAgent` can run at drain time,
|
|
@@ -553,6 +625,8 @@ export class AgentManager {
|
|
|
553
625
|
private readonly unreadTtlMs = retentionMs("PI_SUBAGENTS_UNREAD_TTL_MIN", UNCONSUMED_RECORD_TTL_MS);
|
|
554
626
|
private onComplete?: OnAgentComplete;
|
|
555
627
|
private onStart?: OnAgentStart;
|
|
628
|
+
private onQueuedStop?: OnAgentQueuedStop;
|
|
629
|
+
private onReaped?: OnAgentReaped;
|
|
556
630
|
private onCompact?: OnAgentCompact;
|
|
557
631
|
private onUsage?: OnAgentUsage;
|
|
558
632
|
private onSteered?: OnAgentSteered;
|
|
@@ -583,6 +657,8 @@ export class AgentManager {
|
|
|
583
657
|
|
|
584
658
|
/** Evicted records by id, oldest first (insertion order). */
|
|
585
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>();
|
|
586
662
|
|
|
587
663
|
/**
|
|
588
664
|
* Agents waiting to start, tagged with the pool they wait on (none for a
|
|
@@ -616,9 +692,13 @@ export class AgentManager {
|
|
|
616
692
|
onSteered?: OnAgentSteered,
|
|
617
693
|
private readonly clock: Clock = realClock,
|
|
618
694
|
private readonly homeDir?: string,
|
|
695
|
+
onQueuedStop?: OnAgentQueuedStop,
|
|
696
|
+
onReaped?: OnAgentReaped,
|
|
619
697
|
) {
|
|
620
698
|
this.onComplete = onComplete;
|
|
621
699
|
this.onStart = onStart;
|
|
700
|
+
this.onQueuedStop = guardObserver("queued stop", onQueuedStop);
|
|
701
|
+
this.onReaped = guardObserver("reaped", onReaped);
|
|
622
702
|
this.onCompact = guardObserver("compaction", onCompact);
|
|
623
703
|
this.onUsage = guardObserver("usage", onUsage);
|
|
624
704
|
this.onSteered = onSteered;
|
|
@@ -632,6 +712,31 @@ export class AgentManager {
|
|
|
632
712
|
this.cleanupInterval.unref?.();
|
|
633
713
|
}
|
|
634
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
|
+
|
|
635
740
|
/** Update the max concurrent background agents limit. */
|
|
636
741
|
setMaxConcurrent(n: number) {
|
|
637
742
|
this.maxConcurrent = Math.max(1, n);
|
|
@@ -699,7 +804,7 @@ export class AgentManager {
|
|
|
699
804
|
// Validate before the queue branch — a queued spawn should fail at the
|
|
700
805
|
// call, not minutes later at drain. Throw (not warn): programmatic callers
|
|
701
806
|
// can fix and retry; the RPC layer converts throws into error envelopes.
|
|
702
|
-
|
|
807
|
+
assertSpawnCwd(options.cwd, ctx.cwd);
|
|
703
808
|
const selection = options.modelSelection ?? selectModel({
|
|
704
809
|
homeDir: options.homeDir ?? this.homeDir,
|
|
705
810
|
agentType: type,
|
|
@@ -740,6 +845,7 @@ export class AgentManager {
|
|
|
740
845
|
startedAt: this.clock.now(),
|
|
741
846
|
abortController,
|
|
742
847
|
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0, cost: 0 },
|
|
848
|
+
spawnSeq: this.nextSequence(),
|
|
743
849
|
compactionCount: 0,
|
|
744
850
|
// Raw tri-state (not coerced to a boolean): true = background, false =
|
|
745
851
|
// foreground (has an inline tool-result surface), undefined = caller never
|
|
@@ -757,18 +863,22 @@ export class AgentManager {
|
|
|
757
863
|
modelTier: selection.tier ? tierDisclosure(selection.tier) : undefined,
|
|
758
864
|
previousModelTier: options.previousModelTier,
|
|
759
865
|
previousModelFallback: options.previousModelFallback,
|
|
866
|
+
previousId: options.previousId,
|
|
760
867
|
depth: options.depth ?? 1,
|
|
761
868
|
parentAgentId: options.parentAgentId,
|
|
762
869
|
workflowId: options.workflowId,
|
|
763
870
|
maxSubagentDepth: options.maxSubagentDepth,
|
|
764
871
|
rootSessionId: options.rootSessionId,
|
|
765
872
|
};
|
|
873
|
+
this.decidePlanGate(record, () => queryPlanPolicy(pi.events));
|
|
874
|
+
record.planQuery = () => queryPlanPolicy(pi.events, "restrict");
|
|
766
875
|
this.agents.set(id, record);
|
|
767
876
|
// After the insert, so `takenHandles()` already counts this record's own
|
|
768
877
|
// handle — a spawn named after its own type gets `explore-2`, not a
|
|
769
878
|
// duplicate `explore` that would make resolution ambiguous.
|
|
770
879
|
if (record.handle !== undefined && record.alias === undefined && options.name !== undefined) {
|
|
771
|
-
record.
|
|
880
|
+
record.nameBase = handleBase(options.name);
|
|
881
|
+
record.alias = assignHandle(record.nameBase, this.takenHandles());
|
|
772
882
|
}
|
|
773
883
|
|
|
774
884
|
const args: SpawnArgs = { pi, ctx, type, prompt, options };
|
|
@@ -784,6 +894,14 @@ export class AgentManager {
|
|
|
784
894
|
this.agents.delete(id);
|
|
785
895
|
throw err;
|
|
786
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
|
+
}
|
|
787
905
|
if (lease === undefined) {
|
|
788
906
|
// Queue it — started once its pool has room and the process budget a
|
|
789
907
|
// lease, whichever was missing.
|
|
@@ -792,7 +910,7 @@ export class AgentManager {
|
|
|
792
910
|
record.status = "queued";
|
|
793
911
|
// A queued record never reaches startAgent's signal wiring, so arm the
|
|
794
912
|
// parent abort here or Esc could not release the position.
|
|
795
|
-
if (!this.armQueuedAbort(
|
|
913
|
+
if (!this.armQueuedAbort(record, options.signal)) return id;
|
|
796
914
|
let release!: () => void;
|
|
797
915
|
record.startGate = new Promise<void>(resolve => { release = resolve; });
|
|
798
916
|
// Failure bookkeeping keys on whether an inline caller gets the throw,
|
|
@@ -822,18 +940,14 @@ export class AgentManager {
|
|
|
822
940
|
* replaces it with its own, and `sendToBackground` must be able to take it
|
|
823
941
|
* off, or the caller that armed it could stop a run it no longer owns.
|
|
824
942
|
*/
|
|
825
|
-
private armQueuedAbort(
|
|
943
|
+
private armQueuedAbort(record: AgentRecord, signal?: AbortSignal): boolean {
|
|
826
944
|
if (signal === undefined) return true;
|
|
827
945
|
if (signal.aborted) {
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
record.status = "stopped";
|
|
831
|
-
record.completedAt = this.clock.now();
|
|
832
|
-
}
|
|
946
|
+
record.status = "stopped";
|
|
947
|
+
record.completedAt = this.clock.now();
|
|
833
948
|
return false;
|
|
834
949
|
}
|
|
835
|
-
|
|
836
|
-
if (record) this.armParentAbort(record, signal);
|
|
950
|
+
this.armParentAbort(record, signal);
|
|
837
951
|
return true;
|
|
838
952
|
}
|
|
839
953
|
|
|
@@ -863,9 +977,8 @@ export class AgentManager {
|
|
|
863
977
|
* budget's wait queue is full.
|
|
864
978
|
*/
|
|
865
979
|
private admit(record: AgentRecord, pool: Pool | undefined): BudgetLease | undefined {
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
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;
|
|
869
982
|
if (lease === undefined && !budgetHasPendingRoom()) throw new Error(BUDGET_QUEUE_FULL_ERROR);
|
|
870
983
|
return lease;
|
|
871
984
|
}
|
|
@@ -893,6 +1006,7 @@ export class AgentManager {
|
|
|
893
1006
|
const left = new Promise<void>(resolve => { markLeft = resolve; });
|
|
894
1007
|
const entry: QueueEntry = {
|
|
895
1008
|
id,
|
|
1009
|
+
record,
|
|
896
1010
|
pool,
|
|
897
1011
|
start,
|
|
898
1012
|
release: () => {
|
|
@@ -902,7 +1016,7 @@ export class AgentManager {
|
|
|
902
1016
|
// enqueueBudget only registers; grant cannot run until the next pump,
|
|
903
1017
|
// after this entry has been initialized and published in the queue.
|
|
904
1018
|
cancel: enqueueBudget({
|
|
905
|
-
depth: record.depth
|
|
1019
|
+
depth: record.depth,
|
|
906
1020
|
ready: () => pool === undefined || this.poolHasRoom(pool),
|
|
907
1021
|
grant: (lease) => {
|
|
908
1022
|
this.queue = this.queue.filter(e => e !== entry);
|
|
@@ -957,7 +1071,7 @@ export class AgentManager {
|
|
|
957
1071
|
// session about it — the same failure reported twice.
|
|
958
1072
|
if (failAs === "foreground") markConsumed(record, this.clock.now());
|
|
959
1073
|
record.status = "error";
|
|
960
|
-
record.error =
|
|
1074
|
+
record.error = errorMessage(err);
|
|
961
1075
|
record.completedAt = this.clock.now();
|
|
962
1076
|
try { this.onComplete?.(record); } catch { /* the startup failure is the error worth reporting */ }
|
|
963
1077
|
} else {
|
|
@@ -998,10 +1112,10 @@ export class AgentManager {
|
|
|
998
1112
|
record: AgentRecord,
|
|
999
1113
|
{ pi, ctx, type, prompt, options }: SpawnArgs,
|
|
1000
1114
|
) {
|
|
1001
|
-
// Re-validate
|
|
1115
|
+
// Re-validate the cwd (caller-supplied, else the session's): queued spawns can start minutes after
|
|
1002
1116
|
// spawn()'s check, and the directory may be gone by then (TOCTOU). Same
|
|
1003
1117
|
// curated errors; drainQueue parks a throw on the record as an error.
|
|
1004
|
-
|
|
1118
|
+
assertSpawnCwd(options.cwd, ctx.cwd);
|
|
1005
1119
|
// Single resolution point for the caller-supplied cwd — the worktree base
|
|
1006
1120
|
// repo and both cleanup calls below MUST agree on this value forever.
|
|
1007
1121
|
const customCwd = options.cwd ?? undefined; // null (RPC "unset") → undefined
|
|
@@ -1032,7 +1146,8 @@ export class AgentManager {
|
|
|
1032
1146
|
try {
|
|
1033
1147
|
if (options.modelSelection?.tierBinding && !options.modelSelection.tierAdmitted) {
|
|
1034
1148
|
const block = await admitModelSelection(options.modelSelection, ctx.modelRegistry, pi.events);
|
|
1035
|
-
|
|
1149
|
+
// Admitting an unadmitted binding always resolves its `tier`.
|
|
1150
|
+
record.modelTier = tierDisclosure(options.modelSelection.tier!);
|
|
1036
1151
|
record.modelFallback = options.modelSelection.fallback;
|
|
1037
1152
|
if (block) throw new Error(`${modelSelectionNote(record)} ${describeBlock(block)} The agent was not started.`);
|
|
1038
1153
|
if (record.abortController?.signal.aborted || options.signal?.aborted) throw new Error("Agent aborted during model admission.");
|
|
@@ -1059,6 +1174,11 @@ export class AgentManager {
|
|
|
1059
1174
|
const isolation = options.workflowId !== undefined
|
|
1060
1175
|
? options.isolation
|
|
1061
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
|
+
}
|
|
1062
1182
|
let worktreeCwd: string | undefined;
|
|
1063
1183
|
if (isolation === "worktree" && isWorktreeIsolationEnabled()) {
|
|
1064
1184
|
let wt: WorktreeInfo;
|
|
@@ -1066,7 +1186,7 @@ export class AgentManager {
|
|
|
1066
1186
|
wt = await createWorktree(pi, baseCwd, id);
|
|
1067
1187
|
} catch (err) {
|
|
1068
1188
|
this.chargePool(record.chargedPool, -1);
|
|
1069
|
-
const cause =
|
|
1189
|
+
const cause = errorMessage(err);
|
|
1070
1190
|
// Only a parent-cwd spawn can be fixed by pointing `cwd` elsewhere.
|
|
1071
1191
|
const hint = customCwd === undefined
|
|
1072
1192
|
? " To isolate another repo, pass its path as the `Agent` tool's `cwd`; or omit `isolation`."
|
|
@@ -1094,7 +1214,11 @@ export class AgentManager {
|
|
|
1094
1214
|
// definition unchanged) worktree instead.
|
|
1095
1215
|
if (record.status !== "running") {
|
|
1096
1216
|
this.chargePool(record.chargedPool, -1);
|
|
1097
|
-
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
|
+
}
|
|
1098
1222
|
// After the cleanup, not before: the copy is still being removed until here.
|
|
1099
1223
|
this.releaseLease(record);
|
|
1100
1224
|
this.drainQueue();
|
|
@@ -1111,8 +1235,12 @@ export class AgentManager {
|
|
|
1111
1235
|
this.chargePool(record.chargedPool, -1);
|
|
1112
1236
|
if (record.worktree) {
|
|
1113
1237
|
try {
|
|
1114
|
-
record
|
|
1115
|
-
} 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}`);
|
|
1116
1244
|
}
|
|
1117
1245
|
throw err;
|
|
1118
1246
|
}
|
|
@@ -1125,8 +1253,10 @@ export class AgentManager {
|
|
|
1125
1253
|
else this.armParentAbort(record, options.signal);
|
|
1126
1254
|
const detach = () => this.detachParentAbort(record);
|
|
1127
1255
|
|
|
1256
|
+
record.ranThisRun = record.status === "running";
|
|
1128
1257
|
const promise = runAgent(ctx, type, prompt, {
|
|
1129
1258
|
pi,
|
|
1259
|
+
clock: this.clock,
|
|
1130
1260
|
homeDir: options.homeDir ?? this.homeDir,
|
|
1131
1261
|
agentId: id,
|
|
1132
1262
|
model: options.modelSelection?.model,
|
|
@@ -1136,6 +1266,7 @@ export class AgentManager {
|
|
|
1136
1266
|
inheritContext: options.inheritContext,
|
|
1137
1267
|
thinkingLevel: options.thinkingLevel,
|
|
1138
1268
|
structuredOutput: options.structuredOutput,
|
|
1269
|
+
planGate: record.planGate,
|
|
1139
1270
|
resumeSessionFile: options.resumeSessionFile,
|
|
1140
1271
|
nested: options.parentAgentId !== undefined,
|
|
1141
1272
|
workflow: options.workflowId !== undefined,
|
|
@@ -1172,7 +1303,7 @@ export class AgentManager {
|
|
|
1172
1303
|
nestedRuntime: {
|
|
1173
1304
|
manager: this,
|
|
1174
1305
|
parentAgentId: id,
|
|
1175
|
-
depth: record.depth
|
|
1306
|
+
depth: record.depth,
|
|
1176
1307
|
maxSubagentDepth: record.maxSubagentDepth,
|
|
1177
1308
|
},
|
|
1178
1309
|
onSessionCreated: (session) => {
|
|
@@ -1254,8 +1385,8 @@ export class AgentManager {
|
|
|
1254
1385
|
} catch { /* ignore — never block cleanup */ }
|
|
1255
1386
|
}
|
|
1256
1387
|
const wtResult = await cleanupWorktree(pi, baseCwd, record.worktree, options.description);
|
|
1257
|
-
record
|
|
1258
|
-
if (wtResult.hasChanges && wtResult.branch) {
|
|
1388
|
+
recordWorktreeCleanup(record, wtResult);
|
|
1389
|
+
if (!wtResult.error && wtResult.hasChanges && wtResult.branch) {
|
|
1259
1390
|
// With a caller-supplied cwd the branch lives in THAT repo, not the
|
|
1260
1391
|
// parent session's — say so, or the orchestrator merges in the wrong repo.
|
|
1261
1392
|
// Named by its toplevel: the cwd may be a subdirectory of it.
|
|
@@ -1264,7 +1395,7 @@ export class AgentManager {
|
|
|
1264
1395
|
// Appended to the prose only. A structured child's caller parses
|
|
1265
1396
|
// `structuredJson`, which stays untouched — but `result` is also
|
|
1266
1397
|
// what a human reads, so the note still belongs on it.
|
|
1267
|
-
record.result =
|
|
1398
|
+
record.result = responseText +
|
|
1268
1399
|
`\n\n---\nChanges saved to branch \`${wtResult.branch}\`${repoNote}. Merge with: \`git merge ${wtResult.branch}\`${customCwd !== undefined ? ` (run in \`${repo}\`)` : ""}`;
|
|
1269
1400
|
}
|
|
1270
1401
|
}
|
|
@@ -1279,7 +1410,7 @@ export class AgentManager {
|
|
|
1279
1410
|
if (record.status !== "stopped") {
|
|
1280
1411
|
record.status = "error";
|
|
1281
1412
|
}
|
|
1282
|
-
record.error =
|
|
1413
|
+
record.error = errorMessage(err);
|
|
1283
1414
|
record.completedAt ??= this.clock.now();
|
|
1284
1415
|
|
|
1285
1416
|
detach();
|
|
@@ -1294,7 +1425,7 @@ export class AgentManager {
|
|
|
1294
1425
|
if (record.worktree) {
|
|
1295
1426
|
try {
|
|
1296
1427
|
const wtResult = await cleanupWorktree(pi, baseCwd, record.worktree, options.description);
|
|
1297
|
-
record
|
|
1428
|
+
recordWorktreeCleanup(record, wtResult);
|
|
1298
1429
|
} catch { /* ignore cleanup errors */ }
|
|
1299
1430
|
}
|
|
1300
1431
|
|
|
@@ -1339,16 +1470,9 @@ export class AgentManager {
|
|
|
1339
1470
|
// Nobody waits on a settled run; a later foreground resume must not look
|
|
1340
1471
|
// like a spawn `sendToBackground` could hand off.
|
|
1341
1472
|
record.blocking = false;
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
//
|
|
1345
|
-
// background settle has always drained, even for a nested child that held
|
|
1346
|
-
// no slot — so that path is unchanged whether or not the foreground pool is
|
|
1347
|
-
// on. The `pool` half only adds the drain a freed FOREGROUND slot needs.
|
|
1348
|
-
// A drain with nothing freed is a no-op anyway, but "no-op" is a claim
|
|
1349
|
-
// about reachability, and matching the old condition needs no such claim.
|
|
1350
|
-
// The lease half: a freed budget slot may start work in any manager.
|
|
1351
|
-
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.
|
|
1352
1476
|
this.releaseLease(record);
|
|
1353
1477
|
|
|
1354
1478
|
try {
|
|
@@ -1361,7 +1485,7 @@ export class AgentManager {
|
|
|
1361
1485
|
// Drained even when an unguarded `onComplete` throws: the slot and lease
|
|
1362
1486
|
// are already free, and a waiter that misses this drain may be the child
|
|
1363
1487
|
// a running parent is blocked on, with nothing else left to pump.
|
|
1364
|
-
|
|
1488
|
+
this.drainQueue();
|
|
1365
1489
|
}
|
|
1366
1490
|
}
|
|
1367
1491
|
|
|
@@ -1412,8 +1536,7 @@ export class AgentManager {
|
|
|
1412
1536
|
* Returns { id, record } so callers can access the agent ID.
|
|
1413
1537
|
*
|
|
1414
1538
|
* @param onSpawned - Called synchronously once the run is kicked off, before
|
|
1415
|
-
* onSessionCreated fires.
|
|
1416
|
-
* streamToOutputFile can pick it up.
|
|
1539
|
+
* onSessionCreated fires.
|
|
1417
1540
|
*/
|
|
1418
1541
|
async spawnAndWait(
|
|
1419
1542
|
pi: ExtensionAPI,
|
|
@@ -1461,7 +1584,7 @@ export class AgentManager {
|
|
|
1461
1584
|
// one contract rather than letting queue pressure decide whether a strict
|
|
1462
1585
|
// worktree failure throws or returns as a result.
|
|
1463
1586
|
if (record.promise === undefined && record.status === "error") {
|
|
1464
|
-
throw new Error(record.error
|
|
1587
|
+
throw new Error(record.error);
|
|
1465
1588
|
}
|
|
1466
1589
|
return { id, record };
|
|
1467
1590
|
}
|
|
@@ -1527,6 +1650,9 @@ export class AgentManager {
|
|
|
1527
1650
|
// both paths flip the status synchronously, so a second call made in the
|
|
1528
1651
|
// same tick is refused too. The caller decides whether to wait or steer.
|
|
1529
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!);
|
|
1530
1656
|
|
|
1531
1657
|
// Background resume: settle asynchronously and notify on completion exactly
|
|
1532
1658
|
// like a background spawn, returning immediately with the record still
|
|
@@ -1542,6 +1668,8 @@ export class AgentManager {
|
|
|
1542
1668
|
|
|
1543
1669
|
record.isBackground = true;
|
|
1544
1670
|
record.resultConsumed = false;
|
|
1671
|
+
record.noticeDelivered = undefined;
|
|
1672
|
+
record.ranThisRun = false;
|
|
1545
1673
|
record.consumedAt = undefined;
|
|
1546
1674
|
record.result = undefined;
|
|
1547
1675
|
record.error = undefined;
|
|
@@ -1558,7 +1686,7 @@ export class AgentManager {
|
|
|
1558
1686
|
if (occupiesPoolSlot(record)) this.runningBackground--;
|
|
1559
1687
|
this.releaseLease(record);
|
|
1560
1688
|
record.status = "error";
|
|
1561
|
-
record.error =
|
|
1689
|
+
record.error = errorMessage(err);
|
|
1562
1690
|
record.completedAt = this.clock.now();
|
|
1563
1691
|
this.drainQueue();
|
|
1564
1692
|
throw err;
|
|
@@ -1589,6 +1717,8 @@ export class AgentManager {
|
|
|
1589
1717
|
// its limit; the caller's signal (or `/agents` stop) releases the wait.
|
|
1590
1718
|
// No pool: the foreground pool does not cover resumes (see the README).
|
|
1591
1719
|
const lease = this.admit(record, undefined);
|
|
1720
|
+
record.ranThisRun = false;
|
|
1721
|
+
record.noticeDelivered = undefined;
|
|
1592
1722
|
record.status = lease === undefined ? "queued" : "running";
|
|
1593
1723
|
record.budgetLease = lease;
|
|
1594
1724
|
record.startedAt = this.clock.now();
|
|
@@ -1631,13 +1761,17 @@ export class AgentManager {
|
|
|
1631
1761
|
record.status = "stopped";
|
|
1632
1762
|
record.completedAt = this.clock.now();
|
|
1633
1763
|
}
|
|
1764
|
+
markConsumed(record, this.clock.now());
|
|
1634
1765
|
this.detachParentAbort(record);
|
|
1635
1766
|
return record;
|
|
1636
1767
|
}
|
|
1637
1768
|
}
|
|
1638
1769
|
|
|
1639
1770
|
try {
|
|
1771
|
+
// Positive evidence for the reap notice: only a live signal means the prompt runs.
|
|
1772
|
+
record.ranThisRun = record.status === "running";
|
|
1640
1773
|
this.settleOutcome(record, await resumeAgent(record.session, prompt, {
|
|
1774
|
+
clock: this.clock,
|
|
1641
1775
|
type: record.type,
|
|
1642
1776
|
maxTurns: options?.maxTurns ?? record.invocation?.maxTurns,
|
|
1643
1777
|
onTurnEnd: options?.onTurnEnd,
|
|
@@ -1664,6 +1798,8 @@ export class AgentManager {
|
|
|
1664
1798
|
this.failResume(record, err);
|
|
1665
1799
|
}
|
|
1666
1800
|
this.detachParentAbort(record);
|
|
1801
|
+
// The caller gets the settled record inline, whatever the outcome.
|
|
1802
|
+
markConsumed(record, this.clock.now());
|
|
1667
1803
|
|
|
1668
1804
|
// Same contract as the spawn settle paths: children spawned during the
|
|
1669
1805
|
// resumed turn must not outlive it — nothing else can see or reach them.
|
|
@@ -1704,7 +1840,7 @@ export class AgentManager {
|
|
|
1704
1840
|
private failResume(record: AgentRecord, err: unknown): void {
|
|
1705
1841
|
if (record.status !== "stopped") {
|
|
1706
1842
|
record.status = "error";
|
|
1707
|
-
record.error =
|
|
1843
|
+
record.error = errorMessage(err);
|
|
1708
1844
|
}
|
|
1709
1845
|
record.completedAt ??= this.clock.now();
|
|
1710
1846
|
}
|
|
@@ -1769,7 +1905,9 @@ export class AgentManager {
|
|
|
1769
1905
|
this.drainQueue();
|
|
1770
1906
|
};
|
|
1771
1907
|
|
|
1908
|
+
record.ranThisRun = true;
|
|
1772
1909
|
const promise = resumeAgent(record.session, prompt, {
|
|
1910
|
+
clock: this.clock,
|
|
1773
1911
|
type: record.type,
|
|
1774
1912
|
maxTurns: options.maxTurns ?? record.invocation?.maxTurns,
|
|
1775
1913
|
onTurnEnd: options.onTurnEnd,
|
|
@@ -1835,9 +1973,15 @@ export class AgentManager {
|
|
|
1835
1973
|
return this.agents.get(id);
|
|
1836
1974
|
}
|
|
1837
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
|
+
|
|
1838
1982
|
/** Handles already in use, so a fresh spawn can pick an unclaimed one. */
|
|
1839
1983
|
private takenHandles(): Set<string> {
|
|
1840
|
-
const taken = new Set<string>();
|
|
1984
|
+
const taken = new Set<string>(this.reservedHandles());
|
|
1841
1985
|
for (const record of this.agents.values()) {
|
|
1842
1986
|
if (record.handle) taken.add(record.handle);
|
|
1843
1987
|
if (record.alias) taken.add(record.alias);
|
|
@@ -1853,23 +1997,19 @@ export class AgentManager {
|
|
|
1853
1997
|
}
|
|
1854
1998
|
|
|
1855
1999
|
/**
|
|
1856
|
-
* Resolve an `@name` from the prompt. Matches a top-level agent's handle
|
|
1857
|
-
* case-insensitively,
|
|
1858
|
-
*
|
|
1859
|
-
* 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.
|
|
1860
2003
|
*/
|
|
1861
2004
|
resolveMention(name: string): MentionResolution | undefined {
|
|
1862
2005
|
const wanted = name.toLowerCase();
|
|
1863
|
-
let fallback: AgentRecord | undefined;
|
|
1864
2006
|
for (const record of this.agents.values()) {
|
|
1865
2007
|
if (record.parentAgentId !== undefined) continue;
|
|
1866
|
-
// Handle and alias share one namespace
|
|
1867
|
-
//
|
|
1868
|
-
|
|
1869
|
-
if (record.
|
|
1870
|
-
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 };
|
|
1871
2012
|
}
|
|
1872
|
-
if (fallback) return { kind: "live", record: fallback };
|
|
1873
2013
|
const byId = this.agents.get(name);
|
|
1874
2014
|
if (byId?.parentAgentId === undefined && byId !== undefined) return { kind: "live", record: byId };
|
|
1875
2015
|
// Only once nothing live answers: a tombstone is a conversation to reopen,
|
|
@@ -1906,7 +2046,14 @@ export class AgentManager {
|
|
|
1906
2046
|
);
|
|
1907
2047
|
}
|
|
1908
2048
|
|
|
1909
|
-
|
|
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 {
|
|
1910
2057
|
const record = this.agents.get(id);
|
|
1911
2058
|
if (!record) return false;
|
|
1912
2059
|
|
|
@@ -1915,20 +2062,29 @@ export class AgentManager {
|
|
|
1915
2062
|
// done; a blocking caller learns of the stop from its own tool result.
|
|
1916
2063
|
if (record.status === "queued") {
|
|
1917
2064
|
this.dequeue(q => q.id === id);
|
|
1918
|
-
record.
|
|
1919
|
-
|
|
2065
|
+
record.stoppedBy = by;
|
|
2066
|
+
this.stopQueued(record);
|
|
1920
2067
|
return true;
|
|
1921
2068
|
}
|
|
1922
2069
|
|
|
1923
2070
|
if (record.status !== "running") return false;
|
|
2071
|
+
// Before the abort: the run settles asynchronously and its notice reads this.
|
|
2072
|
+
record.stoppedBy = by;
|
|
1924
2073
|
record.abortController?.abort();
|
|
1925
2074
|
record.status = "stopped";
|
|
1926
2075
|
record.completedAt = this.clock.now();
|
|
1927
2076
|
return true;
|
|
1928
2077
|
}
|
|
1929
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
|
+
|
|
1930
2086
|
/** Dispose a record's session and remove it from the map. */
|
|
1931
|
-
private removeRecord(id: string, record:
|
|
2087
|
+
private removeRecord(id: string, record: SettledRecord): void {
|
|
1932
2088
|
this.tombstone(record);
|
|
1933
2089
|
this.noteEviction(record);
|
|
1934
2090
|
const session = record.session;
|
|
@@ -1951,19 +2107,63 @@ export class AgentManager {
|
|
|
1951
2107
|
* addressed by and a session file to reopen — an in-memory session leaves no
|
|
1952
2108
|
* transcript, so the mention would have nothing to continue from.
|
|
1953
2109
|
*/
|
|
1954
|
-
private tombstone(record:
|
|
1955
|
-
|
|
1956
|
-
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 {
|
|
1957
2119
|
handle: record.handle,
|
|
1958
2120
|
alias: record.alias,
|
|
1959
2121
|
id: record.id,
|
|
1960
2122
|
type: record.type,
|
|
1961
2123
|
description: record.description,
|
|
1962
2124
|
sessionFile: record.sessionFile,
|
|
1963
|
-
completedAt: record.completedAt
|
|
2125
|
+
completedAt: record.completedAt,
|
|
1964
2126
|
...(record.modelTier ? { modelTier: record.modelTier } : {}),
|
|
1965
2127
|
...(record.modelFallback ? { modelFallback: record.modelFallback } : {}),
|
|
1966
|
-
}
|
|
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);
|
|
1967
2167
|
// Bound the memory a long session can accumulate. Oldest first, since the
|
|
1968
2168
|
// agent someone still wants to reach is the one they used most recently.
|
|
1969
2169
|
while (this.tombstones.size > MAX_TOMBSTONES) {
|
|
@@ -1973,26 +2173,68 @@ export class AgentManager {
|
|
|
1973
2173
|
}
|
|
1974
2174
|
|
|
1975
2175
|
/** Remember that `record` existed, so its id still answers with something better than "not found". */
|
|
1976
|
-
private noteEviction(record:
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
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 {
|
|
1980
2191
|
id: record.id,
|
|
1981
2192
|
type: record.type,
|
|
1982
2193
|
description: record.description,
|
|
1983
2194
|
status: record.status,
|
|
1984
|
-
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)),
|
|
1985
2196
|
transcript: record.outputFile ?? record.sessionFile,
|
|
1986
2197
|
...(record.modelTier ? { modelTier: record.modelTier } : {}),
|
|
1987
2198
|
...(record.modelFallback ? { modelFallback: record.modelFallback } : {}),
|
|
1988
|
-
}
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
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;
|
|
1992
2213
|
this.evictedNotes.delete(oldest);
|
|
1993
2214
|
}
|
|
1994
2215
|
}
|
|
1995
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
|
+
|
|
1996
2238
|
/** What became of an evicted agent, by id; undefined for an id this session never evicted. */
|
|
1997
2239
|
getEvictedNote(id: string): EvictedNote | undefined {
|
|
1998
2240
|
return this.evictedNotes.get(id);
|
|
@@ -2007,12 +2249,21 @@ export class AgentManager {
|
|
|
2007
2249
|
private cleanup() {
|
|
2008
2250
|
const now = this.clock.now();
|
|
2009
2251
|
for (const [id, record] of this.agents) {
|
|
2010
|
-
if (record
|
|
2011
|
-
const completedAt = record
|
|
2252
|
+
if (!isSettled(record)) continue;
|
|
2253
|
+
const { completedAt } = record;
|
|
2012
2254
|
const expired = record.resultConsumed
|
|
2013
2255
|
? now - (record.consumedAt ?? completedAt) >= this.consumedTtlMs
|
|
2014
2256
|
: now - completedAt >= this.unreadTtlMs;
|
|
2015
|
-
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);
|
|
2016
2267
|
}
|
|
2017
2268
|
}
|
|
2018
2269
|
|
|
@@ -2024,7 +2275,7 @@ export class AgentManager {
|
|
|
2024
2275
|
*/
|
|
2025
2276
|
clearCompleted(skipUnconsumed = false): void {
|
|
2026
2277
|
for (const [id, record] of this.agents) {
|
|
2027
|
-
if (record
|
|
2278
|
+
if (!isSettled(record)) continue;
|
|
2028
2279
|
if (skipUnconsumed && !record.resultConsumed) continue;
|
|
2029
2280
|
this.removeRecord(id, record);
|
|
2030
2281
|
}
|
|
@@ -2037,6 +2288,7 @@ export class AgentManager {
|
|
|
2037
2288
|
this.tombstones.clear();
|
|
2038
2289
|
// Ids are only meaningful within the session that issued them.
|
|
2039
2290
|
this.evictedNotes.clear();
|
|
2291
|
+
this.successors.clear();
|
|
2040
2292
|
}
|
|
2041
2293
|
|
|
2042
2294
|
/** Whether any agents are still running or queued. */
|
|
@@ -2046,6 +2298,22 @@ export class AgentManager {
|
|
|
2046
2298
|
);
|
|
2047
2299
|
}
|
|
2048
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
|
+
|
|
2049
2317
|
/**
|
|
2050
2318
|
* Abort everything that cannot outlive its session and let the rest finish.
|
|
2051
2319
|
* Survivors are running top-level background agents and their nested children:
|
|
@@ -2059,19 +2327,11 @@ export class AgentManager {
|
|
|
2059
2327
|
* @returns how many agents keep running.
|
|
2060
2328
|
*/
|
|
2061
2329
|
detach(): number {
|
|
2062
|
-
const keepQueued = (
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
for (const
|
|
2067
|
-
if (keepQueued(queued.id)) continue;
|
|
2068
|
-
const record = this.agents.get(queued.id);
|
|
2069
|
-
if (record) {
|
|
2070
|
-
record.status = "stopped";
|
|
2071
|
-
record.completedAt = this.clock.now();
|
|
2072
|
-
}
|
|
2073
|
-
}
|
|
2074
|
-
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);
|
|
2075
2335
|
let survivors = 0;
|
|
2076
2336
|
for (const record of this.agents.values()) {
|
|
2077
2337
|
if (record.status !== "running") continue;
|
|
@@ -2091,15 +2351,12 @@ export class AgentManager {
|
|
|
2091
2351
|
abortAll(): number {
|
|
2092
2352
|
let count = 0;
|
|
2093
2353
|
// Clear queued agents first
|
|
2094
|
-
|
|
2095
|
-
const record = this.agents.get(queued.id);
|
|
2096
|
-
if (record) {
|
|
2097
|
-
record.status = "stopped";
|
|
2098
|
-
record.completedAt = this.clock.now();
|
|
2099
|
-
count++;
|
|
2100
|
-
}
|
|
2101
|
-
}
|
|
2354
|
+
const dropped = this.queue.map(({ record }) => record);
|
|
2102
2355
|
this.dequeue(() => true);
|
|
2356
|
+
for (const record of dropped) {
|
|
2357
|
+
this.stopQueued(record);
|
|
2358
|
+
count++;
|
|
2359
|
+
}
|
|
2103
2360
|
// Abort running agents
|
|
2104
2361
|
for (const record of this.agents.values()) {
|
|
2105
2362
|
if (record.status === "running") {
|