@signalridge/pi-subagents 1.5.0 → 1.7.0
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/CHANGELOG.md +44 -0
- package/README.md +7 -4
- package/package.json +7 -6
- package/src/agent-color.ts +188 -0
- package/src/agent-file-toggle.ts +8 -0
- package/src/agent-manager.ts +793 -62
- package/src/agent-runner.ts +313 -21
- package/src/agent-tiers.ts +82 -3
- package/src/agent-types.ts +3 -0
- package/src/ask-tools.ts +114 -0
- package/src/cross-extension-rpc.ts +16 -5
- package/src/custom-agents.ts +67 -2
- package/src/default-agents.ts +6 -5
- package/src/gate.ts +0 -0
- package/src/index.ts +3165 -1195
- package/src/mention-clone.ts +196 -0
- package/src/mention.ts +141 -0
- package/src/output-file.ts +23 -1
- package/src/settings.ts +159 -3
- package/src/supervisor.ts +115 -0
- package/src/types.ts +59 -2
- package/src/ui/agent-mention.ts +163 -0
- package/src/ui/conversation-viewer.ts +5 -4
- package/src/ui/fleet-list.ts +9 -7
- package/src/worktree.ts +128 -648
package/src/agent-manager.ts
CHANGED
|
@@ -20,12 +20,22 @@ import {
|
|
|
20
20
|
INTERNAL_AGENT_CONFIG_OVERRIDE,
|
|
21
21
|
type InternalAgentConfigOverride,
|
|
22
22
|
} from "./internal-run.js";
|
|
23
|
+
import { assignHandle, handleBase } from "./mention.js";
|
|
23
24
|
import { shutdownAndDisposeSession } from "./session-lifecycle.js";
|
|
24
25
|
import type { WorkflowThinking } from "./settings.js";
|
|
25
|
-
import type { AgentInvocation, AgentOwner, AgentRecord, AgentRecordSnapshot, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
|
|
26
|
+
import type { AgentInvocation, AgentOwner, AgentRecord, AgentRecordSnapshot, IsolationMode, ResumableAgentEntry, SubagentType, ThinkingLevel } from "./types.js";
|
|
26
27
|
import { addUsage } from "./usage.js";
|
|
27
28
|
import type { WorkflowTierResolutionSnapshot } from "./workflow-tiers.js";
|
|
28
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
cleanupWorktree,
|
|
31
|
+
cleanupWorktreeAsync,
|
|
32
|
+
createWorktree,
|
|
33
|
+
isWorktreeIsolationEnabled,
|
|
34
|
+
pruneWorktrees,
|
|
35
|
+
pruneWorktreesAsync,
|
|
36
|
+
type WorktreeCleanupResult,
|
|
37
|
+
type WorktreeInfo,
|
|
38
|
+
} from "./worktree.js";
|
|
29
39
|
|
|
30
40
|
export type OnAgentComplete = (record: AgentRecord) => void;
|
|
31
41
|
export type OnAgentCreated = (record: AgentRecord) => void;
|
|
@@ -42,6 +52,15 @@ export interface WorktreeCleanupFailure {
|
|
|
42
52
|
|
|
43
53
|
/** Default max concurrent background agents. */
|
|
44
54
|
const DEFAULT_MAX_CONCURRENT = 4;
|
|
55
|
+
/** Bound on the resumable (`@handle`) index. */
|
|
56
|
+
const MAX_RESUMABLE_ENTRIES = 128;
|
|
57
|
+
/**
|
|
58
|
+
* Cumulative descendants one top-level agent may start, over its whole life.
|
|
59
|
+
*
|
|
60
|
+
* High enough that no ordinary delegation notices it, low enough that a runaway
|
|
61
|
+
* fan-out is caught by a number here rather than by the account's rate limit.
|
|
62
|
+
*/
|
|
63
|
+
export const DEFAULT_MAX_SUBAGENT_SPAWNS_PER_BRANCH = 64;
|
|
45
64
|
/**
|
|
46
65
|
* Nested worktree cleanup waits for cooperative children, but a provider that
|
|
47
66
|
* ignores abort must not hold its parent forever. Timed-out descendants are
|
|
@@ -157,15 +176,18 @@ function snapshotAgentRecord(record: AgentRecord): AgentRecordSnapshot {
|
|
|
157
176
|
|
|
158
177
|
const WORKTREE_FAILURE_DIAGNOSTIC_LIMIT = 2_000;
|
|
159
178
|
|
|
160
|
-
function shellQuote(value: string): string {
|
|
161
|
-
|
|
179
|
+
function shellQuote(value: string | undefined): string {
|
|
180
|
+
if (!value) return "''";
|
|
181
|
+
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
162
182
|
}
|
|
163
183
|
|
|
164
|
-
function worktreeRecoveryCommands(cwd
|
|
184
|
+
function worktreeRecoveryCommands(cwd?: string, path?: string): readonly string[] {
|
|
185
|
+
const c = cwd ? shellQuote(cwd) : "'.'";
|
|
186
|
+
const p = path ? shellQuote(path) : "''";
|
|
165
187
|
return Object.freeze([
|
|
166
|
-
`git -C ${
|
|
167
|
-
`rm -rf -- ${
|
|
168
|
-
`git -C ${
|
|
188
|
+
`git -C ${c} worktree remove --force ${p}`,
|
|
189
|
+
`rm -rf -- ${p}`,
|
|
190
|
+
`git -C ${c} worktree prune`,
|
|
169
191
|
]);
|
|
170
192
|
}
|
|
171
193
|
|
|
@@ -224,6 +246,9 @@ const MANAGED_TEXT_LIMIT = 8_000;
|
|
|
224
246
|
const MANAGED_ERROR_LIMIT = 2_000;
|
|
225
247
|
const MANAGED_PATH_LIMIT = 2_000;
|
|
226
248
|
const MANAGED_MAX_TIMESTAMP = 8_640_000_000_000_000;
|
|
249
|
+
const MANAGED_PERSIST_RETRY_INITIAL_DELAY_MS = 25;
|
|
250
|
+
const MANAGED_PERSIST_RETRY_MAX_DELAY_MS = 2_000;
|
|
251
|
+
const MANAGED_PERSIST_RETRY_MAX_ATTEMPTS = 8;
|
|
227
252
|
|
|
228
253
|
export type ManagedSpawnState = "queued" | "running" | "completed" | "failed" | "stopped" | "interrupted";
|
|
229
254
|
|
|
@@ -253,6 +278,10 @@ export interface ManagedSpawnTombstone {
|
|
|
253
278
|
owner: AgentOwner;
|
|
254
279
|
/** Semantic tier requested by the workflow; resolution remains internal. */
|
|
255
280
|
tier?: WorkflowTier;
|
|
281
|
+
/** Named managed thread, when this spawn re-enters a sequential session. */
|
|
282
|
+
thread?: string;
|
|
283
|
+
/** Effective policy fingerprint that makes thread reuse safe across calls/reloads. */
|
|
284
|
+
threadPolicyFingerprint?: string;
|
|
256
285
|
/** Internal audit snapshot of the resolved model/thinking policy. */
|
|
257
286
|
tierSnapshot?: WorkflowTierResolutionSnapshot;
|
|
258
287
|
state: ManagedSpawnState;
|
|
@@ -327,7 +356,30 @@ function normalizeManagedSpawnRequest(raw: unknown): ManagedSpawnRequest {
|
|
|
327
356
|
return parseManagedSpawnRequest(raw);
|
|
328
357
|
}
|
|
329
358
|
|
|
330
|
-
function managedFingerprint(request: ManagedSpawnRequest): string {
|
|
359
|
+
function managedFingerprint(request: ManagedSpawnRequest, policyFingerprint?: string): string {
|
|
360
|
+
return createHash("sha256")
|
|
361
|
+
.update(JSON.stringify([
|
|
362
|
+
request.type,
|
|
363
|
+
request.prompt,
|
|
364
|
+
request.description,
|
|
365
|
+
...(request.tier === undefined ? [] : [request.tier]),
|
|
366
|
+
request.model,
|
|
367
|
+
request.thinking,
|
|
368
|
+
request.toolset,
|
|
369
|
+
request.excludeTools,
|
|
370
|
+
request.isolation,
|
|
371
|
+
request.thread,
|
|
372
|
+
request.owner.extension,
|
|
373
|
+
request.owner.runId,
|
|
374
|
+
request.owner.nodeId,
|
|
375
|
+
request.owner.attemptId,
|
|
376
|
+
policyFingerprint,
|
|
377
|
+
]))
|
|
378
|
+
.digest("hex");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Exact schema-v1 identity; do not add newer policy fields to this algorithm. */
|
|
382
|
+
function managedLegacyFingerprintV1(request: ManagedSpawnRequest): string {
|
|
331
383
|
return createHash("sha256")
|
|
332
384
|
.update(JSON.stringify([
|
|
333
385
|
request.type,
|
|
@@ -342,6 +394,34 @@ function managedFingerprint(request: ManagedSpawnRequest): string {
|
|
|
342
394
|
.digest("hex");
|
|
343
395
|
}
|
|
344
396
|
|
|
397
|
+
/**
|
|
398
|
+
* Policy identity for a managed spawn. Prompts and owner attempt identities
|
|
399
|
+
* intentionally do not participate: a thread is allowed to receive new work,
|
|
400
|
+
* but its session cannot safely change model/tool/isolation policy.
|
|
401
|
+
*/
|
|
402
|
+
function managedThreadPolicyFingerprint(request: ManagedSpawnRequest, policy: ManagedSpawnPolicy): string {
|
|
403
|
+
const model = policy.model as { provider?: unknown; id?: unknown } | undefined;
|
|
404
|
+
const excludeTools = [...new Set([...(request.excludeTools ?? []), ...(policy.excludeTools ?? [])])].sort();
|
|
405
|
+
return createHash("sha256")
|
|
406
|
+
.update(JSON.stringify([
|
|
407
|
+
request.type,
|
|
408
|
+
request.tier,
|
|
409
|
+
request.model,
|
|
410
|
+
request.thinking === "off" ? undefined : request.thinking,
|
|
411
|
+
request.toolset ?? policy.toolset,
|
|
412
|
+
excludeTools,
|
|
413
|
+
request.isolation ?? policy.isolation,
|
|
414
|
+
policy.maxTurns,
|
|
415
|
+
policy.isolated,
|
|
416
|
+
policy.inheritContext,
|
|
417
|
+
policy.thinkingLevel,
|
|
418
|
+
policy.policyFingerprint,
|
|
419
|
+
model?.provider,
|
|
420
|
+
model?.id,
|
|
421
|
+
]))
|
|
422
|
+
.digest("hex");
|
|
423
|
+
}
|
|
424
|
+
|
|
345
425
|
function isManagedState(value: unknown): value is ManagedSpawnState {
|
|
346
426
|
return value === "queued" || value === "running" || value === "completed" || value === "failed" || value === "stopped" || value === "interrupted";
|
|
347
427
|
}
|
|
@@ -448,6 +528,11 @@ function parseManagedTombstone(raw: unknown): ManagedSpawnTombstone | undefined
|
|
|
448
528
|
if (!isManagedState(state)) return undefined;
|
|
449
529
|
const terminalValue = raw.terminal;
|
|
450
530
|
const persistedCompactionCount = raw.compactionCount;
|
|
531
|
+
const thread = raw.thread === undefined ? undefined : boundedManagedString(raw.thread, "thread", 128);
|
|
532
|
+
const threadPolicyFingerprint = raw.threadPolicyFingerprint === undefined
|
|
533
|
+
? undefined
|
|
534
|
+
: boundedManagedString(raw.threadPolicyFingerprint, "threadPolicyFingerprint", 128);
|
|
535
|
+
if ((thread === undefined) !== (threadPolicyFingerprint === undefined)) return undefined;
|
|
451
536
|
const tier = raw.tier;
|
|
452
537
|
if (tier !== undefined && !isWorkflowTier(tier)) return undefined;
|
|
453
538
|
const tierSnapshotValue = raw.tierSnapshot;
|
|
@@ -503,6 +588,7 @@ function parseManagedTombstone(raw: unknown): ManagedSpawnTombstone | undefined
|
|
|
503
588
|
description: boundedManagedString(raw.description, "description", 512),
|
|
504
589
|
owner: normalizeManagedOwner(raw.owner),
|
|
505
590
|
...(tier === undefined ? {} : { tier }),
|
|
591
|
+
...(thread === undefined ? {} : { thread, threadPolicyFingerprint }),
|
|
506
592
|
...(tierSnapshot ? { tierSnapshot } : {}),
|
|
507
593
|
state,
|
|
508
594
|
createdAt,
|
|
@@ -526,6 +612,12 @@ export interface SpawnOptions {
|
|
|
526
612
|
tier?: WorkflowTier;
|
|
527
613
|
/** User-named model tier; resolved by pi-subagents at session start. */
|
|
528
614
|
agentTier?: string;
|
|
615
|
+
/** Optional toolset hint; concrete tool availability remains agent-configured. */
|
|
616
|
+
toolset?: string;
|
|
617
|
+
/** Additional tool names denied for this invocation. */
|
|
618
|
+
excludeTools?: string[];
|
|
619
|
+
/** Named sequential-thread hint for workflow orchestration. */
|
|
620
|
+
thread?: string;
|
|
529
621
|
isBackground?: boolean;
|
|
530
622
|
/**
|
|
531
623
|
* Skip the maxConcurrent queue check for this spawn — start immediately even
|
|
@@ -576,13 +668,48 @@ export interface SpawnOptions {
|
|
|
576
668
|
configCwd?: string;
|
|
577
669
|
/** Root session id, inherited by nested launches so transcripts stay grouped. */
|
|
578
670
|
rootSessionId?: string;
|
|
671
|
+
/**
|
|
672
|
+
* Reuse an existing mention handle instead of allocating a fresh one. Set when
|
|
673
|
+
* a resumable entry is reopened, so `@handle` keeps addressing the same
|
|
674
|
+
* conversation across the eviction. Bypasses the uniqueness search on purpose:
|
|
675
|
+
* the handle is already spoken for by the entry being reopened.
|
|
676
|
+
*/
|
|
677
|
+
reclaimHandle?: string;
|
|
678
|
+
/**
|
|
679
|
+
* Reopen the conversation in this session file rather than starting a new
|
|
680
|
+
* one. Like `reclaimHandle`, an internal capability: the value always comes
|
|
681
|
+
* from a resumable entry this extension wrote, never from a tool argument or
|
|
682
|
+
* an RPC payload — a forged path would let a spawn read an unrelated session.
|
|
683
|
+
*/
|
|
684
|
+
resumeSessionFile?: string;
|
|
579
685
|
}
|
|
580
686
|
|
|
581
687
|
/** Internal managed-spawn policy; model and thinking are finalized by runAgent when a tier is present. */
|
|
582
688
|
export type ManagedSpawnPolicy = Pick<
|
|
583
689
|
SpawnOptions,
|
|
584
|
-
"model" | "maxTurns" | "isolated" | "inheritContext" | "thinkingLevel" | "isolation" | "invocation" | "rootSessionId"
|
|
585
|
-
|
|
690
|
+
"model" | "maxTurns" | "isolated" | "inheritContext" | "thinkingLevel" | "isolation" | "invocation" | "rootSessionId" | "toolset" | "excludeTools"
|
|
691
|
+
> & {
|
|
692
|
+
/** In-process identity of the resolved agent definition/tool allowlist for thread reuse. */
|
|
693
|
+
policyFingerprint?: string;
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
interface ResumeTerminalSnapshot {
|
|
697
|
+
status: AgentRecord["status"];
|
|
698
|
+
startedAt: number;
|
|
699
|
+
completedAt?: number;
|
|
700
|
+
result?: string;
|
|
701
|
+
error?: string;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
interface ResumeControl {
|
|
705
|
+
controller: AbortController;
|
|
706
|
+
cleanup: () => void;
|
|
707
|
+
snapshot: ResumeTerminalSnapshot;
|
|
708
|
+
deferred?: {
|
|
709
|
+
resolve: (value: string) => void;
|
|
710
|
+
reject: (error: unknown) => void;
|
|
711
|
+
};
|
|
712
|
+
}
|
|
586
713
|
|
|
587
714
|
export class AgentManager {
|
|
588
715
|
private agents = new Map<string, AgentRecord>();
|
|
@@ -593,14 +720,46 @@ export class AgentManager {
|
|
|
593
720
|
private onCompact?: OnAgentCompact;
|
|
594
721
|
private managedSpawns = new Map<string, ManagedSpawnTombstone>();
|
|
595
722
|
private managedKeysById = new Map<string, string>();
|
|
723
|
+
/** Active workflow-thread name to the managed AgentSession it re-enters. */
|
|
724
|
+
private managedThreads = new Map<string, string>();
|
|
725
|
+
/** Policy identity captured with each thread; policy changes cannot bypass a denylist. */
|
|
726
|
+
private managedThreadPolicies = new Map<string, string>();
|
|
727
|
+
/** Reservation closes the synchronous onSpawned -> same-thread re-entry window. */
|
|
728
|
+
private managedThreadReservations = new Set<string>();
|
|
596
729
|
private managedPersistence?: ManagedSpawnPersistence;
|
|
730
|
+
private readonly managedPersistenceRetries = new Map<string, {
|
|
731
|
+
tombstone: ManagedSpawnTombstone;
|
|
732
|
+
attempt: number;
|
|
733
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
734
|
+
}>();
|
|
597
735
|
private maxConcurrent: number;
|
|
598
736
|
/** Base repos worktrees were created from — so dispose() can prune them all,
|
|
599
737
|
* not just the parent repo (caller-supplied cwd can target other repos). */
|
|
600
738
|
private worktreeRepos = new Set<string>();
|
|
601
739
|
|
|
602
|
-
/** Queue of background agents waiting to start. */
|
|
603
|
-
private queue:
|
|
740
|
+
/** Queue of background agents waiting to start (spawn or resume). */
|
|
741
|
+
private queue: Array<
|
|
742
|
+
| { kind: "spawn"; id: string; args: SpawnArgs }
|
|
743
|
+
| { kind: "resume"; id: string; start: () => void }
|
|
744
|
+
> = [];
|
|
745
|
+
/**
|
|
746
|
+
* Resumable index: evicted top-level agents whose conversations can still be
|
|
747
|
+
* reopened from disk (`@handle` reopen). Bounded; oldest evicted first.
|
|
748
|
+
* Distinct from ManagedSpawnTombstone (managed-spawn idempotency).
|
|
749
|
+
*/
|
|
750
|
+
private readonly resumable = new Map<string, ResumableAgentEntry>();
|
|
751
|
+
/** Whether evicted records are indexed at all (settings: rememberAgents). */
|
|
752
|
+
private rememberAgents = true;
|
|
753
|
+
/** Whether spawned agents get `contact_supervisor` (settings). */
|
|
754
|
+
private supervisorQuestions = true;
|
|
755
|
+
/**
|
|
756
|
+
* Descendants spawned so far under each top-level agent, keyed by that
|
|
757
|
+
* agent's id. Cumulative for the branch's lifetime — see the budget check in
|
|
758
|
+
* `spawnInternal`. Pruned with the branch's metadata.
|
|
759
|
+
*/
|
|
760
|
+
private readonly branchSpawnCounts = new Map<string, number>();
|
|
761
|
+
/** Cumulative descendants any one top-level agent may start (settings). */
|
|
762
|
+
private maxSubagentSpawnsPerBranch = DEFAULT_MAX_SUBAGENT_SPAWNS_PER_BRANCH;
|
|
604
763
|
/** Number of currently running background agents. */
|
|
605
764
|
private runningBackground = 0;
|
|
606
765
|
/** IDs that currently hold a background pool slot; guards late/double settlements. */
|
|
@@ -613,6 +772,8 @@ export class AgentManager {
|
|
|
613
772
|
private readonly removingRecords = new Set<string>();
|
|
614
773
|
/** Records whose provider/session cleanup is still running after terminal status. */
|
|
615
774
|
private readonly settlingRecords = new Set<string>();
|
|
775
|
+
/** Active resume controller/listener and the terminal state it superseded. */
|
|
776
|
+
private readonly resumeControls = new Map<string, ResumeControl>();
|
|
616
777
|
|
|
617
778
|
/** Records whose runAgent provider/tool promise itself has not settled. */
|
|
618
779
|
private readonly providerPendingRecords = new Set<string>();
|
|
@@ -744,6 +905,7 @@ export class AgentManager {
|
|
|
744
905
|
const record = this.agents.get(ownerId);
|
|
745
906
|
if (!record) return `parent agent "${ownerId}" is missing`;
|
|
746
907
|
if (this.removingRecords.has(ownerId)) return `parent agent "${ownerId}" is being removed`;
|
|
908
|
+
if (this.deferredRecordRemovals.has(ownerId)) return `parent agent "${ownerId}" is pending removal`;
|
|
747
909
|
if (record.detached) return `parent agent "${ownerId}" is detached`;
|
|
748
910
|
if (this.nestedSpawnSeals.has(ownerId)) return `parent agent "${ownerId}" is sealed`;
|
|
749
911
|
if (record.status !== "queued" && record.status !== "running") {
|
|
@@ -769,9 +931,57 @@ export class AgentManager {
|
|
|
769
931
|
private canResumeNested(record: AgentRecord): boolean {
|
|
770
932
|
return !record.detached && !this.removingRecords.has(record.id) &&
|
|
771
933
|
!this.settlingRecords.has(record.id) &&
|
|
934
|
+
!this.deferredRecordRemovals.has(record.id) &&
|
|
772
935
|
(record.parentAgentId === undefined || this.nestedOwnerValidation(record.parentAgentId) === undefined);
|
|
773
936
|
}
|
|
774
937
|
|
|
938
|
+
private clearManagedPersistenceRetry(key: string): void {
|
|
939
|
+
const pending = this.managedPersistenceRetries.get(key);
|
|
940
|
+
if (!pending) return;
|
|
941
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
942
|
+
this.managedPersistenceRetries.delete(key);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
private clearManagedPersistenceRetries(): void {
|
|
946
|
+
for (const key of this.managedPersistenceRetries.keys()) this.clearManagedPersistenceRetry(key);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
private scheduleManagedPersistenceRetry(tombstone: ManagedSpawnTombstone): void {
|
|
950
|
+
if (this.disposed) return;
|
|
951
|
+
const persistence = this.managedPersistence;
|
|
952
|
+
if (!persistence) return;
|
|
953
|
+
const key = tombstone.spawnKey;
|
|
954
|
+
this.clearManagedPersistenceRetry(key);
|
|
955
|
+
const pending = { tombstone, attempt: 0, timer: undefined as ReturnType<typeof setTimeout> | undefined };
|
|
956
|
+
this.managedPersistenceRetries.set(key, pending);
|
|
957
|
+
const attempt = (): void => {
|
|
958
|
+
if (this.managedPersistenceRetries.get(key) !== pending) return;
|
|
959
|
+
pending.timer = undefined;
|
|
960
|
+
if (this.disposed || this.managedSpawns.get(key) !== tombstone) {
|
|
961
|
+
this.managedPersistenceRetries.delete(key);
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
try {
|
|
965
|
+
persistence.append(cloneManagedTombstone(tombstone));
|
|
966
|
+
this.managedPersistenceRetries.delete(key);
|
|
967
|
+
} catch (error: unknown) {
|
|
968
|
+
pending.attempt += 1;
|
|
969
|
+
if (pending.attempt >= MANAGED_PERSIST_RETRY_MAX_ATTEMPTS) {
|
|
970
|
+
this.managedPersistenceRetries.delete(key);
|
|
971
|
+
console.warn(`[pi-subagents] managed tombstone persistence retry exhausted for ${key}: ${error instanceof Error ? error.message : String(error)}`);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
const delay = Math.min(
|
|
975
|
+
MANAGED_PERSIST_RETRY_MAX_DELAY_MS,
|
|
976
|
+
MANAGED_PERSIST_RETRY_INITIAL_DELAY_MS * 2 ** Math.min(pending.attempt - 1, 6),
|
|
977
|
+
);
|
|
978
|
+
pending.timer = setTimeout(attempt, delay);
|
|
979
|
+
pending.timer.unref?.();
|
|
980
|
+
}
|
|
981
|
+
};
|
|
982
|
+
queueMicrotask(attempt);
|
|
983
|
+
}
|
|
984
|
+
|
|
775
985
|
private persistManaged(tombstone: ManagedSpawnTombstone, required = false): void {
|
|
776
986
|
if (!this.managedPersistence) {
|
|
777
987
|
if (required) throw new Error("managed spawn persistence is unavailable");
|
|
@@ -779,12 +989,14 @@ export class AgentManager {
|
|
|
779
989
|
}
|
|
780
990
|
try {
|
|
781
991
|
this.managedPersistence.append(cloneManagedTombstone(tombstone));
|
|
992
|
+
this.clearManagedPersistenceRetry(tombstone.spawnKey);
|
|
782
993
|
} catch (error: unknown) {
|
|
783
994
|
if (required) {
|
|
784
995
|
throw error instanceof Error ? error : new Error(String(error));
|
|
785
996
|
}
|
|
786
|
-
// Terminal
|
|
787
|
-
//
|
|
997
|
+
// Terminal persistence is retried independently of the agent promise. A
|
|
998
|
+
// permanently unavailable journal degrades to a warning, never a failed run.
|
|
999
|
+
this.scheduleManagedPersistenceRetry(tombstone);
|
|
788
1000
|
}
|
|
789
1001
|
}
|
|
790
1002
|
|
|
@@ -800,6 +1012,7 @@ export class AgentManager {
|
|
|
800
1012
|
private replaceManagedTombstone(key: string, next: ManagedSpawnTombstone, required = false): void {
|
|
801
1013
|
const previous = this.managedSpawns.get(key);
|
|
802
1014
|
if (previous && JSON.stringify(previous) === JSON.stringify(next)) return;
|
|
1015
|
+
this.clearManagedPersistenceRetry(key);
|
|
803
1016
|
this.managedSpawns.set(key, next);
|
|
804
1017
|
this.managedKeysById.set(next.id, key);
|
|
805
1018
|
this.persistManaged(next, required);
|
|
@@ -889,9 +1102,13 @@ export class AgentManager {
|
|
|
889
1102
|
entries: readonly ManagedSpawnEntryLike[],
|
|
890
1103
|
options: { dropActive?: boolean } = {},
|
|
891
1104
|
): ManagedSpawnTombstone[] {
|
|
1105
|
+
this.clearManagedPersistenceRetries();
|
|
892
1106
|
if (this.disposed) return [];
|
|
893
1107
|
this.managedSpawns.clear();
|
|
894
1108
|
this.managedKeysById.clear();
|
|
1109
|
+
this.managedThreads.clear();
|
|
1110
|
+
this.managedThreadPolicies.clear();
|
|
1111
|
+
this.managedThreadReservations.clear();
|
|
895
1112
|
for (const entry of entries) {
|
|
896
1113
|
if (entry.type !== "custom" || entry.customType !== MANAGED_SPAWN_ENTRY_TYPE) continue;
|
|
897
1114
|
const tombstone = parseManagedTombstone(entry.data);
|
|
@@ -899,6 +1116,11 @@ export class AgentManager {
|
|
|
899
1116
|
if (options.dropActive && !isManagedTerminalState(tombstone.state)) continue;
|
|
900
1117
|
this.managedSpawns.set(tombstone.spawnKey, tombstone);
|
|
901
1118
|
this.managedKeysById.set(tombstone.id, tombstone.spawnKey);
|
|
1119
|
+
if (tombstone.thread && tombstone.threadPolicyFingerprint) {
|
|
1120
|
+
const threadKey = `${tombstone.owner.runId}\u0000${tombstone.thread}`;
|
|
1121
|
+
this.managedThreads.set(threadKey, tombstone.id);
|
|
1122
|
+
this.managedThreadPolicies.set(threadKey, tombstone.threadPolicyFingerprint);
|
|
1123
|
+
}
|
|
902
1124
|
}
|
|
903
1125
|
const recovered: ManagedSpawnTombstone[] = [];
|
|
904
1126
|
for (const [key, tombstone] of this.managedSpawns) {
|
|
@@ -918,6 +1140,22 @@ export class AgentManager {
|
|
|
918
1140
|
return tombstone ? cloneManagedTombstone(tombstone) : undefined;
|
|
919
1141
|
}
|
|
920
1142
|
|
|
1143
|
+
/** Reconcile a spawn whose RPC reply was lost after allocation. */
|
|
1144
|
+
reconcileManaged(spawnKey: string, owner: AgentOwner): ManagedSpawnResult | undefined {
|
|
1145
|
+
const key = spawnKey.trim();
|
|
1146
|
+
const tombstone = this.managedSpawns.get(key);
|
|
1147
|
+
if (!tombstone) return undefined;
|
|
1148
|
+
if (
|
|
1149
|
+
tombstone.owner.extension !== owner.extension ||
|
|
1150
|
+
tombstone.owner.runId !== owner.runId ||
|
|
1151
|
+
tombstone.owner.nodeId !== owner.nodeId ||
|
|
1152
|
+
tombstone.owner.attemptId !== owner.attemptId
|
|
1153
|
+
) return undefined;
|
|
1154
|
+
const record = this.agents.get(tombstone.id);
|
|
1155
|
+
if (record && (record.status === "queued" || record.status === "running")) this.abortOwned(record.id, owner);
|
|
1156
|
+
return this.managedResult(key);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
921
1159
|
/**
|
|
922
1160
|
* Spawn an agent and return its ID immediately (for background use).
|
|
923
1161
|
* If the concurrency limit is reached, the agent is queued.
|
|
@@ -1000,6 +1238,30 @@ export class AgentManager {
|
|
|
1000
1238
|
? Object.freeze([...(nestedParent.ancestorAgentIds ?? []), nestedParent.id])
|
|
1001
1239
|
: undefined;
|
|
1002
1240
|
|
|
1241
|
+
// Cumulative spawn budget for the branch. The depth cap bounds how DEEP
|
|
1242
|
+
// nesting goes and nothing about how WIDE it gets: with nesting on by
|
|
1243
|
+
// default (depth 2), a single top-level agent can fan out without limit,
|
|
1244
|
+
// because its only cost per child is one of its own turns — and max turns
|
|
1245
|
+
// is commonly unlimited. This counts every descendant of a top-level agent
|
|
1246
|
+
// for the branch's whole lifetime, so a runaway fan-out stops at a number
|
|
1247
|
+
// instead of at the account's rate limit.
|
|
1248
|
+
//
|
|
1249
|
+
// Counted here rather than in the nested tool so that every path into a
|
|
1250
|
+
// nested spawn is covered, and counted cumulatively rather than
|
|
1251
|
+
// concurrently on purpose: a loop spawning one child at a time, forever, is
|
|
1252
|
+
// exactly the shape a concurrency limit does not catch.
|
|
1253
|
+
const branchRoot = ancestorAgentIds?.[0] ?? nestedParent?.id;
|
|
1254
|
+
if (branchRoot !== undefined) {
|
|
1255
|
+
const spawned = this.branchSpawnCounts.get(branchRoot) ?? 0;
|
|
1256
|
+
if (spawned >= this.maxSubagentSpawnsPerBranch) {
|
|
1257
|
+
throw new Error(
|
|
1258
|
+
`Nested spawn budget exhausted for this branch (${spawned}/${this.maxSubagentSpawnsPerBranch} agents). ` +
|
|
1259
|
+
"Complete the remaining work directly, or raise `maxSubagentSpawnsPerBranch` in subagents.json.",
|
|
1260
|
+
);
|
|
1261
|
+
}
|
|
1262
|
+
this.branchSpawnCounts.set(branchRoot, spawned + 1);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1003
1265
|
// Validate before the queue branch — a queued spawn should fail at the
|
|
1004
1266
|
// call, not minutes later at drain. Throw (not warn): programmatic callers
|
|
1005
1267
|
// can fix and retry; the RPC layer converts throws into error envelopes.
|
|
@@ -1031,6 +1293,14 @@ export class AgentManager {
|
|
|
1031
1293
|
rootSessionId: options.rootSessionId,
|
|
1032
1294
|
...(owner ? { owner: Object.freeze({ ...owner }) } : {}),
|
|
1033
1295
|
};
|
|
1296
|
+
// Top-level agents get a mention handle derived from their type. The name
|
|
1297
|
+
// space covers live records and resumable entries, so a handle is never
|
|
1298
|
+
// allocated twice while its conversation is still reachable.
|
|
1299
|
+
if (options.parentAgentId === undefined && options.reclaimHandle === undefined) {
|
|
1300
|
+
record.handle = assignHandle(handleBase(type), this.takenHandles());
|
|
1301
|
+
} else if (options.reclaimHandle !== undefined) {
|
|
1302
|
+
record.handle = options.reclaimHandle;
|
|
1303
|
+
}
|
|
1034
1304
|
this.agents.set(id, record);
|
|
1035
1305
|
if (!record.detached) {
|
|
1036
1306
|
try { this.onCreated?.(record); } catch { /* observer failures cannot orphan a record */ }
|
|
@@ -1073,7 +1343,7 @@ export class AgentManager {
|
|
|
1073
1343
|
|
|
1074
1344
|
if (occupiesPoolSlot(record) && !options.bypassQueue && this.runningBackground >= this.maxConcurrent) {
|
|
1075
1345
|
// Queue it — will be started when a running agent completes
|
|
1076
|
-
this.queue.push({ id, args });
|
|
1346
|
+
this.queue.push({ kind: "spawn", id, args });
|
|
1077
1347
|
return id;
|
|
1078
1348
|
}
|
|
1079
1349
|
|
|
@@ -1118,16 +1388,90 @@ export class AgentManager {
|
|
|
1118
1388
|
): ManagedSpawnResult {
|
|
1119
1389
|
if (this.disposed) throw new Error("AgentManager is disposed");
|
|
1120
1390
|
const normalized = normalizeManagedSpawnRequest(request);
|
|
1391
|
+
if (normalized.thread && (normalized.isolation === "worktree" || policy.isolation === "worktree")) {
|
|
1392
|
+
throw new Error("Managed workflow threads cannot use worktree isolation; use separate calls instead.");
|
|
1393
|
+
}
|
|
1121
1394
|
const scope = normalized.spawnKey;
|
|
1122
|
-
const
|
|
1395
|
+
const policyFingerprint = managedThreadPolicyFingerprint(normalized, policy);
|
|
1396
|
+
const fingerprint = managedFingerprint(normalized, policyFingerprint);
|
|
1397
|
+
const legacyFingerprint = managedLegacyFingerprintV1(normalized);
|
|
1398
|
+
const threadPolicyFingerprint = normalized.thread ? policyFingerprint : undefined;
|
|
1123
1399
|
const previous = this.managedSpawns.get(scope);
|
|
1124
1400
|
if (previous) {
|
|
1125
|
-
if (previous.fingerprint !== fingerprint) {
|
|
1401
|
+
if (previous.fingerprint !== fingerprint && previous.fingerprint !== legacyFingerprint) {
|
|
1126
1402
|
throw new Error(`Managed spawn key conflict: "${normalized.spawnKey}"`);
|
|
1127
1403
|
}
|
|
1404
|
+
if (normalized.thread && previous.threadPolicyFingerprint !== threadPolicyFingerprint) {
|
|
1405
|
+
throw new Error(`Managed workflow thread policy conflict: "${normalized.thread}"; use a new thread name for changed model/tool policy.`);
|
|
1406
|
+
}
|
|
1128
1407
|
return this.managedResult(scope);
|
|
1129
1408
|
}
|
|
1130
1409
|
|
|
1410
|
+
const threadKey = normalized.thread ? `${normalized.owner.runId}\u0000${normalized.thread}` : undefined;
|
|
1411
|
+
if (threadKey && this.managedThreadReservations.has(threadKey)) {
|
|
1412
|
+
throw new Error(`Managed workflow thread "${normalized.thread}" is already running; calls must be sequential.`);
|
|
1413
|
+
}
|
|
1414
|
+
const threadedId = threadKey ? this.managedThreads.get(threadKey) : undefined;
|
|
1415
|
+
if (threadedId) {
|
|
1416
|
+
if (threadPolicyFingerprint !== undefined && this.managedThreadPolicies.get(threadKey!) !== threadPolicyFingerprint) {
|
|
1417
|
+
throw new Error(`Managed workflow thread policy conflict: "${normalized.thread}"; use a new thread name for changed model/tool policy.`);
|
|
1418
|
+
}
|
|
1419
|
+
const record = this.agents.get(threadedId);
|
|
1420
|
+
if (record?.session && record.status !== "running" && record.status !== "queued" && !record.detached) {
|
|
1421
|
+
const previousOwner = record.owner;
|
|
1422
|
+
const previousInvocation = record.invocation;
|
|
1423
|
+
const previousManagedKey = this.managedKeysById.get(threadedId);
|
|
1424
|
+
record.owner = Object.freeze({ ...normalized.owner });
|
|
1425
|
+
record.invocation = policy.invocation;
|
|
1426
|
+
const now = Date.now();
|
|
1427
|
+
const tombstone: ManagedSpawnTombstone = {
|
|
1428
|
+
schemaVersion: MANAGED_SPAWN_SCHEMA_VERSION,
|
|
1429
|
+
spawnKey: scope,
|
|
1430
|
+
fingerprint,
|
|
1431
|
+
id: threadedId,
|
|
1432
|
+
requestId: normalized.requestId,
|
|
1433
|
+
type: normalized.type,
|
|
1434
|
+
description: normalized.description,
|
|
1435
|
+
owner: { ...normalized.owner },
|
|
1436
|
+
...(normalized.tier === undefined ? {} : { tier: normalized.tier }),
|
|
1437
|
+
...(normalized.thread === undefined ? {} : { thread: normalized.thread, threadPolicyFingerprint }),
|
|
1438
|
+
state: "queued",
|
|
1439
|
+
createdAt: now,
|
|
1440
|
+
updatedAt: now,
|
|
1441
|
+
compactionCount: record.compactionCount,
|
|
1442
|
+
};
|
|
1443
|
+
this.clearManagedPersistenceRetry(scope);
|
|
1444
|
+
this.managedSpawns.set(scope, tombstone);
|
|
1445
|
+
this.managedKeysById.set(threadedId, scope);
|
|
1446
|
+
try {
|
|
1447
|
+
this.persistManaged(tombstone, true);
|
|
1448
|
+
} catch (error: unknown) {
|
|
1449
|
+
this.managedSpawns.delete(scope);
|
|
1450
|
+
if (previousManagedKey === undefined) this.managedKeysById.delete(threadedId);
|
|
1451
|
+
else this.managedKeysById.set(threadedId, previousManagedKey);
|
|
1452
|
+
record.owner = previousOwner;
|
|
1453
|
+
record.invocation = previousInvocation;
|
|
1454
|
+
throw error;
|
|
1455
|
+
}
|
|
1456
|
+
void this.resume(threadedId, normalized.prompt, undefined, {
|
|
1457
|
+
isBackground: true,
|
|
1458
|
+
onToolActivity: callbacks?.onToolActivity,
|
|
1459
|
+
onAssistantUsage: callbacks?.onAssistantUsage,
|
|
1460
|
+
onCompaction: callbacks?.onCompaction ? (info) => callbacks.onCompaction?.(info as CompactionInfo) : undefined,
|
|
1461
|
+
}).catch(() => {});
|
|
1462
|
+
this.syncManagedRecord(record, true);
|
|
1463
|
+
const resumedState = String(record.status);
|
|
1464
|
+
return { id: threadedId, state: resumedState === "queued" ? "queued" : "running", created: true };
|
|
1465
|
+
}
|
|
1466
|
+
if (record && !record.detached && (record.status === "running" || record.status === "queued")) {
|
|
1467
|
+
throw new Error(`Managed workflow thread "${normalized.thread}" is already running; calls must be sequential.`);
|
|
1468
|
+
}
|
|
1469
|
+
if (!record || record.detached || !record.session) {
|
|
1470
|
+
this.managedThreads.delete(threadKey!);
|
|
1471
|
+
this.managedThreadPolicies.delete(threadKey!);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1131
1475
|
// Register and persist the immutable idempotency identity before starting
|
|
1132
1476
|
// the session. A crash after allocation cannot cause a second AgentSession.
|
|
1133
1477
|
const now = Date.now();
|
|
@@ -1141,7 +1485,8 @@ export class AgentManager {
|
|
|
1141
1485
|
type: normalized.type,
|
|
1142
1486
|
description: normalized.description,
|
|
1143
1487
|
owner: { ...normalized.owner },
|
|
1144
|
-
|
|
1488
|
+
...(normalized.tier === undefined ? {} : { tier: normalized.tier }),
|
|
1489
|
+
...(normalized.thread === undefined ? {} : { thread: normalized.thread, threadPolicyFingerprint }),
|
|
1145
1490
|
state: "queued",
|
|
1146
1491
|
createdAt: now,
|
|
1147
1492
|
updatedAt: now,
|
|
@@ -1149,6 +1494,11 @@ export class AgentManager {
|
|
|
1149
1494
|
};
|
|
1150
1495
|
this.managedSpawns.set(scope, tombstone);
|
|
1151
1496
|
this.managedKeysById.set(id, scope);
|
|
1497
|
+
if (threadKey) {
|
|
1498
|
+
this.managedThreads.set(threadKey, id);
|
|
1499
|
+
this.managedThreadPolicies.set(threadKey, threadPolicyFingerprint!);
|
|
1500
|
+
this.managedThreadReservations.add(threadKey);
|
|
1501
|
+
}
|
|
1152
1502
|
try {
|
|
1153
1503
|
// The allocation identity must reach the session journal before the
|
|
1154
1504
|
// AgentSession or queue can produce side effects. A failed append rolls
|
|
@@ -1157,6 +1507,11 @@ export class AgentManager {
|
|
|
1157
1507
|
} catch (error: unknown) {
|
|
1158
1508
|
this.managedSpawns.delete(scope);
|
|
1159
1509
|
this.managedKeysById.delete(id);
|
|
1510
|
+
if (threadKey) {
|
|
1511
|
+
this.managedThreads.delete(threadKey);
|
|
1512
|
+
this.managedThreadPolicies.delete(threadKey);
|
|
1513
|
+
this.managedThreadReservations.delete(threadKey);
|
|
1514
|
+
}
|
|
1160
1515
|
throw error;
|
|
1161
1516
|
}
|
|
1162
1517
|
|
|
@@ -1164,11 +1519,17 @@ export class AgentManager {
|
|
|
1164
1519
|
this.spawnInternal(pi, ctx, normalized.type, normalized.prompt, {
|
|
1165
1520
|
...policy,
|
|
1166
1521
|
...(normalized.tier === undefined ? {} : { tier: normalized.tier }),
|
|
1522
|
+
...(normalized.thread === undefined ? {} : { thread: normalized.thread }),
|
|
1167
1523
|
description: normalized.description,
|
|
1168
1524
|
isBackground: true,
|
|
1169
1525
|
...callbacks,
|
|
1170
1526
|
}, normalized.owner, id);
|
|
1171
1527
|
} catch (error: unknown) {
|
|
1528
|
+
if (threadKey) {
|
|
1529
|
+
this.managedThreads.delete(threadKey);
|
|
1530
|
+
this.managedThreadPolicies.delete(threadKey);
|
|
1531
|
+
this.managedThreadReservations.delete(threadKey);
|
|
1532
|
+
}
|
|
1172
1533
|
const existing = this.managedSpawns.get(scope);
|
|
1173
1534
|
if (existing?.state === "failed" && existing.terminal) return this.managedResult(scope, true);
|
|
1174
1535
|
const completedAt = Date.now();
|
|
@@ -1188,13 +1549,18 @@ export class AgentManager {
|
|
|
1188
1549
|
|
|
1189
1550
|
const record = this.agents.get(id);
|
|
1190
1551
|
if (record) this.syncManagedRecord(record);
|
|
1552
|
+
if (threadKey) this.managedThreadReservations.delete(threadKey);
|
|
1191
1553
|
return this.managedResult(scope, true);
|
|
1192
1554
|
}
|
|
1193
1555
|
|
|
1194
1556
|
/** Forget managed idempotency keys when a logical session is replaced. */
|
|
1195
1557
|
resetManagedSpawns(): void {
|
|
1558
|
+
this.clearManagedPersistenceRetries();
|
|
1196
1559
|
this.managedSpawns.clear();
|
|
1197
1560
|
this.managedKeysById.clear();
|
|
1561
|
+
this.managedThreads.clear();
|
|
1562
|
+
this.managedThreadPolicies.clear();
|
|
1563
|
+
this.managedThreadReservations.clear();
|
|
1198
1564
|
}
|
|
1199
1565
|
|
|
1200
1566
|
/** Actually start an agent (called immediately or from queue drain). */
|
|
@@ -1212,23 +1578,23 @@ export class AgentManager {
|
|
|
1212
1578
|
// Worktree isolation: try to create a temporary git worktree. Strict —
|
|
1213
1579
|
// fail loud if not possible (no silent fallback to main tree). Done
|
|
1214
1580
|
// BEFORE state mutation so a throw doesn't leave the record half-running.
|
|
1581
|
+
// "off" explicitly opts out; global switch gates "worktree" deterministically.
|
|
1215
1582
|
let worktreeCwd: string | undefined;
|
|
1216
1583
|
let worktreeRepoRoot: string | undefined;
|
|
1217
|
-
if (options.isolation === "
|
|
1584
|
+
if (options.isolation === "off") {
|
|
1585
|
+
// Explicit opt-out — no worktree, no check.
|
|
1586
|
+
} else if (options.isolation === "worktree") {
|
|
1587
|
+
if (!isWorktreeIsolationEnabled()) {
|
|
1588
|
+
throw new Error('Cannot run with isolation: "worktree" — worktree isolation is disabled in project settings. Enable it or omit `isolation`.');
|
|
1589
|
+
}
|
|
1218
1590
|
const wt = createWorktree(baseCwd, id);
|
|
1219
1591
|
if (!wt) {
|
|
1220
1592
|
throw new Error(
|
|
1221
1593
|
'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
|
|
1222
|
-
|
|
1594
|
+
'Initialize git and commit at least once, or omit `isolation`.',
|
|
1223
1595
|
);
|
|
1224
1596
|
}
|
|
1225
1597
|
record.worktree = wt;
|
|
1226
|
-
// workPath preserves subdirectory scoping for caller-supplied cwds: a
|
|
1227
|
-
// cwd deep in a monorepo maps to the same subdir inside the copy, not
|
|
1228
|
-
// the copied repo's root. Plain worktree spawns keep the historical
|
|
1229
|
-
// behavior (agent at the copy's root) — moving them to workPath would
|
|
1230
|
-
// also move .pi config discovery when the parent session sits in a repo
|
|
1231
|
-
// subdirectory, silently dropping extensions/skills.
|
|
1232
1598
|
worktreeCwd = customCwd !== undefined ? wt.workPath : wt.path;
|
|
1233
1599
|
worktreeRepoRoot = wt.repoRoot;
|
|
1234
1600
|
this.worktreeRepos.add(wt.repoRoot);
|
|
@@ -1287,6 +1653,14 @@ export class AgentManager {
|
|
|
1287
1653
|
// off-limits, even when the invocation started in a subdirectory.
|
|
1288
1654
|
worktreeBase: worktreeRepoRoot,
|
|
1289
1655
|
configCwd: options.configCwd ?? (customCwd !== undefined ? ctx.cwd : undefined),
|
|
1656
|
+
// Top-level conversations persist by default so `@handle` has something
|
|
1657
|
+
// to reopen after the record is evicted; frontmatter still overrides.
|
|
1658
|
+
rememberAgents: this.rememberAgents,
|
|
1659
|
+
supervisorQuestions: this.supervisorQuestions,
|
|
1660
|
+
resumeSessionFile: options.resumeSessionFile,
|
|
1661
|
+
toolset: options.toolset,
|
|
1662
|
+
excludeTools: options.excludeTools,
|
|
1663
|
+
thread: options.thread,
|
|
1290
1664
|
signal: record.abortController!.signal,
|
|
1291
1665
|
onToolActivity: (activity) => {
|
|
1292
1666
|
if (record.detached) return;
|
|
@@ -1350,6 +1724,9 @@ export class AgentManager {
|
|
|
1350
1724
|
return;
|
|
1351
1725
|
}
|
|
1352
1726
|
record.session = session;
|
|
1727
|
+
// Capture the persisted session file so an evicted record can be
|
|
1728
|
+
// reopened as a resumable entry (@handle reopen).
|
|
1729
|
+
record.sessionFile = session.sessionManager?.getSessionFile?.() ?? (session as { sessionFile?: string })?.sessionFile;
|
|
1353
1730
|
// Flush any steers that arrived before the session was ready
|
|
1354
1731
|
if (record.pendingSteers?.length) {
|
|
1355
1732
|
for (const msg of record.pendingSteers) {
|
|
@@ -1432,6 +1809,7 @@ export class AgentManager {
|
|
|
1432
1809
|
// undefined output or session.
|
|
1433
1810
|
record.result = responseText;
|
|
1434
1811
|
record.session = session;
|
|
1812
|
+
record.sessionFile = session.sessionManager?.getSessionFile?.() ?? (session as { sessionFile?: string })?.sessionFile;
|
|
1435
1813
|
this.syncManagedRecord(record);
|
|
1436
1814
|
|
|
1437
1815
|
// Quiesce descendants before removing the parent's worktree. A nested
|
|
@@ -1634,6 +2012,20 @@ export class AgentManager {
|
|
|
1634
2012
|
this.releasePoolSlot(record.id);
|
|
1635
2013
|
continue;
|
|
1636
2014
|
}
|
|
2015
|
+
if (next.kind === "resume") {
|
|
2016
|
+
try {
|
|
2017
|
+
next.start();
|
|
2018
|
+
} catch (err) {
|
|
2019
|
+
record.status = "error";
|
|
2020
|
+
record.error = err instanceof Error ? err.message : String(err);
|
|
2021
|
+
record.completedAt = Date.now();
|
|
2022
|
+
this.clearParentSignal(record.id);
|
|
2023
|
+
this.releasePoolSlot(record.id);
|
|
2024
|
+
this.syncManagedRecord(record);
|
|
2025
|
+
this.notifyComplete(record);
|
|
2026
|
+
}
|
|
2027
|
+
continue;
|
|
2028
|
+
}
|
|
1637
2029
|
try {
|
|
1638
2030
|
this.startAgent(next.id, record, next.args);
|
|
1639
2031
|
} catch (err) {
|
|
@@ -1735,6 +2127,60 @@ export class AgentManager {
|
|
|
1735
2127
|
return { id, record };
|
|
1736
2128
|
}
|
|
1737
2129
|
|
|
2130
|
+
private resumeSnapshot(record: AgentRecord): ResumeTerminalSnapshot {
|
|
2131
|
+
return {
|
|
2132
|
+
status: record.status,
|
|
2133
|
+
startedAt: record.startedAt,
|
|
2134
|
+
...(record.completedAt === undefined ? {} : { completedAt: record.completedAt }),
|
|
2135
|
+
...(record.result === undefined ? {} : { result: record.result }),
|
|
2136
|
+
...(record.error === undefined ? {} : { error: record.error }),
|
|
2137
|
+
};
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
private restoreResumeSnapshot(record: AgentRecord, snapshot: ResumeTerminalSnapshot): void {
|
|
2141
|
+
record.status = snapshot.status;
|
|
2142
|
+
record.startedAt = snapshot.startedAt;
|
|
2143
|
+
record.completedAt = snapshot.completedAt;
|
|
2144
|
+
record.result = snapshot.result;
|
|
2145
|
+
record.error = snapshot.error;
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
private beginResumeControl(
|
|
2149
|
+
id: string,
|
|
2150
|
+
record: AgentRecord,
|
|
2151
|
+
signal: AbortSignal | undefined,
|
|
2152
|
+
snapshot: ResumeTerminalSnapshot,
|
|
2153
|
+
deferred?: ResumeControl["deferred"],
|
|
2154
|
+
): ResumeControl {
|
|
2155
|
+
const controller = new AbortController();
|
|
2156
|
+
let control!: ResumeControl;
|
|
2157
|
+
const onAbort = (): void => {
|
|
2158
|
+
controller.abort(signal?.reason);
|
|
2159
|
+
if (record.status === "queued" || record.status === "running") this.abort(id);
|
|
2160
|
+
};
|
|
2161
|
+
const cleanup = (): void => {
|
|
2162
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2163
|
+
if (this.resumeControls.get(id) === control) this.resumeControls.delete(id);
|
|
2164
|
+
if (record.abortController === controller) record.abortController = undefined;
|
|
2165
|
+
};
|
|
2166
|
+
control = { controller, cleanup, snapshot, ...(deferred ? { deferred } : {}) };
|
|
2167
|
+
this.resumeControls.set(id, control);
|
|
2168
|
+
record.abortController = controller;
|
|
2169
|
+
if (signal) {
|
|
2170
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2171
|
+
if (signal.aborted) onAbort();
|
|
2172
|
+
}
|
|
2173
|
+
return control;
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
private cancelResumeControl(id: string): void {
|
|
2177
|
+
const control = this.resumeControls.get(id);
|
|
2178
|
+
if (!control) return;
|
|
2179
|
+
control.controller.abort();
|
|
2180
|
+
control.cleanup();
|
|
2181
|
+
control.deferred?.resolve("");
|
|
2182
|
+
}
|
|
2183
|
+
|
|
1738
2184
|
/**
|
|
1739
2185
|
* Resume an existing agent session with a new prompt.
|
|
1740
2186
|
*/
|
|
@@ -1742,28 +2188,66 @@ export class AgentManager {
|
|
|
1742
2188
|
id: string,
|
|
1743
2189
|
prompt: string,
|
|
1744
2190
|
signal?: AbortSignal,
|
|
2191
|
+
options?: {
|
|
2192
|
+
isBackground?: boolean;
|
|
2193
|
+
onToolActivity?: (activity: { type: "start" | "end"; toolName: string }) => void;
|
|
2194
|
+
onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
|
|
2195
|
+
onCompaction?: (info: unknown) => void;
|
|
2196
|
+
},
|
|
1745
2197
|
): Promise<AgentRecord | undefined> {
|
|
1746
2198
|
if (this.disposed) return undefined;
|
|
1747
2199
|
const record = this.agents.get(id);
|
|
1748
2200
|
if (!record) return undefined;
|
|
1749
|
-
// Nested resumes are another way to restart work below an owner. Require
|
|
1750
|
-
// the same live, unsuspended ancestor chain as a fresh nested spawn before
|
|
1751
|
-
// touching the target record or invoking the provider.
|
|
1752
2201
|
if (!this.canResumeNested(record)) return undefined;
|
|
1753
2202
|
if (signal?.aborted) return record;
|
|
2203
|
+
if (this.resumeControls.has(id) || record.status === "running" || record.status === "queued") return undefined;
|
|
1754
2204
|
if (!record.session) return undefined;
|
|
1755
|
-
this.settlingRecords.add(id);
|
|
1756
2205
|
|
|
1757
|
-
const
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
2206
|
+
const snapshot = this.resumeSnapshot(record);
|
|
2207
|
+
if (options?.isBackground) {
|
|
2208
|
+
// A background resume is one lifecycle, including time spent queued. Keep
|
|
2209
|
+
// a promise on the record for quiesce/waitForAll instead of the completed
|
|
2210
|
+
// promise from the original spawn.
|
|
2211
|
+
let resolveResume!: (value: string) => void;
|
|
2212
|
+
let rejectResume!: (error: unknown) => void;
|
|
2213
|
+
const lifecyclePromise = new Promise<string>((resolve, reject) => {
|
|
2214
|
+
resolveResume = resolve;
|
|
2215
|
+
rejectResume = reject;
|
|
2216
|
+
});
|
|
2217
|
+
const control = this.beginResumeControl(id, record, signal, snapshot, {
|
|
2218
|
+
resolve: resolveResume,
|
|
2219
|
+
reject: rejectResume,
|
|
2220
|
+
});
|
|
2221
|
+
record.isBackground = true;
|
|
2222
|
+
record.resultConsumed = false;
|
|
2223
|
+
record.result = undefined;
|
|
2224
|
+
record.error = undefined;
|
|
2225
|
+
record.completedAt = undefined;
|
|
2226
|
+
record.status = "queued";
|
|
2227
|
+
record.promise = lifecyclePromise;
|
|
2228
|
+
|
|
2229
|
+
const start = (): void => {
|
|
2230
|
+
const execution = this.startResume(id, record, prompt, control, options);
|
|
2231
|
+
record.promise = execution;
|
|
2232
|
+
void execution
|
|
2233
|
+
.then(resolveResume, rejectResume)
|
|
2234
|
+
.catch(() => {});
|
|
2235
|
+
};
|
|
2236
|
+
if (occupiesPoolSlot(record) && this.runningBackground >= this.maxConcurrent) {
|
|
2237
|
+
this.queue.push({ kind: "resume", id, start });
|
|
2238
|
+
} else {
|
|
2239
|
+
start();
|
|
2240
|
+
}
|
|
2241
|
+
return record;
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
this.settlingRecords.add(id);
|
|
2245
|
+
const control = this.beginResumeControl(id, record, signal, snapshot);
|
|
1761
2246
|
record.status = "running";
|
|
1762
2247
|
record.startedAt = Date.now();
|
|
1763
2248
|
record.completedAt = undefined;
|
|
1764
2249
|
record.result = undefined;
|
|
1765
2250
|
record.error = undefined;
|
|
1766
|
-
|
|
1767
2251
|
try {
|
|
1768
2252
|
const { text, failure } = await resumeAgent(record.session, prompt, {
|
|
1769
2253
|
onToolActivity: (activity) => {
|
|
@@ -1778,52 +2262,139 @@ export class AgentManager {
|
|
|
1778
2262
|
this.syncManagedRecord(record);
|
|
1779
2263
|
this.onCompact?.(record, info);
|
|
1780
2264
|
},
|
|
1781
|
-
signal,
|
|
2265
|
+
signal: control.controller.signal,
|
|
1782
2266
|
});
|
|
1783
|
-
if (!record.detached && (record.status as
|
|
1784
|
-
// Same contract as the spawn path (#144): a failed final turn is an
|
|
1785
|
-
// error, not a completion — but the resumed text stays available.
|
|
2267
|
+
if (!record.detached && !control.controller.signal.aborted && (record.status as AgentRecord["status"]) !== "stopped") {
|
|
1786
2268
|
record.status = failure ? "error" : "completed";
|
|
1787
2269
|
if (failure) record.error = failure;
|
|
1788
2270
|
record.result = text;
|
|
1789
2271
|
record.completedAt = Date.now();
|
|
1790
2272
|
}
|
|
1791
2273
|
} catch (err) {
|
|
1792
|
-
if (!record.detached && (record.status as
|
|
1793
|
-
record.status = "error";
|
|
1794
|
-
record.error = err instanceof Error ? err.message : String(err);
|
|
2274
|
+
if (!record.detached && (record.status as AgentRecord["status"]) !== "stopped") {
|
|
2275
|
+
record.status = control.controller.signal.aborted ? "stopped" : "error";
|
|
2276
|
+
if (record.status === "error") record.error = err instanceof Error ? err.message : String(err);
|
|
1795
2277
|
record.completedAt = Date.now();
|
|
1796
2278
|
}
|
|
1797
|
-
}
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
// A branch replacement/quiescence timeout owns this late continuation.
|
|
1801
|
-
// Restore the pre-resume terminal snapshot rather than publishing the
|
|
1802
|
-
// result from the detached session into the old record.
|
|
1803
|
-
record.status = previousStatus;
|
|
1804
|
-
record.completedAt = previousCompletedAt;
|
|
1805
|
-
record.result = previousResult;
|
|
1806
|
-
record.error = previousError;
|
|
2279
|
+
} finally {
|
|
2280
|
+
if (record.detached) this.restoreResumeSnapshot(record, snapshot);
|
|
2281
|
+
control.cleanup();
|
|
1807
2282
|
this.settlingRecords.delete(id);
|
|
1808
2283
|
this.retryDeferredRecordRemovals();
|
|
1809
|
-
return undefined;
|
|
1810
2284
|
}
|
|
1811
2285
|
|
|
1812
|
-
|
|
1813
|
-
// resumed turn must not outlive it — nothing else can see or reach them.
|
|
2286
|
+
if (record.detached) return undefined;
|
|
1814
2287
|
this.syncManagedRecord(record);
|
|
1815
2288
|
await this.abortOwnedChildren(id);
|
|
1816
2289
|
if (record.detached) {
|
|
1817
|
-
this.
|
|
2290
|
+
this.restoreResumeSnapshot(record, snapshot);
|
|
1818
2291
|
this.retryDeferredRecordRemovals();
|
|
1819
2292
|
return undefined;
|
|
1820
2293
|
}
|
|
1821
|
-
|
|
1822
|
-
this.settlingRecords.delete(id);
|
|
1823
2294
|
this.retryDeferredRecordRemovals();
|
|
1824
2295
|
return record;
|
|
1825
2296
|
}
|
|
1826
2297
|
|
|
2298
|
+
/**
|
|
2299
|
+
* Run a background resume to completion. Mirrors the foreground resume's
|
|
2300
|
+
* settle contract (including nested-ownership, detach, and deferred-removal
|
|
2301
|
+
* handling) but acquires a pool slot and notifies on completion like a
|
|
2302
|
+
* background spawn. Called directly or from the queue drain.
|
|
2303
|
+
*/
|
|
2304
|
+
private async startResume(
|
|
2305
|
+
id: string,
|
|
2306
|
+
record: AgentRecord,
|
|
2307
|
+
prompt: string,
|
|
2308
|
+
control: ResumeControl,
|
|
2309
|
+
options?: {
|
|
2310
|
+
isBackground?: boolean;
|
|
2311
|
+
onToolActivity?: (activity: { type: "start" | "end"; toolName: string }) => void;
|
|
2312
|
+
onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
|
|
2313
|
+
onCompaction?: (info: unknown) => void;
|
|
2314
|
+
},
|
|
2315
|
+
): Promise<string> {
|
|
2316
|
+
const snapshot = control.snapshot;
|
|
2317
|
+
let slotHeld = false;
|
|
2318
|
+
try {
|
|
2319
|
+
if (this.disposed || record.detached || this.agents.get(id) !== record) {
|
|
2320
|
+
if (record.detached) this.restoreResumeSnapshot(record, snapshot);
|
|
2321
|
+
return "";
|
|
2322
|
+
}
|
|
2323
|
+
if (!this.canResumeNested(record)) {
|
|
2324
|
+
record.status = "error";
|
|
2325
|
+
record.error = "owner branch is no longer resumable";
|
|
2326
|
+
record.completedAt = Date.now();
|
|
2327
|
+
this.syncManagedRecord(record);
|
|
2328
|
+
this.notifyComplete(record);
|
|
2329
|
+
return "";
|
|
2330
|
+
}
|
|
2331
|
+
this.settlingRecords.add(id);
|
|
2332
|
+
if (occupiesPoolSlot(record)) {
|
|
2333
|
+
this.heldPoolSlots.add(id);
|
|
2334
|
+
this.runningBackground++;
|
|
2335
|
+
slotHeld = true;
|
|
2336
|
+
}
|
|
2337
|
+
record.status = "running";
|
|
2338
|
+
record.startedAt = Date.now();
|
|
2339
|
+
record.completedAt = undefined;
|
|
2340
|
+
record.result = undefined;
|
|
2341
|
+
record.error = undefined;
|
|
2342
|
+
this.syncManagedRecord(record);
|
|
2343
|
+
|
|
2344
|
+
try {
|
|
2345
|
+
const { text, failure } = await resumeAgent(record.session!, prompt, {
|
|
2346
|
+
onToolActivity: (activity) => {
|
|
2347
|
+
if (!record.detached && activity.type === "end") record.toolUses++;
|
|
2348
|
+
options?.onToolActivity?.(activity);
|
|
2349
|
+
},
|
|
2350
|
+
onAssistantUsage: (usage) => {
|
|
2351
|
+
if (!record.detached) addUsage(record.lifetimeUsage, usage);
|
|
2352
|
+
options?.onAssistantUsage?.(usage);
|
|
2353
|
+
},
|
|
2354
|
+
onCompaction: (info) => {
|
|
2355
|
+
if (record.detached) return;
|
|
2356
|
+
record.compactionCount++;
|
|
2357
|
+
this.syncManagedRecord(record);
|
|
2358
|
+
this.onCompact?.(record, info);
|
|
2359
|
+
options?.onCompaction?.(info);
|
|
2360
|
+
},
|
|
2361
|
+
signal: control.controller.signal,
|
|
2362
|
+
});
|
|
2363
|
+
if (!record.detached && !control.controller.signal.aborted && (record.status as AgentRecord["status"]) !== "stopped") {
|
|
2364
|
+
record.status = failure ? "error" : "completed";
|
|
2365
|
+
if (failure) record.error = failure;
|
|
2366
|
+
record.result = text;
|
|
2367
|
+
record.completedAt = Date.now();
|
|
2368
|
+
}
|
|
2369
|
+
} catch (err) {
|
|
2370
|
+
if (!record.detached && (record.status as AgentRecord["status"]) !== "stopped") {
|
|
2371
|
+
record.status = control.controller.signal.aborted ? "stopped" : "error";
|
|
2372
|
+
if (record.status === "error") record.error = err instanceof Error ? err.message : String(err);
|
|
2373
|
+
record.completedAt = Date.now();
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
if (record.detached) {
|
|
2378
|
+
this.restoreResumeSnapshot(record, snapshot);
|
|
2379
|
+
return "";
|
|
2380
|
+
}
|
|
2381
|
+
this.syncManagedRecord(record);
|
|
2382
|
+
await this.abortOwnedChildren(id);
|
|
2383
|
+
if (record.detached) {
|
|
2384
|
+
this.restoreResumeSnapshot(record, snapshot);
|
|
2385
|
+
return "";
|
|
2386
|
+
}
|
|
2387
|
+
this.notifyComplete(record);
|
|
2388
|
+
return record.result ?? "";
|
|
2389
|
+
} finally {
|
|
2390
|
+
control.cleanup();
|
|
2391
|
+
this.settlingRecords.delete(id);
|
|
2392
|
+
if (slotHeld) this.releasePoolSlot(id);
|
|
2393
|
+
this.retryDeferredRecordRemovals();
|
|
2394
|
+
this.drainQueue();
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
|
|
1827
2398
|
/**
|
|
1828
2399
|
* Send a steering message to an agent from the UI (mirrors the steer_subagent
|
|
1829
2400
|
* tool). A live session delivers it now — it interrupts the agent after its
|
|
@@ -1879,6 +2450,7 @@ export class AgentManager {
|
|
|
1879
2450
|
private abortRecord(id: string, drain = true): boolean {
|
|
1880
2451
|
const record = this.agents.get(id);
|
|
1881
2452
|
if (!record) return false;
|
|
2453
|
+
const resumeControl = this.resumeControls.get(id);
|
|
1882
2454
|
|
|
1883
2455
|
// Remove from queue if queued.
|
|
1884
2456
|
if (record.status === "queued") {
|
|
@@ -1886,6 +2458,9 @@ export class AgentManager {
|
|
|
1886
2458
|
record.status = "stopped";
|
|
1887
2459
|
record.completedAt = Date.now();
|
|
1888
2460
|
this.clearParentSignal(record.id);
|
|
2461
|
+
resumeControl?.controller.abort();
|
|
2462
|
+
resumeControl?.cleanup();
|
|
2463
|
+
resumeControl?.deferred?.resolve("");
|
|
1889
2464
|
this.syncManagedRecord(record);
|
|
1890
2465
|
// Queued agents have no run promise yet. Still use the normal terminal
|
|
1891
2466
|
// callback so lifecycle consumers (including workflow waits) cannot hang.
|
|
@@ -2008,6 +2583,17 @@ export class AgentManager {
|
|
|
2008
2583
|
);
|
|
2009
2584
|
if (!stillReferenced) this.nestedSpawnSeals.delete(id);
|
|
2010
2585
|
}
|
|
2586
|
+
// Branch spawn budgets outlive their descendants on purpose — the count is
|
|
2587
|
+
// cumulative for the branch's life, so it must survive children being
|
|
2588
|
+
// evicted, or a slow loop would reset its own budget. It is dropped only
|
|
2589
|
+
// once the root itself is gone and nothing still descends from it.
|
|
2590
|
+
for (const rootId of this.branchSpawnCounts.keys()) {
|
|
2591
|
+
if (this.agents.has(rootId) || this.removingRecords.has(rootId)) continue;
|
|
2592
|
+
const stillReferenced = [...this.agents.values()].some(
|
|
2593
|
+
(record) => record.ancestorAgentIds?.includes(rootId) === true,
|
|
2594
|
+
);
|
|
2595
|
+
if (!stillReferenced) this.branchSpawnCounts.delete(rootId);
|
|
2596
|
+
}
|
|
2011
2597
|
}
|
|
2012
2598
|
private retryPinnedWorktreeCleanup(): void {
|
|
2013
2599
|
if (this.disposed) return;
|
|
@@ -2074,9 +2660,16 @@ export class AgentManager {
|
|
|
2074
2660
|
try { record.outputCleanup(); } catch { /* ignore stale transcript cleanup errors */ }
|
|
2075
2661
|
record.outputCleanup = undefined;
|
|
2076
2662
|
}
|
|
2663
|
+
this.indexResumable(record);
|
|
2077
2664
|
if (record.session) this.trackRecordSessionTeardown(id, record.session);
|
|
2078
2665
|
record.session = undefined;
|
|
2079
2666
|
this.clearParentSignal(id);
|
|
2667
|
+
for (const [thread, threadId] of this.managedThreads) {
|
|
2668
|
+
if (threadId !== id) continue;
|
|
2669
|
+
this.managedThreads.delete(thread);
|
|
2670
|
+
this.managedThreadPolicies.delete(thread);
|
|
2671
|
+
this.managedThreadReservations.delete(thread);
|
|
2672
|
+
}
|
|
2080
2673
|
this.agents.delete(id);
|
|
2081
2674
|
return true;
|
|
2082
2675
|
} finally {
|
|
@@ -2088,6 +2681,132 @@ export class AgentManager {
|
|
|
2088
2681
|
}
|
|
2089
2682
|
}
|
|
2090
2683
|
|
|
2684
|
+
/**
|
|
2685
|
+
* Preserve enough of a departing record for `@handle` to reopen its
|
|
2686
|
+
* conversation later. Nothing to keep unless it has a session file to reopen
|
|
2687
|
+
* — an in-memory session leaves no transcript, so the mention would have
|
|
2688
|
+
* nothing to continue from. Only top-level agents are indexed.
|
|
2689
|
+
*/
|
|
2690
|
+
private indexResumable(record: AgentRecord): void {
|
|
2691
|
+
if (!this.rememberAgents) return;
|
|
2692
|
+
if (record.parentAgentId !== undefined) return;
|
|
2693
|
+
if (!record.sessionFile || !record.session) return;
|
|
2694
|
+
const entry: ResumableAgentEntry = {
|
|
2695
|
+
handle: record.handle ?? record.id,
|
|
2696
|
+
id: record.id,
|
|
2697
|
+
type: record.type,
|
|
2698
|
+
description: record.description,
|
|
2699
|
+
sessionFile: record.sessionFile,
|
|
2700
|
+
completedAt: record.completedAt ?? Date.now(),
|
|
2701
|
+
};
|
|
2702
|
+
this.resumable.set(entry.handle, entry);
|
|
2703
|
+
// Bound the memory a long session can accumulate. Oldest first, since the
|
|
2704
|
+
// agent someone still wants to reach is the one they used most recently.
|
|
2705
|
+
while (this.resumable.size > MAX_RESUMABLE_ENTRIES) {
|
|
2706
|
+
let oldest: ResumableAgentEntry | undefined;
|
|
2707
|
+
for (const candidate of this.resumable.values()) {
|
|
2708
|
+
if (!oldest || candidate.completedAt <= oldest.completedAt) oldest = candidate;
|
|
2709
|
+
}
|
|
2710
|
+
if (!oldest) break;
|
|
2711
|
+
this.resumable.delete(oldest.handle);
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
/**
|
|
2716
|
+
* Every handle currently spoken for — live records and resumable entries
|
|
2717
|
+
* alike. One shared set, so a fresh spawn can never be handed the handle of
|
|
2718
|
+
* an evicted conversation that `@handle` can still reopen.
|
|
2719
|
+
*/
|
|
2720
|
+
private takenHandles(): ReadonlySet<string> {
|
|
2721
|
+
const taken = new Set<string>(this.resumable.keys());
|
|
2722
|
+
for (const record of this.agents.values()) {
|
|
2723
|
+
if (record.handle) taken.add(record.handle);
|
|
2724
|
+
}
|
|
2725
|
+
return taken;
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
/**
|
|
2729
|
+
* What `@handle` currently addresses: the live record holding that handle,
|
|
2730
|
+
* else the resumable entry left behind when it was evicted.
|
|
2731
|
+
*
|
|
2732
|
+
* Live wins. A resumable entry is deliberately kept after its conversation is
|
|
2733
|
+
* reopened (the reopened record may die before establishing a session of its
|
|
2734
|
+
* own, and the original transcript is still the right thing to reopen next
|
|
2735
|
+
* time), so both can hold the same handle at once — and while a record is
|
|
2736
|
+
* live, it is the one the user means.
|
|
2737
|
+
*/
|
|
2738
|
+
resolveMention(handle: string): { kind: "live"; record: AgentRecord } | { kind: "resumable"; entry: ResumableAgentEntry } | undefined {
|
|
2739
|
+
const wanted = handle.toLowerCase();
|
|
2740
|
+
for (const record of this.agents.values()) {
|
|
2741
|
+
if (record.detached) continue;
|
|
2742
|
+
if (record.handle?.toLowerCase() === wanted || record.id === handle) return { kind: "live", record };
|
|
2743
|
+
}
|
|
2744
|
+
const entry = this.getResumable(handle);
|
|
2745
|
+
return entry ? { kind: "resumable", entry } : undefined;
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
/** Resolve a resumable entry by handle or id. */
|
|
2749
|
+
getResumable(name: string): ResumableAgentEntry | undefined {
|
|
2750
|
+
const wanted = name.toLowerCase();
|
|
2751
|
+
for (const entry of this.resumable.values()) {
|
|
2752
|
+
if (entry.handle.toLowerCase() === wanted || entry.id === name) return entry;
|
|
2753
|
+
}
|
|
2754
|
+
return undefined;
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
/** Evicted agents whose conversation can still be reopened, newest first. */
|
|
2758
|
+
listResumable(): ResumableAgentEntry[] {
|
|
2759
|
+
return [...this.resumable.values()].sort((a, b) => b.completedAt - a.completedAt);
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
/** Forget an evicted agent, by handle or id. */
|
|
2763
|
+
dropResumable(name: string): boolean {
|
|
2764
|
+
const entry = this.getResumable(name);
|
|
2765
|
+
if (!entry) return false;
|
|
2766
|
+
this.resumable.delete(entry.handle);
|
|
2767
|
+
return true;
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2770
|
+
/** Cumulative descendants any one top-level agent may start. */
|
|
2771
|
+
getMaxSubagentSpawnsPerBranch(): number {
|
|
2772
|
+
return this.maxSubagentSpawnsPerBranch;
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2775
|
+
/**
|
|
2776
|
+
* Set the branch spawn budget. `0` is refused rather than treated as
|
|
2777
|
+
* "unlimited": zero reads as a limit, and silently meaning its opposite is
|
|
2778
|
+
* how a safety valve gets disabled by accident. Nesting is turned off with
|
|
2779
|
+
* `maxSubagentDepth`, which says so.
|
|
2780
|
+
*/
|
|
2781
|
+
setMaxSubagentSpawnsPerBranch(n: number): void {
|
|
2782
|
+
if (!Number.isSafeInteger(n) || n < 1) return;
|
|
2783
|
+
this.maxSubagentSpawnsPerBranch = n;
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
/** Descendants started so far under a top-level agent (for UI and tests). */
|
|
2787
|
+
getBranchSpawnCount(rootAgentId: string): number {
|
|
2788
|
+
return this.branchSpawnCounts.get(rootAgentId) ?? 0;
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
/** Whether spawned agents may ask their human a question. */
|
|
2792
|
+
getSupervisorQuestions(): boolean {
|
|
2793
|
+
return this.supervisorQuestions;
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
setSupervisorQuestions(enabled: boolean): void {
|
|
2797
|
+
this.supervisorQuestions = enabled;
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
/** Whether evicted records are indexed for `@handle` reopen. */
|
|
2801
|
+
getRememberAgents(): boolean {
|
|
2802
|
+
return this.rememberAgents;
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
setRememberAgents(enabled: boolean): void {
|
|
2806
|
+
this.rememberAgents = enabled;
|
|
2807
|
+
if (!enabled) this.resumable.clear();
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2091
2810
|
private cleanup() {
|
|
2092
2811
|
const cutoff = Date.now() - 10 * 60_000;
|
|
2093
2812
|
for (const [id, record] of this.agents) {
|
|
@@ -2096,9 +2815,7 @@ export class AgentManager {
|
|
|
2096
2815
|
this.removeRecord(id, record);
|
|
2097
2816
|
}
|
|
2098
2817
|
this.retryDeferredRecordRemovals();
|
|
2099
|
-
}
|
|
2100
|
-
|
|
2101
|
-
/**
|
|
2818
|
+
} /**
|
|
2102
2819
|
* Remove all completed/stopped/errored records immediately.
|
|
2103
2820
|
* Called on session start/switch so tasks from a prior session don't persist.
|
|
2104
2821
|
* Pass skipUnconsumed=true to preserve records the LLM hasn't read yet
|
|
@@ -2307,6 +3024,13 @@ export class AgentManager {
|
|
|
2307
3024
|
/** Quarantine a record before any late provider continuation can observe it. */
|
|
2308
3025
|
private quarantineRecord(id: string, record: AgentRecord): void {
|
|
2309
3026
|
record.detached = true;
|
|
3027
|
+
const resumeControl = this.resumeControls.get(id);
|
|
3028
|
+
if (resumeControl) {
|
|
3029
|
+
this.restoreResumeSnapshot(record, resumeControl.snapshot);
|
|
3030
|
+
this.cancelResumeControl(id);
|
|
3031
|
+
} else {
|
|
3032
|
+
record.abortController?.abort();
|
|
3033
|
+
}
|
|
2310
3034
|
|
|
2311
3035
|
if (record.status === "queued" || record.status === "running") {
|
|
2312
3036
|
record.status = "stopped";
|
|
@@ -2408,6 +3132,7 @@ export class AgentManager {
|
|
|
2408
3132
|
* notifications, or persistence into the replacement branch.
|
|
2409
3133
|
*/
|
|
2410
3134
|
detachForBranchChange(): void {
|
|
3135
|
+
this.clearManagedPersistenceRetries();
|
|
2411
3136
|
const records = [...this.agents.values()]
|
|
2412
3137
|
.filter((record) => !this.isFullyCleaned(record))
|
|
2413
3138
|
.sort((left, right) => this.recordDepth(right) - this.recordDepth(left));
|
|
@@ -2458,6 +3183,7 @@ export class AgentManager {
|
|
|
2458
3183
|
* session shutdown does not block the event loop.
|
|
2459
3184
|
*/
|
|
2460
3185
|
dispose(): Promise<readonly WorktreeCleanupFailure[]> {
|
|
3186
|
+
this.clearManagedPersistenceRetries();
|
|
2461
3187
|
if (this.disposePromise) return this.disposePromise;
|
|
2462
3188
|
this.disposed = true;
|
|
2463
3189
|
clearInterval(this.cleanupInterval);
|
|
@@ -2488,6 +3214,7 @@ export class AgentManager {
|
|
|
2488
3214
|
record.status = "stopped";
|
|
2489
3215
|
record.completedAt ??= Date.now();
|
|
2490
3216
|
}
|
|
3217
|
+
this.cancelResumeControl(record.id);
|
|
2491
3218
|
record.pendingSteers = undefined;
|
|
2492
3219
|
this.nestedSpawnSeals.add(record.id);
|
|
2493
3220
|
this.removingRecords.add(record.id);
|
|
@@ -2496,6 +3223,7 @@ export class AgentManager {
|
|
|
2496
3223
|
record.outputCleanup = undefined;
|
|
2497
3224
|
}
|
|
2498
3225
|
}
|
|
3226
|
+
this.resumeControls.clear();
|
|
2499
3227
|
|
|
2500
3228
|
this.disposePromise = this.finishDispose(records, reposToPrune);
|
|
2501
3229
|
return this.disposePromise;
|
|
@@ -2597,6 +3325,9 @@ export class AgentManager {
|
|
|
2597
3325
|
this.agents.clear();
|
|
2598
3326
|
this.managedSpawns.clear();
|
|
2599
3327
|
this.managedKeysById.clear();
|
|
3328
|
+
this.managedThreads.clear();
|
|
3329
|
+
this.managedThreadPolicies.clear();
|
|
3330
|
+
this.managedThreadReservations.clear();
|
|
2600
3331
|
this.nestedSpawnSeals.clear();
|
|
2601
3332
|
this.removingRecords.clear();
|
|
2602
3333
|
this.settlingRecords.clear();
|