@signalridge/pi-subagents 1.10.2 → 1.11.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 +35 -0
- package/README.md +21 -10
- package/examples/agent-tool-description.md +1 -1
- package/package.json +7 -6
- package/src/agent-manager.ts +1405 -486
- package/src/agent-runner.ts +486 -254
- package/src/ask-tools.ts +31 -21
- package/src/context-boundary.ts +54 -0
- package/src/context.ts +121 -36
- package/src/custom-agents.ts +7 -0
- package/src/enabled-models.ts +72 -78
- package/src/index.ts +988 -799
- package/src/mention-clone.ts +129 -83
- package/src/model-runtime-bridge.ts +68 -0
- package/src/model-scope.ts +4 -2
- package/src/nested-tools.ts +3 -1
- package/src/types.ts +6 -4
package/src/agent-manager.ts
CHANGED
|
@@ -11,11 +11,27 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
11
11
|
import { realpathSync, statSync } from "node:fs";
|
|
12
12
|
import { isAbsolute, resolve } from "node:path";
|
|
13
13
|
import type { Model } from "@earendil-works/pi-ai";
|
|
14
|
-
import type {
|
|
14
|
+
import type {
|
|
15
|
+
AgentSession,
|
|
16
|
+
ExtensionAPI,
|
|
17
|
+
ExtensionContext,
|
|
18
|
+
} from "@earendil-works/pi-coding-agent";
|
|
15
19
|
import type { ManagedSpawnRequest as ProtocolManagedSpawnRequest } from "@signalridge/pi-subagents-protocol";
|
|
16
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
isManagedAgentTier,
|
|
22
|
+
parseManagedSpawnRequest,
|
|
23
|
+
} from "@signalridge/pi-subagents-protocol";
|
|
17
24
|
import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
|
|
18
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
type AgentTierResolutionSnapshot,
|
|
27
|
+
getAgentTiersSettings,
|
|
28
|
+
resolveAgentTier,
|
|
29
|
+
} from "./agent-tiers.js";
|
|
30
|
+
import { getAgentConfig } from "./agent-types.js";
|
|
31
|
+
import {
|
|
32
|
+
assertNoRawInheritance,
|
|
33
|
+
assertSafeChildSession,
|
|
34
|
+
} from "./context-boundary.js";
|
|
19
35
|
import {
|
|
20
36
|
INTERNAL_AGENT_CONFIG_OVERRIDE,
|
|
21
37
|
INTERNAL_PARENT_POLICY_SNAPSHOT,
|
|
@@ -23,9 +39,22 @@ import {
|
|
|
23
39
|
type ParentPolicySnapshot,
|
|
24
40
|
} from "./internal-run.js";
|
|
25
41
|
import { assignHandle, handleBase } from "./mention.js";
|
|
42
|
+
import {
|
|
43
|
+
assertParentModelRuntimeAvailable,
|
|
44
|
+
parentModelSessionOptions,
|
|
45
|
+
} from "./model-runtime-bridge.js";
|
|
26
46
|
import { shutdownAndDisposeSession } from "./session-lifecycle.js";
|
|
27
47
|
import type { TierThinking } from "./settings.js";
|
|
28
|
-
import type {
|
|
48
|
+
import type {
|
|
49
|
+
AgentInvocation,
|
|
50
|
+
AgentOwner,
|
|
51
|
+
AgentRecord,
|
|
52
|
+
AgentRecordSnapshot,
|
|
53
|
+
IsolationMode,
|
|
54
|
+
ResumableAgentEntry,
|
|
55
|
+
SubagentType,
|
|
56
|
+
ThinkingLevel,
|
|
57
|
+
} from "./types.js";
|
|
29
58
|
import { addUsage } from "./usage.js";
|
|
30
59
|
import {
|
|
31
60
|
cleanupWorktree,
|
|
@@ -41,8 +70,14 @@ import {
|
|
|
41
70
|
export type OnAgentComplete = (record: AgentRecord) => void;
|
|
42
71
|
export type OnAgentCreated = (record: AgentRecord) => void;
|
|
43
72
|
export type OnAgentStart = (record: AgentRecord) => void;
|
|
44
|
-
export type OnAgentCompact = (
|
|
45
|
-
|
|
73
|
+
export type OnAgentCompact = (
|
|
74
|
+
record: AgentRecord,
|
|
75
|
+
info: CompactionInfo,
|
|
76
|
+
) => void;
|
|
77
|
+
export type CompactionInfo = {
|
|
78
|
+
reason: "manual" | "threshold" | "overflow";
|
|
79
|
+
tokensBefore: number;
|
|
80
|
+
};
|
|
46
81
|
|
|
47
82
|
export interface WorktreeCleanupFailure {
|
|
48
83
|
readonly path: string;
|
|
@@ -70,6 +105,7 @@ export const DEFAULT_MAX_SUBAGENT_SPAWNS_PER_BRANCH = 64;
|
|
|
70
105
|
const OWNED_CHILD_QUIESCE_TIMEOUT_MS = 5_000;
|
|
71
106
|
/** Shutdown briefly drains abort-responsive providers; stragglers retain worktrees. */
|
|
72
107
|
const DISPOSE_PROVIDER_QUIESCE_TIMEOUT_MS = 1_000;
|
|
108
|
+
const DISPOSE_SESSION_TEARDOWN_TIMEOUT_MS = 1_000;
|
|
73
109
|
|
|
74
110
|
/**
|
|
75
111
|
* Validate a caller-supplied SpawnOptions.cwd. `undefined`/`null` mean "unset"
|
|
@@ -77,10 +113,14 @@ const DISPOSE_PROVIDER_QUIESCE_TIMEOUT_MS = 1_000;
|
|
|
77
113
|
* directory — curated errors instead of TypeErrors from path/fs internals
|
|
78
114
|
* (RPC callers send arbitrary JSON: null, numbers, file paths).
|
|
79
115
|
*/
|
|
80
|
-
function assertValidSpawnCwd(
|
|
116
|
+
function assertValidSpawnCwd(
|
|
117
|
+
cwd: unknown,
|
|
118
|
+
): asserts cwd is string | undefined | null {
|
|
81
119
|
if (cwd == null) return;
|
|
82
120
|
if (typeof cwd !== "string" || !isAbsolute(cwd)) {
|
|
83
|
-
throw new Error(
|
|
121
|
+
throw new Error(
|
|
122
|
+
`SpawnOptions.cwd must be an absolute path: "${String(cwd)}"`,
|
|
123
|
+
);
|
|
84
124
|
}
|
|
85
125
|
let isDirectory = false;
|
|
86
126
|
try {
|
|
@@ -102,17 +142,29 @@ function assertValidSpawnCwd(cwd: unknown): asserts cwd is string | undefined |
|
|
|
102
142
|
* goes, not how WIDE. A parent's only limit on concurrent children is that each
|
|
103
143
|
* spawn costs it a turn, which is unbounded when max turns is unlimited.
|
|
104
144
|
*/
|
|
105
|
-
function occupiesPoolSlot(
|
|
145
|
+
function occupiesPoolSlot(
|
|
146
|
+
record: Pick<AgentRecord, "isBackground" | "parentAgentId">,
|
|
147
|
+
): boolean {
|
|
106
148
|
return !!record.isBackground && record.parentAgentId === undefined;
|
|
107
149
|
}
|
|
108
150
|
|
|
109
|
-
|
|
110
151
|
/** Clone only inert JSON-like data and freeze every level to break aliases. */
|
|
111
|
-
function cloneFrozenData<T>(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (
|
|
152
|
+
function cloneFrozenData<T>(
|
|
153
|
+
value: T,
|
|
154
|
+
seen: WeakSet<object> = new WeakSet(),
|
|
155
|
+
): T {
|
|
156
|
+
if (
|
|
157
|
+
value === null ||
|
|
158
|
+
value === undefined ||
|
|
159
|
+
typeof value === "string" ||
|
|
160
|
+
typeof value === "number" ||
|
|
161
|
+
typeof value === "boolean"
|
|
162
|
+
)
|
|
163
|
+
return value;
|
|
164
|
+
if (typeof value !== "object")
|
|
165
|
+
throw new Error("Agent invocation metadata must contain only inert data");
|
|
166
|
+
if (seen.has(value))
|
|
167
|
+
throw new Error("Agent invocation metadata must not be cyclic");
|
|
116
168
|
seen.add(value);
|
|
117
169
|
try {
|
|
118
170
|
if (Array.isArray(value)) {
|
|
@@ -124,10 +176,13 @@ function cloneFrozenData<T>(value: T, seen: WeakSet<object> = new WeakSet()): T
|
|
|
124
176
|
}
|
|
125
177
|
const prototype = Object.getPrototypeOf(value);
|
|
126
178
|
if (prototype !== Object.prototype && prototype !== null) {
|
|
127
|
-
throw new Error(
|
|
179
|
+
throw new Error(
|
|
180
|
+
"Agent invocation metadata must contain only plain objects",
|
|
181
|
+
);
|
|
128
182
|
}
|
|
129
183
|
const clone: Record<string, unknown> = {};
|
|
130
|
-
for (const [key, item] of Object.entries(value))
|
|
184
|
+
for (const [key, item] of Object.entries(value))
|
|
185
|
+
clone[key] = cloneFrozenData(item, seen);
|
|
131
186
|
return Object.freeze(clone) as T;
|
|
132
187
|
} finally {
|
|
133
188
|
seen.delete(value);
|
|
@@ -143,14 +198,24 @@ function cloneFrozenData<T>(value: T, seen: WeakSet<object> = new WeakSet()): T
|
|
|
143
198
|
function snapshotAgentRecord(record: AgentRecord): AgentRecordSnapshot {
|
|
144
199
|
const owner = record.owner ? Object.freeze({ ...record.owner }) : undefined;
|
|
145
200
|
const lifetimeUsage = Object.freeze({ ...record.lifetimeUsage });
|
|
146
|
-
const pendingSteers = record.pendingSteers
|
|
147
|
-
|
|
148
|
-
|
|
201
|
+
const pendingSteers = record.pendingSteers
|
|
202
|
+
? Object.freeze([...record.pendingSteers])
|
|
203
|
+
: undefined;
|
|
204
|
+
const invocation = record.invocation
|
|
205
|
+
? cloneFrozenData(record.invocation)
|
|
206
|
+
: undefined;
|
|
207
|
+
const worktree = record.worktree
|
|
208
|
+
? Object.freeze({ ...record.worktree })
|
|
209
|
+
: undefined;
|
|
149
210
|
const worktreeResult = record.worktreeResult
|
|
150
211
|
? Object.freeze({
|
|
151
212
|
...record.worktreeResult,
|
|
152
213
|
...(record.worktreeResult.recoveryCommands
|
|
153
|
-
? {
|
|
214
|
+
? {
|
|
215
|
+
recoveryCommands: Object.freeze([
|
|
216
|
+
...record.worktreeResult.recoveryCommands,
|
|
217
|
+
]),
|
|
218
|
+
}
|
|
154
219
|
: {}),
|
|
155
220
|
})
|
|
156
221
|
: undefined;
|
|
@@ -182,7 +247,10 @@ function shellQuote(value: string | undefined): string {
|
|
|
182
247
|
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
183
248
|
}
|
|
184
249
|
|
|
185
|
-
function worktreeRecoveryCommands(
|
|
250
|
+
function worktreeRecoveryCommands(
|
|
251
|
+
cwd?: string,
|
|
252
|
+
path?: string,
|
|
253
|
+
): readonly string[] {
|
|
186
254
|
const c = cwd ? shellQuote(cwd) : "'.'";
|
|
187
255
|
const p = path ? shellQuote(path) : "''";
|
|
188
256
|
return Object.freeze([
|
|
@@ -192,7 +260,6 @@ function worktreeRecoveryCommands(cwd?: string, path?: string): readonly string[
|
|
|
192
260
|
]);
|
|
193
261
|
}
|
|
194
262
|
|
|
195
|
-
|
|
196
263
|
function sameFilesystemPath(left: string, right: string): boolean {
|
|
197
264
|
if (left === right) return true;
|
|
198
265
|
try {
|
|
@@ -202,10 +269,15 @@ function sameFilesystemPath(left: string, right: string): boolean {
|
|
|
202
269
|
}
|
|
203
270
|
}
|
|
204
271
|
|
|
205
|
-
function cleanupFailureResult(
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
272
|
+
function cleanupFailureResult(
|
|
273
|
+
cwd: string,
|
|
274
|
+
worktree: WorktreeInfo,
|
|
275
|
+
error: unknown,
|
|
276
|
+
): WorktreeCleanupResult {
|
|
277
|
+
const reason =
|
|
278
|
+
`Worktree cleanup threw before reporting an outcome for ${worktree.path}: ${error instanceof Error ? error.message : String(error)}`
|
|
279
|
+
.replace(/\s+/g, " ")
|
|
280
|
+
.slice(0, WORKTREE_FAILURE_DIAGNOSTIC_LIMIT);
|
|
209
281
|
return {
|
|
210
282
|
hasChanges: false,
|
|
211
283
|
path: worktree.path,
|
|
@@ -215,8 +287,13 @@ function cleanupFailureResult(cwd: string, worktree: WorktreeInfo, error: unknow
|
|
|
215
287
|
};
|
|
216
288
|
}
|
|
217
289
|
|
|
218
|
-
function snapshotCleanupFailure(
|
|
219
|
-
|
|
290
|
+
function snapshotCleanupFailure(
|
|
291
|
+
worktree: WorktreeInfo,
|
|
292
|
+
result: WorktreeCleanupResult,
|
|
293
|
+
): WorktreeCleanupFailure {
|
|
294
|
+
const reason = (
|
|
295
|
+
result.cleanupDiagnostic ?? `Worktree cleanup failed for ${worktree.path}`
|
|
296
|
+
)
|
|
220
297
|
.replace(/\s+/g, " ")
|
|
221
298
|
.slice(0, WORKTREE_FAILURE_DIAGNOSTIC_LIMIT);
|
|
222
299
|
const recovery = result.recoveryCommands?.length
|
|
@@ -258,7 +335,8 @@ const MANAGED_PERSIST_RETRY_INITIAL_DELAY_MS = 25;
|
|
|
258
335
|
const MANAGED_PERSIST_RETRY_MAX_DELAY_MS = 2_000;
|
|
259
336
|
const MANAGED_PERSIST_RETRY_MAX_ATTEMPTS = 8;
|
|
260
337
|
|
|
261
|
-
export type ManagedSpawnState =
|
|
338
|
+
export type ManagedSpawnState =
|
|
339
|
+
"queued" | "running" | "completed" | "failed" | "stopped" | "interrupted";
|
|
262
340
|
|
|
263
341
|
export interface ManagedTerminalSnapshot {
|
|
264
342
|
status: "completed" | "failed" | "stopped" | "interrupted";
|
|
@@ -325,35 +403,56 @@ type ManagedRecordStatus = AgentRecord["status"];
|
|
|
325
403
|
|
|
326
404
|
type ManagedRequestRecord = Record<string, unknown>;
|
|
327
405
|
|
|
328
|
-
const MANAGED_OWNER_KEYS = new Set([
|
|
406
|
+
const MANAGED_OWNER_KEYS = new Set([
|
|
407
|
+
"extension",
|
|
408
|
+
"runId",
|
|
409
|
+
"nodeId",
|
|
410
|
+
"attemptId",
|
|
411
|
+
]);
|
|
329
412
|
|
|
330
413
|
function isRecord(value: unknown): value is ManagedRequestRecord {
|
|
331
414
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
332
415
|
}
|
|
333
416
|
|
|
334
|
-
function boundedManagedString(
|
|
417
|
+
function boundedManagedString(
|
|
418
|
+
value: unknown,
|
|
419
|
+
label: string,
|
|
420
|
+
max: number,
|
|
421
|
+
): string {
|
|
335
422
|
if (typeof value !== "string") throw new Error(`${label} must be a string`);
|
|
336
423
|
const normalized = value.trim();
|
|
337
424
|
if (!normalized) throw new Error(`${label} must not be empty`);
|
|
338
|
-
if (normalized.length > max)
|
|
425
|
+
if (normalized.length > max)
|
|
426
|
+
throw new Error(`${label} exceeds ${max} characters`);
|
|
339
427
|
return normalized;
|
|
340
428
|
}
|
|
341
429
|
|
|
342
|
-
function rejectManagedKeys(
|
|
430
|
+
function rejectManagedKeys(
|
|
431
|
+
value: ManagedRequestRecord,
|
|
432
|
+
allowed: ReadonlySet<string>,
|
|
433
|
+
label: string,
|
|
434
|
+
): void {
|
|
343
435
|
for (const key of Object.keys(value)) {
|
|
344
|
-
if (!allowed.has(key))
|
|
436
|
+
if (!allowed.has(key))
|
|
437
|
+
throw new Error(`${label} contains unsupported field "${key}"`);
|
|
345
438
|
}
|
|
346
439
|
}
|
|
347
440
|
|
|
348
|
-
function normalizeManagedOwner(
|
|
441
|
+
function normalizeManagedOwner(
|
|
442
|
+
raw: unknown,
|
|
443
|
+
requireAttempt = false,
|
|
444
|
+
): AgentOwner {
|
|
349
445
|
if (!isRecord(raw)) throw new Error("owner must be an object");
|
|
350
446
|
rejectManagedKeys(raw, MANAGED_OWNER_KEYS, "owner");
|
|
351
447
|
const extension = boundedManagedString(raw.extension, "owner.extension", 64);
|
|
352
|
-
if (extension !== "pi-workflows")
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
448
|
+
if (extension !== "pi-workflows")
|
|
449
|
+
throw new Error('owner.extension must be "pi-workflows"');
|
|
450
|
+
const attemptId =
|
|
451
|
+
raw.attemptId === undefined
|
|
452
|
+
? undefined
|
|
453
|
+
: boundedManagedString(raw.attemptId, "owner.attemptId", 256);
|
|
454
|
+
if (requireAttempt && attemptId === undefined)
|
|
455
|
+
throw new Error("owner.attemptId is required");
|
|
357
456
|
return {
|
|
358
457
|
extension,
|
|
359
458
|
runId: boundedManagedString(raw.runId, "owner.runId", 256),
|
|
@@ -362,30 +461,41 @@ function normalizeManagedOwner(raw: unknown, requireAttempt = false): AgentOwner
|
|
|
362
461
|
};
|
|
363
462
|
}
|
|
364
463
|
|
|
365
|
-
function managedFingerprint(
|
|
464
|
+
function managedFingerprint(
|
|
465
|
+
request: ManagedSpawnRequest,
|
|
466
|
+
policyFingerprint?: string,
|
|
467
|
+
): string {
|
|
366
468
|
return createHash("sha256")
|
|
367
|
-
.update(
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
469
|
+
.update(
|
|
470
|
+
JSON.stringify([
|
|
471
|
+
request.type,
|
|
472
|
+
request.prompt,
|
|
473
|
+
request.description,
|
|
474
|
+
request.tier,
|
|
475
|
+
request.toolset,
|
|
476
|
+
request.excludeTools,
|
|
477
|
+
request.isolation,
|
|
478
|
+
request.thread,
|
|
479
|
+
request.owner.extension,
|
|
480
|
+
request.owner.runId,
|
|
481
|
+
request.owner.nodeId,
|
|
482
|
+
request.owner.attemptId,
|
|
483
|
+
policyFingerprint,
|
|
484
|
+
]),
|
|
485
|
+
)
|
|
382
486
|
.digest("hex");
|
|
383
487
|
}
|
|
384
488
|
|
|
385
|
-
|
|
386
|
-
|
|
489
|
+
function currentParentThinkingLevel(
|
|
490
|
+
pi: ExtensionAPI,
|
|
491
|
+
): ThinkingLevel | undefined {
|
|
387
492
|
const level = pi.getThinkingLevel?.();
|
|
388
|
-
return level === "minimal" ||
|
|
493
|
+
return level === "minimal" ||
|
|
494
|
+
level === "low" ||
|
|
495
|
+
level === "medium" ||
|
|
496
|
+
level === "high" ||
|
|
497
|
+
level === "xhigh" ||
|
|
498
|
+
level === "max"
|
|
389
499
|
? level
|
|
390
500
|
: undefined;
|
|
391
501
|
}
|
|
@@ -407,27 +517,39 @@ function managedThreadPolicyFingerprint(
|
|
|
407
517
|
parentThinking: ThinkingLevel | undefined,
|
|
408
518
|
): string {
|
|
409
519
|
const effectiveTier = request.tier ?? policy.invocation?.agentTier;
|
|
410
|
-
const profile = effectiveTier
|
|
520
|
+
const profile = effectiveTier
|
|
521
|
+
? getAgentTiersSettings().profiles?.[effectiveTier]
|
|
522
|
+
: undefined;
|
|
411
523
|
const inheritedModelIdentity =
|
|
412
|
-
profile?.model === "inherit"
|
|
413
|
-
|
|
414
|
-
|
|
524
|
+
profile?.model === "inherit"
|
|
525
|
+
? { provider: parentModel?.provider, id: parentModel?.id }
|
|
526
|
+
: undefined;
|
|
527
|
+
const inheritedThinkingIdentity =
|
|
528
|
+
profile?.thinking === "inherit" ? parentThinking : undefined;
|
|
529
|
+
const excludeTools = [
|
|
530
|
+
...new Set([
|
|
531
|
+
...(request.excludeTools ?? []),
|
|
532
|
+
...(policy.excludeTools ?? []),
|
|
533
|
+
]),
|
|
534
|
+
].sort();
|
|
415
535
|
return createHash("sha256")
|
|
416
|
-
.update(
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
536
|
+
.update(
|
|
537
|
+
JSON.stringify([
|
|
538
|
+
request.type,
|
|
539
|
+
effectiveTier,
|
|
540
|
+
profile?.model,
|
|
541
|
+
profile?.thinking,
|
|
542
|
+
inheritedModelIdentity,
|
|
543
|
+
inheritedThinkingIdentity,
|
|
544
|
+
request.toolset ?? policy.toolset,
|
|
545
|
+
excludeTools,
|
|
546
|
+
request.isolation ?? policy.isolation,
|
|
547
|
+
policy.maxTurns,
|
|
548
|
+
policy.isolated,
|
|
549
|
+
policy.inheritContext,
|
|
550
|
+
policy.policyFingerprint,
|
|
551
|
+
]),
|
|
552
|
+
)
|
|
431
553
|
.digest("hex");
|
|
432
554
|
}
|
|
433
555
|
|
|
@@ -439,7 +561,10 @@ function managedThreadPolicyFingerprint(
|
|
|
439
561
|
* resolves to and invalidates the session it was reusing. A message that only
|
|
440
562
|
* says "policy changed" sends the reader looking in the wrong file.
|
|
441
563
|
*/
|
|
442
|
-
function threadPolicyConflictMessage(
|
|
564
|
+
function threadPolicyConflictMessage(
|
|
565
|
+
thread: string,
|
|
566
|
+
tier: string | undefined,
|
|
567
|
+
): string {
|
|
443
568
|
const cause = tier
|
|
444
569
|
? `Its tier "${tier}", the session model or thinking level that tier inherits, or its tool/isolation policy changed since the thread's last call.`
|
|
445
570
|
: "Its model, thinking, tool, or isolation policy changed since the thread's last call.";
|
|
@@ -450,34 +575,64 @@ function threadPolicyConflictMessage(thread: string, tier: string | undefined):
|
|
|
450
575
|
}
|
|
451
576
|
|
|
452
577
|
function isManagedState(value: unknown): value is ManagedSpawnState {
|
|
453
|
-
return
|
|
578
|
+
return (
|
|
579
|
+
value === "queued" ||
|
|
580
|
+
value === "running" ||
|
|
581
|
+
value === "completed" ||
|
|
582
|
+
value === "failed" ||
|
|
583
|
+
value === "stopped" ||
|
|
584
|
+
value === "interrupted"
|
|
585
|
+
);
|
|
454
586
|
}
|
|
455
587
|
|
|
456
|
-
function isManagedTerminalState(
|
|
457
|
-
|
|
588
|
+
function isManagedTerminalState(
|
|
589
|
+
value: ManagedSpawnState,
|
|
590
|
+
): value is "completed" | "failed" | "stopped" | "interrupted" {
|
|
591
|
+
return (
|
|
592
|
+
value === "completed" ||
|
|
593
|
+
value === "failed" ||
|
|
594
|
+
value === "stopped" ||
|
|
595
|
+
value === "interrupted"
|
|
596
|
+
);
|
|
458
597
|
}
|
|
459
598
|
|
|
460
|
-
function capManagedText(
|
|
599
|
+
function capManagedText(
|
|
600
|
+
value: string | undefined,
|
|
601
|
+
limit: number,
|
|
602
|
+
): string | undefined {
|
|
461
603
|
if (!value) return undefined;
|
|
462
604
|
if (value.length <= limit) return value;
|
|
463
605
|
const marker = "\n…[truncated]";
|
|
464
606
|
return `${value.slice(0, Math.max(0, limit - marker.length))}${marker}`;
|
|
465
607
|
}
|
|
466
608
|
|
|
467
|
-
function cloneManagedTerminal(
|
|
609
|
+
function cloneManagedTerminal(
|
|
610
|
+
terminal: ManagedTerminalSnapshot | undefined,
|
|
611
|
+
): ManagedTerminalSnapshot | undefined {
|
|
468
612
|
return terminal ? { ...terminal } : undefined;
|
|
469
613
|
}
|
|
470
614
|
|
|
471
|
-
function cloneManagedTombstone(
|
|
615
|
+
function cloneManagedTombstone(
|
|
616
|
+
tombstone: ManagedSpawnTombstone,
|
|
617
|
+
): ManagedSpawnTombstone {
|
|
472
618
|
return {
|
|
473
619
|
...tombstone,
|
|
474
620
|
owner: { ...tombstone.owner },
|
|
475
|
-
...(tombstone.tierSnapshot
|
|
621
|
+
...(tombstone.tierSnapshot
|
|
622
|
+
? { tierSnapshot: { ...tombstone.tierSnapshot } }
|
|
623
|
+
: {}),
|
|
476
624
|
terminal: cloneManagedTerminal(tombstone.terminal),
|
|
477
625
|
};
|
|
478
626
|
}
|
|
479
627
|
|
|
480
|
-
const THINKING_LEVELS = new Set<ThinkingLevel>([
|
|
628
|
+
const THINKING_LEVELS = new Set<ThinkingLevel>([
|
|
629
|
+
"minimal",
|
|
630
|
+
"low",
|
|
631
|
+
"medium",
|
|
632
|
+
"high",
|
|
633
|
+
"xhigh",
|
|
634
|
+
"max",
|
|
635
|
+
]);
|
|
481
636
|
const TIER_SNAPSHOT_KEYS = new Set([
|
|
482
637
|
"tier",
|
|
483
638
|
"source",
|
|
@@ -490,29 +645,57 @@ const TIER_SNAPSHOT_KEYS = new Set([
|
|
|
490
645
|
"diagnostic",
|
|
491
646
|
]);
|
|
492
647
|
|
|
493
|
-
function parseTierSnapshot(
|
|
494
|
-
|
|
648
|
+
function parseTierSnapshot(
|
|
649
|
+
raw: unknown,
|
|
650
|
+
tier: string,
|
|
651
|
+
): AgentTierResolutionSnapshot | undefined {
|
|
652
|
+
if (!isRecord(raw) || raw.tier !== tier || !isManagedAgentTier(tier))
|
|
653
|
+
return undefined;
|
|
495
654
|
try {
|
|
496
655
|
rejectManagedKeys(raw, TIER_SNAPSHOT_KEYS, "tierSnapshot");
|
|
497
656
|
const source = raw.source;
|
|
498
|
-
if (source !== "call" && source !== "frontmatter" && source !== "default")
|
|
657
|
+
if (source !== "call" && source !== "frontmatter" && source !== "default")
|
|
658
|
+
return undefined;
|
|
499
659
|
const optionalThinking = (value: unknown): ThinkingLevel | undefined => {
|
|
500
660
|
if (value === undefined) return undefined;
|
|
501
|
-
return typeof value === "string" &&
|
|
502
|
-
|
|
661
|
+
return typeof value === "string" &&
|
|
662
|
+
THINKING_LEVELS.has(value as ThinkingLevel)
|
|
663
|
+
? (value as ThinkingLevel)
|
|
503
664
|
: undefined;
|
|
504
665
|
};
|
|
505
666
|
const thinking = optionalThinking(raw.thinking);
|
|
506
667
|
const requestedThinking = optionalThinking(raw.requestedThinking);
|
|
507
668
|
const configuredThinking = raw.configuredThinking;
|
|
508
|
-
if (
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
669
|
+
if (
|
|
670
|
+
typeof raw.configuredModel !== "string" ||
|
|
671
|
+
!raw.configuredModel.trim() ||
|
|
672
|
+
raw.configuredModel.length > MANAGED_PATH_LIMIT
|
|
673
|
+
)
|
|
674
|
+
return undefined;
|
|
675
|
+
if (
|
|
676
|
+
typeof configuredThinking !== "string" ||
|
|
677
|
+
![...THINKING_LEVELS, "inherit"].includes(configuredThinking)
|
|
678
|
+
)
|
|
679
|
+
return undefined;
|
|
680
|
+
if (
|
|
681
|
+
raw.model !== undefined &&
|
|
682
|
+
(typeof raw.model !== "string" ||
|
|
683
|
+
!raw.model.trim() ||
|
|
684
|
+
raw.model.length > MANAGED_PATH_LIMIT ||
|
|
685
|
+
!raw.model.includes("/"))
|
|
686
|
+
)
|
|
687
|
+
return undefined;
|
|
512
688
|
if (raw.thinking !== undefined && thinking === undefined) return undefined;
|
|
513
|
-
if (raw.requestedThinking !== undefined && requestedThinking === undefined)
|
|
514
|
-
|
|
515
|
-
if (raw.
|
|
689
|
+
if (raw.requestedThinking !== undefined && requestedThinking === undefined)
|
|
690
|
+
return undefined;
|
|
691
|
+
if (raw.clamped !== undefined && typeof raw.clamped !== "boolean")
|
|
692
|
+
return undefined;
|
|
693
|
+
if (
|
|
694
|
+
raw.diagnostic !== undefined &&
|
|
695
|
+
(typeof raw.diagnostic !== "string" ||
|
|
696
|
+
raw.diagnostic.length > MANAGED_ERROR_LIMIT)
|
|
697
|
+
)
|
|
698
|
+
return undefined;
|
|
516
699
|
return {
|
|
517
700
|
tier,
|
|
518
701
|
source,
|
|
@@ -522,14 +705,18 @@ function parseTierSnapshot(raw: unknown, tier: string): AgentTierResolutionSnaps
|
|
|
522
705
|
configuredThinking: configuredThinking as TierThinking,
|
|
523
706
|
...(requestedThinking ? { requestedThinking } : {}),
|
|
524
707
|
...(raw.clamped === true ? { clamped: true } : {}),
|
|
525
|
-
...(typeof raw.diagnostic === "string"
|
|
708
|
+
...(typeof raw.diagnostic === "string"
|
|
709
|
+
? { diagnostic: raw.diagnostic }
|
|
710
|
+
: {}),
|
|
526
711
|
};
|
|
527
712
|
} catch {
|
|
528
713
|
return undefined;
|
|
529
714
|
}
|
|
530
715
|
}
|
|
531
716
|
|
|
532
|
-
function terminalStatusForRecord(
|
|
717
|
+
function terminalStatusForRecord(
|
|
718
|
+
status: ManagedRecordStatus,
|
|
719
|
+
): ManagedTerminalSnapshot["status"] | undefined {
|
|
533
720
|
if (status === "completed" || status === "steered") return "completed";
|
|
534
721
|
if (status === "error" || status === "aborted") return "failed";
|
|
535
722
|
if (status === "stopped") return "stopped";
|
|
@@ -543,18 +730,30 @@ function managedSpawnKeyForQuarantine(raw: unknown): string | undefined {
|
|
|
543
730
|
return key.length > 0 && key.length <= 256 ? key : undefined;
|
|
544
731
|
}
|
|
545
732
|
|
|
546
|
-
function parseManagedTombstone(
|
|
547
|
-
|
|
733
|
+
function parseManagedTombstone(
|
|
734
|
+
raw: unknown,
|
|
735
|
+
): ManagedSpawnTombstone | undefined {
|
|
736
|
+
if (!isRecord(raw) || raw.schemaVersion !== MANAGED_SPAWN_SCHEMA_VERSION)
|
|
737
|
+
return undefined;
|
|
548
738
|
try {
|
|
549
739
|
const state = raw.state;
|
|
550
740
|
if (!isManagedState(state)) return undefined;
|
|
551
741
|
const terminalValue = raw.terminal;
|
|
552
742
|
const persistedCompactionCount = raw.compactionCount;
|
|
553
|
-
const thread =
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
743
|
+
const thread =
|
|
744
|
+
raw.thread === undefined
|
|
745
|
+
? undefined
|
|
746
|
+
: boundedManagedString(raw.thread, "thread", 128);
|
|
747
|
+
const threadPolicyFingerprint =
|
|
748
|
+
raw.threadPolicyFingerprint === undefined
|
|
749
|
+
? undefined
|
|
750
|
+
: boundedManagedString(
|
|
751
|
+
raw.threadPolicyFingerprint,
|
|
752
|
+
"threadPolicyFingerprint",
|
|
753
|
+
128,
|
|
754
|
+
);
|
|
755
|
+
if ((thread === undefined) !== (threadPolicyFingerprint === undefined))
|
|
756
|
+
return undefined;
|
|
558
757
|
const rawTier = raw.tier;
|
|
559
758
|
if (rawTier !== undefined && !isManagedAgentTier(rawTier)) return undefined;
|
|
560
759
|
const tier = rawTier as string | undefined;
|
|
@@ -567,40 +766,108 @@ function parseManagedTombstone(raw: unknown): ManagedSpawnTombstone | undefined
|
|
|
567
766
|
}
|
|
568
767
|
if (
|
|
569
768
|
typeof persistedCompactionCount !== "undefined" &&
|
|
570
|
-
(typeof persistedCompactionCount !== "number" ||
|
|
571
|
-
|
|
769
|
+
(typeof persistedCompactionCount !== "number" ||
|
|
770
|
+
!Number.isInteger(persistedCompactionCount) ||
|
|
771
|
+
persistedCompactionCount < 0)
|
|
772
|
+
)
|
|
773
|
+
return undefined;
|
|
572
774
|
let terminal: ManagedTerminalSnapshot | undefined;
|
|
573
775
|
if (terminalValue !== undefined) {
|
|
574
|
-
if (
|
|
776
|
+
if (
|
|
777
|
+
!isRecord(terminalValue) ||
|
|
778
|
+
!isManagedTerminalState(terminalValue.status as ManagedSpawnState)
|
|
779
|
+
)
|
|
780
|
+
return undefined;
|
|
575
781
|
const completedAt = terminalValue.completedAt;
|
|
576
782
|
const compactionCount = terminalValue.compactionCount;
|
|
577
783
|
if (
|
|
578
|
-
typeof completedAt !== "number" ||
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
784
|
+
typeof completedAt !== "number" ||
|
|
785
|
+
!Number.isFinite(completedAt) ||
|
|
786
|
+
completedAt < 0 ||
|
|
787
|
+
completedAt > MANAGED_MAX_TIMESTAMP ||
|
|
788
|
+
typeof compactionCount !== "number" ||
|
|
789
|
+
!Number.isInteger(compactionCount) ||
|
|
790
|
+
compactionCount < 0
|
|
791
|
+
)
|
|
792
|
+
return undefined;
|
|
793
|
+
if (
|
|
794
|
+
typeof terminalValue.tokenCount !== "undefined" &&
|
|
795
|
+
(typeof terminalValue.tokenCount !== "number" ||
|
|
796
|
+
!Number.isInteger(terminalValue.tokenCount) ||
|
|
797
|
+
terminalValue.tokenCount < 0)
|
|
798
|
+
)
|
|
799
|
+
return undefined;
|
|
800
|
+
if (
|
|
801
|
+
typeof terminalValue.result !== "undefined" &&
|
|
802
|
+
typeof terminalValue.result !== "string"
|
|
803
|
+
)
|
|
804
|
+
return undefined;
|
|
805
|
+
if (
|
|
806
|
+
typeof terminalValue.error !== "undefined" &&
|
|
807
|
+
typeof terminalValue.error !== "string"
|
|
808
|
+
)
|
|
809
|
+
return undefined;
|
|
810
|
+
if (
|
|
811
|
+
typeof terminalValue.outputFile !== "undefined" &&
|
|
812
|
+
typeof terminalValue.outputFile !== "string"
|
|
813
|
+
)
|
|
814
|
+
return undefined;
|
|
586
815
|
terminal = {
|
|
587
816
|
status: terminalValue.status as ManagedTerminalSnapshot["status"],
|
|
588
|
-
...(capManagedText(
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
817
|
+
...(capManagedText(
|
|
818
|
+
typeof terminalValue.result === "string"
|
|
819
|
+
? terminalValue.result
|
|
820
|
+
: undefined,
|
|
821
|
+
MANAGED_TEXT_LIMIT,
|
|
822
|
+
)
|
|
823
|
+
? {
|
|
824
|
+
result: capManagedText(
|
|
825
|
+
terminalValue.result as string,
|
|
826
|
+
MANAGED_TEXT_LIMIT,
|
|
827
|
+
),
|
|
828
|
+
}
|
|
829
|
+
: {}),
|
|
830
|
+
...(capManagedText(
|
|
831
|
+
typeof terminalValue.error === "string"
|
|
832
|
+
? terminalValue.error
|
|
833
|
+
: undefined,
|
|
834
|
+
MANAGED_ERROR_LIMIT,
|
|
835
|
+
)
|
|
836
|
+
? {
|
|
837
|
+
error: capManagedText(
|
|
838
|
+
terminalValue.error as string,
|
|
839
|
+
MANAGED_ERROR_LIMIT,
|
|
840
|
+
),
|
|
841
|
+
}
|
|
842
|
+
: {}),
|
|
843
|
+
...(typeof terminalValue.outputFile === "string"
|
|
844
|
+
? {
|
|
845
|
+
outputFile: terminalValue.outputFile.slice(0, MANAGED_PATH_LIMIT),
|
|
846
|
+
}
|
|
847
|
+
: {}),
|
|
848
|
+
...(typeof terminalValue.tokenCount === "number"
|
|
849
|
+
? { tokenCount: terminalValue.tokenCount }
|
|
850
|
+
: {}),
|
|
592
851
|
compactionCount,
|
|
593
852
|
completedAt,
|
|
594
853
|
};
|
|
595
854
|
}
|
|
596
|
-
if (isManagedTerminalState(state) !== (terminal !== undefined))
|
|
855
|
+
if (isManagedTerminalState(state) !== (terminal !== undefined))
|
|
856
|
+
return undefined;
|
|
597
857
|
if (terminal && terminal.status !== state) return undefined;
|
|
598
858
|
const createdAt = raw.createdAt;
|
|
599
859
|
const updatedAt = raw.updatedAt;
|
|
600
860
|
if (
|
|
601
|
-
typeof createdAt !== "number" ||
|
|
602
|
-
|
|
603
|
-
|
|
861
|
+
typeof createdAt !== "number" ||
|
|
862
|
+
!Number.isFinite(createdAt) ||
|
|
863
|
+
createdAt < 0 ||
|
|
864
|
+
createdAt > MANAGED_MAX_TIMESTAMP ||
|
|
865
|
+
typeof updatedAt !== "number" ||
|
|
866
|
+
!Number.isFinite(updatedAt) ||
|
|
867
|
+
updatedAt < 0 ||
|
|
868
|
+
updatedAt > MANAGED_MAX_TIMESTAMP
|
|
869
|
+
)
|
|
870
|
+
return undefined;
|
|
604
871
|
return {
|
|
605
872
|
schemaVersion: MANAGED_SPAWN_SCHEMA_VERSION,
|
|
606
873
|
spawnKey: boundedManagedString(raw.spawnKey, "spawnKey", 256),
|
|
@@ -616,7 +883,10 @@ function parseManagedTombstone(raw: unknown): ManagedSpawnTombstone | undefined
|
|
|
616
883
|
state,
|
|
617
884
|
createdAt,
|
|
618
885
|
updatedAt,
|
|
619
|
-
compactionCount:
|
|
886
|
+
compactionCount:
|
|
887
|
+
typeof persistedCompactionCount === "number"
|
|
888
|
+
? persistedCompactionCount
|
|
889
|
+
: (terminal?.compactionCount ?? 0),
|
|
620
890
|
terminal,
|
|
621
891
|
};
|
|
622
892
|
} catch {
|
|
@@ -672,13 +942,20 @@ export interface SpawnOptions {
|
|
|
672
942
|
/** Called when the agent session is created (for accessing session stats). */
|
|
673
943
|
onSessionCreated?: (session: AgentSession) => void;
|
|
674
944
|
/** Called after pi-subagents resolves the tier for this spawn; see RunOptions. */
|
|
675
|
-
onAgentTierResolved?: (
|
|
945
|
+
onAgentTierResolved?: (
|
|
946
|
+
snapshot: AgentTierResolutionSnapshot,
|
|
947
|
+
modelLabel?: string,
|
|
948
|
+
) => void;
|
|
676
949
|
/** Called synchronously after a new record is allocated, before session creation. */
|
|
677
950
|
onSpawned?: (id: string) => void;
|
|
678
951
|
/** Called at the end of each agentic turn with the cumulative count. */
|
|
679
952
|
onTurnEnd?: (turnCount: number) => void;
|
|
680
953
|
/** Called once per assistant message_end with that message's usage delta. */
|
|
681
|
-
onAssistantUsage?: (usage: {
|
|
954
|
+
onAssistantUsage?: (usage: {
|
|
955
|
+
input: number;
|
|
956
|
+
output: number;
|
|
957
|
+
cacheWrite: number;
|
|
958
|
+
}) => void;
|
|
682
959
|
/** Called when the session successfully compacts. */
|
|
683
960
|
onCompaction?: (info: CompactionInfo) => void;
|
|
684
961
|
/** Nesting depth: top-level subagent = 1. */
|
|
@@ -710,7 +987,14 @@ export interface SpawnOptions {
|
|
|
710
987
|
/** Internal managed-spawn policy; model/thinking resolution is finalized by runAgent. */
|
|
711
988
|
export type ManagedSpawnPolicy = Pick<
|
|
712
989
|
SpawnOptions,
|
|
713
|
-
|
|
990
|
+
| "maxTurns"
|
|
991
|
+
| "isolated"
|
|
992
|
+
| "inheritContext"
|
|
993
|
+
| "isolation"
|
|
994
|
+
| "invocation"
|
|
995
|
+
| "rootSessionId"
|
|
996
|
+
| "toolset"
|
|
997
|
+
| "excludeTools"
|
|
714
998
|
> & {
|
|
715
999
|
/** In-process identity of the resolved agent definition/tool allowlist for thread reuse. */
|
|
716
1000
|
policyFingerprint?: string;
|
|
@@ -762,11 +1046,14 @@ export class AgentManager {
|
|
|
762
1046
|
/** Reservation closes the synchronous onSpawned -> same-thread re-entry window. */
|
|
763
1047
|
private managedThreadReservations = new Set<string>();
|
|
764
1048
|
private managedPersistence?: ManagedSpawnPersistence;
|
|
765
|
-
private readonly managedPersistenceRetries = new Map<
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
1049
|
+
private readonly managedPersistenceRetries = new Map<
|
|
1050
|
+
string,
|
|
1051
|
+
{
|
|
1052
|
+
tombstone: ManagedSpawnTombstone;
|
|
1053
|
+
attempt: number;
|
|
1054
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
1055
|
+
}
|
|
1056
|
+
>();
|
|
770
1057
|
private maxConcurrent: number;
|
|
771
1058
|
/** Base repos worktrees were created from — so dispose() can prune them all,
|
|
772
1059
|
* not just the parent repo (caller-supplied cwd can target other repos). */
|
|
@@ -819,11 +1106,12 @@ export class AgentManager {
|
|
|
819
1106
|
/** Idempotent async manager disposal shared by root shutdown and callers. */
|
|
820
1107
|
private disposePromise?: Promise<readonly WorktreeCleanupFailure[]>;
|
|
821
1108
|
/** Teardown promise associated with a record that has been quarantined/evicted. */
|
|
822
|
-
private readonly recordSessionTeardowns = new Map<string, Promise<void
|
|
1109
|
+
private readonly recordSessionTeardowns = new Map<string, Promise<void>>();
|
|
823
1110
|
/** Prevent late promise continuations from recreating disposed ownership metadata. */
|
|
824
1111
|
private disposed = false;
|
|
825
1112
|
/** Immutable diagnostics retained when shutdown cannot remove a worktree. */
|
|
826
|
-
private worktreeCleanupFailures: readonly WorktreeCleanupFailure[] =
|
|
1113
|
+
private worktreeCleanupFailures: readonly WorktreeCleanupFailure[] =
|
|
1114
|
+
Object.freeze([]);
|
|
827
1115
|
|
|
828
1116
|
constructor(
|
|
829
1117
|
onComplete?: OnAgentComplete,
|
|
@@ -875,7 +1163,10 @@ export class AgentManager {
|
|
|
875
1163
|
return teardown;
|
|
876
1164
|
}
|
|
877
1165
|
|
|
878
|
-
private trackRecordSessionTeardown(
|
|
1166
|
+
private trackRecordSessionTeardown(
|
|
1167
|
+
id: string,
|
|
1168
|
+
session: AgentSession,
|
|
1169
|
+
): Promise<void> {
|
|
879
1170
|
const teardown = this.trackSessionTeardown(session);
|
|
880
1171
|
this.recordSessionTeardowns.set(id, teardown);
|
|
881
1172
|
const release = (): void => {
|
|
@@ -890,13 +1181,28 @@ export class AgentManager {
|
|
|
890
1181
|
return teardown;
|
|
891
1182
|
}
|
|
892
1183
|
|
|
893
|
-
private async awaitSessionTeardowns(): Promise<
|
|
1184
|
+
private async awaitSessionTeardowns(timeoutMs: number): Promise<boolean> {
|
|
1185
|
+
const deadline = Date.now() + timeoutMs;
|
|
894
1186
|
while (this.sessionTeardowns.size > 0) {
|
|
895
|
-
|
|
1187
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1188
|
+
const completed = await Promise.race([
|
|
1189
|
+
Promise.allSettled([...this.sessionTeardowns]).then(() => true),
|
|
1190
|
+
new Promise<boolean>((resolve) => {
|
|
1191
|
+
timer = setTimeout(
|
|
1192
|
+
() => resolve(false),
|
|
1193
|
+
Math.max(0, deadline - Date.now()),
|
|
1194
|
+
);
|
|
1195
|
+
}),
|
|
1196
|
+
]);
|
|
1197
|
+
if (timer) clearTimeout(timer);
|
|
1198
|
+
if (!completed) return false;
|
|
896
1199
|
}
|
|
1200
|
+
return true;
|
|
897
1201
|
}
|
|
898
1202
|
|
|
899
|
-
private async awaitSessionTeardown(
|
|
1203
|
+
private async awaitSessionTeardown(
|
|
1204
|
+
session: AgentSession | undefined,
|
|
1205
|
+
): Promise<void> {
|
|
900
1206
|
if (!session) return;
|
|
901
1207
|
try {
|
|
902
1208
|
await this.trackSessionTeardown(session);
|
|
@@ -926,7 +1232,8 @@ export class AgentManager {
|
|
|
926
1232
|
* without walking mutable parent links.
|
|
927
1233
|
*/
|
|
928
1234
|
private nestedOwnerValidation(parentId: unknown): string | undefined {
|
|
929
|
-
if (typeof parentId !== "string" || parentId.length === 0)
|
|
1235
|
+
if (typeof parentId !== "string" || parentId.length === 0)
|
|
1236
|
+
return "the parent id is missing";
|
|
930
1237
|
|
|
931
1238
|
const parent = this.agents.get(parentId);
|
|
932
1239
|
if (!parent) return `parent agent "${parentId}" is missing`;
|
|
@@ -934,15 +1241,19 @@ export class AgentManager {
|
|
|
934
1241
|
const ownerIds = [parent.id, ...(parent.ancestorAgentIds ?? [])];
|
|
935
1242
|
const seen = new Set<string>();
|
|
936
1243
|
for (const ownerId of ownerIds) {
|
|
937
|
-
if (seen.has(ownerId))
|
|
1244
|
+
if (seen.has(ownerId))
|
|
1245
|
+
return `the parent chain for "${ownerId}" is cyclic`;
|
|
938
1246
|
seen.add(ownerId);
|
|
939
1247
|
|
|
940
1248
|
const record = this.agents.get(ownerId);
|
|
941
1249
|
if (!record) return `parent agent "${ownerId}" is missing`;
|
|
942
|
-
if (this.removingRecords.has(ownerId))
|
|
943
|
-
|
|
1250
|
+
if (this.removingRecords.has(ownerId))
|
|
1251
|
+
return `parent agent "${ownerId}" is being removed`;
|
|
1252
|
+
if (this.deferredRecordRemovals.has(ownerId))
|
|
1253
|
+
return `parent agent "${ownerId}" is pending removal`;
|
|
944
1254
|
if (record.detached) return `parent agent "${ownerId}" is detached`;
|
|
945
|
-
if (this.nestedSpawnSeals.has(ownerId))
|
|
1255
|
+
if (this.nestedSpawnSeals.has(ownerId))
|
|
1256
|
+
return `parent agent "${ownerId}" is sealed`;
|
|
946
1257
|
if (record.status !== "queued" && record.status !== "running") {
|
|
947
1258
|
return `parent agent "${ownerId}" is terminal`;
|
|
948
1259
|
}
|
|
@@ -964,10 +1275,14 @@ export class AgentManager {
|
|
|
964
1275
|
|
|
965
1276
|
/** Validate a nested record's ancestors before a resume can restart it. */
|
|
966
1277
|
private canResumeNested(record: AgentRecord): boolean {
|
|
967
|
-
return
|
|
1278
|
+
return (
|
|
1279
|
+
!record.detached &&
|
|
1280
|
+
!this.removingRecords.has(record.id) &&
|
|
968
1281
|
!this.settlingRecords.has(record.id) &&
|
|
969
1282
|
!this.deferredRecordRemovals.has(record.id) &&
|
|
970
|
-
(record.parentAgentId === undefined ||
|
|
1283
|
+
(record.parentAgentId === undefined ||
|
|
1284
|
+
this.nestedOwnerValidation(record.parentAgentId) === undefined)
|
|
1285
|
+
);
|
|
971
1286
|
}
|
|
972
1287
|
|
|
973
1288
|
private clearManagedPersistenceRetry(key: string): void {
|
|
@@ -978,16 +1293,23 @@ export class AgentManager {
|
|
|
978
1293
|
}
|
|
979
1294
|
|
|
980
1295
|
private clearManagedPersistenceRetries(): void {
|
|
981
|
-
for (const key of this.managedPersistenceRetries.keys())
|
|
1296
|
+
for (const key of this.managedPersistenceRetries.keys())
|
|
1297
|
+
this.clearManagedPersistenceRetry(key);
|
|
982
1298
|
}
|
|
983
1299
|
|
|
984
|
-
private scheduleManagedPersistenceRetry(
|
|
1300
|
+
private scheduleManagedPersistenceRetry(
|
|
1301
|
+
tombstone: ManagedSpawnTombstone,
|
|
1302
|
+
): void {
|
|
985
1303
|
if (this.disposed) return;
|
|
986
1304
|
const persistence = this.managedPersistence;
|
|
987
1305
|
if (!persistence) return;
|
|
988
1306
|
const key = tombstone.spawnKey;
|
|
989
1307
|
this.clearManagedPersistenceRetry(key);
|
|
990
|
-
const pending = {
|
|
1308
|
+
const pending = {
|
|
1309
|
+
tombstone,
|
|
1310
|
+
attempt: 0,
|
|
1311
|
+
timer: undefined as ReturnType<typeof setTimeout> | undefined,
|
|
1312
|
+
};
|
|
991
1313
|
this.managedPersistenceRetries.set(key, pending);
|
|
992
1314
|
const attempt = (): void => {
|
|
993
1315
|
if (this.managedPersistenceRetries.get(key) !== pending) return;
|
|
@@ -1003,12 +1325,15 @@ export class AgentManager {
|
|
|
1003
1325
|
pending.attempt += 1;
|
|
1004
1326
|
if (pending.attempt >= MANAGED_PERSIST_RETRY_MAX_ATTEMPTS) {
|
|
1005
1327
|
this.managedPersistenceRetries.delete(key);
|
|
1006
|
-
console.warn(
|
|
1328
|
+
console.warn(
|
|
1329
|
+
`[pi-subagents] managed tombstone persistence retry exhausted for ${key}: ${error instanceof Error ? error.message : String(error)}`,
|
|
1330
|
+
);
|
|
1007
1331
|
return;
|
|
1008
1332
|
}
|
|
1009
1333
|
const delay = Math.min(
|
|
1010
1334
|
MANAGED_PERSIST_RETRY_MAX_DELAY_MS,
|
|
1011
|
-
MANAGED_PERSIST_RETRY_INITIAL_DELAY_MS *
|
|
1335
|
+
MANAGED_PERSIST_RETRY_INITIAL_DELAY_MS *
|
|
1336
|
+
2 ** Math.min(pending.attempt - 1, 6),
|
|
1012
1337
|
);
|
|
1013
1338
|
pending.timer = setTimeout(attempt, delay);
|
|
1014
1339
|
pending.timer.unref?.();
|
|
@@ -1017,7 +1342,10 @@ export class AgentManager {
|
|
|
1017
1342
|
queueMicrotask(attempt);
|
|
1018
1343
|
}
|
|
1019
1344
|
|
|
1020
|
-
private persistManaged(
|
|
1345
|
+
private persistManaged(
|
|
1346
|
+
tombstone: ManagedSpawnTombstone,
|
|
1347
|
+
required = false,
|
|
1348
|
+
): void {
|
|
1021
1349
|
if (!this.managedPersistence) {
|
|
1022
1350
|
if (required) throw new Error("managed spawn persistence is unavailable");
|
|
1023
1351
|
return;
|
|
@@ -1044,7 +1372,11 @@ export class AgentManager {
|
|
|
1044
1372
|
}
|
|
1045
1373
|
}
|
|
1046
1374
|
|
|
1047
|
-
private replaceManagedTombstone(
|
|
1375
|
+
private replaceManagedTombstone(
|
|
1376
|
+
key: string,
|
|
1377
|
+
next: ManagedSpawnTombstone,
|
|
1378
|
+
required = false,
|
|
1379
|
+
): void {
|
|
1048
1380
|
const previous = this.managedSpawns.get(key);
|
|
1049
1381
|
if (previous && JSON.stringify(previous) === JSON.stringify(next)) return;
|
|
1050
1382
|
this.clearManagedPersistenceRetry(key);
|
|
@@ -1067,36 +1399,56 @@ export class AgentManager {
|
|
|
1067
1399
|
const tombstone = this.managedSpawns.get(key);
|
|
1068
1400
|
if (!tombstone) return;
|
|
1069
1401
|
const terminalStatus = terminalStatusForRecord(record.status);
|
|
1070
|
-
const state: ManagedSpawnState =
|
|
1402
|
+
const state: ManagedSpawnState =
|
|
1403
|
+
terminalStatus ?? (record.status === "queued" ? "queued" : "running");
|
|
1071
1404
|
const compactionCount = Math.max(0, Math.floor(record.compactionCount));
|
|
1072
1405
|
const terminal = terminalStatus
|
|
1073
1406
|
? {
|
|
1074
1407
|
status: terminalStatus,
|
|
1075
|
-
...(capManagedText(record.result, MANAGED_TEXT_LIMIT)
|
|
1076
|
-
|
|
1077
|
-
|
|
1408
|
+
...(capManagedText(record.result, MANAGED_TEXT_LIMIT)
|
|
1409
|
+
? { result: capManagedText(record.result, MANAGED_TEXT_LIMIT) }
|
|
1410
|
+
: {}),
|
|
1411
|
+
...(capManagedText(record.error, MANAGED_ERROR_LIMIT)
|
|
1412
|
+
? { error: capManagedText(record.error, MANAGED_ERROR_LIMIT) }
|
|
1413
|
+
: {}),
|
|
1414
|
+
...(record.outputFile
|
|
1415
|
+
? { outputFile: record.outputFile.slice(0, MANAGED_PATH_LIMIT) }
|
|
1416
|
+
: {}),
|
|
1078
1417
|
...(record.lifetimeUsage.input + record.lifetimeUsage.output > 0
|
|
1079
|
-
? {
|
|
1418
|
+
? {
|
|
1419
|
+
tokenCount: Math.floor(
|
|
1420
|
+
record.lifetimeUsage.input + record.lifetimeUsage.output,
|
|
1421
|
+
),
|
|
1422
|
+
}
|
|
1080
1423
|
: {}),
|
|
1081
1424
|
compactionCount,
|
|
1082
1425
|
completedAt: record.completedAt ?? Date.now(),
|
|
1083
1426
|
}
|
|
1084
1427
|
: undefined;
|
|
1085
|
-
this.replaceManagedTombstone(
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1428
|
+
this.replaceManagedTombstone(
|
|
1429
|
+
key,
|
|
1430
|
+
{
|
|
1431
|
+
...tombstone,
|
|
1432
|
+
...(record.invocation?.agentTierSnapshot
|
|
1433
|
+
? { tierSnapshot: { ...record.invocation.agentTierSnapshot } }
|
|
1434
|
+
: {}),
|
|
1435
|
+
...(record.invocation?.agentTier !== undefined
|
|
1436
|
+
? { tier: record.invocation.agentTier }
|
|
1437
|
+
: {}),
|
|
1438
|
+
state,
|
|
1439
|
+
updatedAt: Date.now(),
|
|
1440
|
+
compactionCount,
|
|
1441
|
+
...(terminal ? { terminal } : { terminal: undefined }),
|
|
1442
|
+
},
|
|
1443
|
+
required,
|
|
1444
|
+
);
|
|
1094
1445
|
}
|
|
1095
1446
|
|
|
1096
1447
|
private settleMissingManaged(key: string): ManagedSpawnTombstone {
|
|
1097
1448
|
const tombstone = this.managedSpawns.get(key);
|
|
1098
1449
|
if (!tombstone) throw new Error(`managed spawn key not found: "${key}"`);
|
|
1099
|
-
if (isManagedTerminalState(tombstone.state) && tombstone.terminal)
|
|
1450
|
+
if (isManagedTerminalState(tombstone.state) && tombstone.terminal)
|
|
1451
|
+
return tombstone;
|
|
1100
1452
|
const now = Date.now();
|
|
1101
1453
|
const settled: ManagedSpawnTombstone = {
|
|
1102
1454
|
...tombstone,
|
|
@@ -1104,7 +1456,8 @@ export class AgentManager {
|
|
|
1104
1456
|
updatedAt: now,
|
|
1105
1457
|
terminal: {
|
|
1106
1458
|
status: "interrupted",
|
|
1107
|
-
error:
|
|
1459
|
+
error:
|
|
1460
|
+
"managed agent interrupted: no live AgentSession after session reload",
|
|
1108
1461
|
compactionCount: tombstone.compactionCount,
|
|
1109
1462
|
completedAt: now,
|
|
1110
1463
|
},
|
|
@@ -1130,14 +1483,19 @@ export class AgentManager {
|
|
|
1130
1483
|
...(tombstone.tier === undefined ? {} : { tier: tombstone.tier }),
|
|
1131
1484
|
state: tombstone.state,
|
|
1132
1485
|
created,
|
|
1133
|
-
...(tombstone.terminal
|
|
1486
|
+
...(tombstone.terminal
|
|
1487
|
+
? { terminal: cloneManagedTerminal(tombstone.terminal) }
|
|
1488
|
+
: {}),
|
|
1134
1489
|
};
|
|
1135
1490
|
}
|
|
1136
1491
|
|
|
1137
1492
|
private retireLiveManagedKeys(): void {
|
|
1138
1493
|
for (const [key, tombstone] of this.managedSpawns) {
|
|
1139
1494
|
const record = this.agents.get(tombstone.id);
|
|
1140
|
-
if (
|
|
1495
|
+
if (
|
|
1496
|
+
!isManagedTerminalState(tombstone.state) ||
|
|
1497
|
+
(record !== undefined && !this.isFullyCleaned(record))
|
|
1498
|
+
) {
|
|
1141
1499
|
// Re-inserting moves an existing key to the end of iteration order, so
|
|
1142
1500
|
// the eviction below always drops the least recently retired key.
|
|
1143
1501
|
this.managedRetiredKeys.delete(key);
|
|
@@ -1166,7 +1524,11 @@ export class AgentManager {
|
|
|
1166
1524
|
this.managedThreadPolicies.clear();
|
|
1167
1525
|
this.managedThreadReservations.clear();
|
|
1168
1526
|
for (const entry of entries) {
|
|
1169
|
-
if (
|
|
1527
|
+
if (
|
|
1528
|
+
entry.type !== "custom" ||
|
|
1529
|
+
entry.customType !== MANAGED_SPAWN_ENTRY_TYPE
|
|
1530
|
+
)
|
|
1531
|
+
continue;
|
|
1170
1532
|
const tombstone = parseManagedTombstone(entry.data);
|
|
1171
1533
|
if (!tombstone) {
|
|
1172
1534
|
const key = managedSpawnKeyForQuarantine(entry.data);
|
|
@@ -1185,18 +1547,28 @@ export class AgentManager {
|
|
|
1185
1547
|
// fact for the same key. Otherwise restore order could resurrect a
|
|
1186
1548
|
// spawn that the clean-break quarantine is meant to reject.
|
|
1187
1549
|
this.managedSpawns.delete(key);
|
|
1188
|
-
console.warn(
|
|
1550
|
+
console.warn(
|
|
1551
|
+
`[pi-subagents] quarantined unsupported managed spawn tombstone for ${key}`,
|
|
1552
|
+
);
|
|
1189
1553
|
}
|
|
1190
1554
|
continue;
|
|
1191
1555
|
}
|
|
1192
|
-
if (
|
|
1193
|
-
|
|
1556
|
+
if (
|
|
1557
|
+
this.managedQuarantinedKeys.has(tombstone.spawnKey) ||
|
|
1558
|
+
this.managedRetiredKeys.has(tombstone.spawnKey)
|
|
1559
|
+
)
|
|
1560
|
+
continue;
|
|
1561
|
+
if (options.dropActive && !isManagedTerminalState(tombstone.state))
|
|
1562
|
+
continue;
|
|
1194
1563
|
this.managedSpawns.set(tombstone.spawnKey, tombstone);
|
|
1195
1564
|
this.managedKeysById.set(tombstone.id, tombstone.spawnKey);
|
|
1196
1565
|
if (tombstone.thread && tombstone.threadPolicyFingerprint) {
|
|
1197
1566
|
const threadKey = `${tombstone.owner.runId}\u0000${tombstone.thread}`;
|
|
1198
1567
|
this.managedThreads.set(threadKey, tombstone.id);
|
|
1199
|
-
this.managedThreadPolicies.set(
|
|
1568
|
+
this.managedThreadPolicies.set(
|
|
1569
|
+
threadKey,
|
|
1570
|
+
tombstone.threadPolicyFingerprint,
|
|
1571
|
+
);
|
|
1200
1572
|
}
|
|
1201
1573
|
}
|
|
1202
1574
|
const recovered: ManagedSpawnTombstone[] = [];
|
|
@@ -1218,7 +1590,10 @@ export class AgentManager {
|
|
|
1218
1590
|
}
|
|
1219
1591
|
|
|
1220
1592
|
/** Reconcile a spawn whose RPC reply was lost after allocation. */
|
|
1221
|
-
reconcileManaged(
|
|
1593
|
+
reconcileManaged(
|
|
1594
|
+
spawnKey: string,
|
|
1595
|
+
owner: AgentOwner,
|
|
1596
|
+
): ManagedSpawnResult | undefined {
|
|
1222
1597
|
const key = spawnKey.trim();
|
|
1223
1598
|
const tombstone = this.managedSpawns.get(key);
|
|
1224
1599
|
if (!tombstone) return undefined;
|
|
@@ -1227,9 +1602,11 @@ export class AgentManager {
|
|
|
1227
1602
|
tombstone.owner.runId !== owner.runId ||
|
|
1228
1603
|
tombstone.owner.nodeId !== owner.nodeId ||
|
|
1229
1604
|
tombstone.owner.attemptId !== owner.attemptId
|
|
1230
|
-
)
|
|
1605
|
+
)
|
|
1606
|
+
return undefined;
|
|
1231
1607
|
const record = this.agents.get(tombstone.id);
|
|
1232
|
-
if (record && (record.status === "queued" || record.status === "running"))
|
|
1608
|
+
if (record && (record.status === "queued" || record.status === "running"))
|
|
1609
|
+
this.abortOwned(record.id, owner);
|
|
1233
1610
|
return this.managedResult(key);
|
|
1234
1611
|
}
|
|
1235
1612
|
|
|
@@ -1262,21 +1639,37 @@ export class AgentManager {
|
|
|
1262
1639
|
if (this.disposed) throw new Error("AgentManager is disposed");
|
|
1263
1640
|
|
|
1264
1641
|
if (typeof type !== "string" || !type.trim() || type.length > 256) {
|
|
1265
|
-
throw new Error(
|
|
1642
|
+
throw new Error(
|
|
1643
|
+
"Agent type must be a non-empty string of at most 256 characters",
|
|
1644
|
+
);
|
|
1266
1645
|
}
|
|
1267
1646
|
if (typeof prompt !== "string" || prompt.length > 100_000) {
|
|
1268
|
-
throw new Error(
|
|
1647
|
+
throw new Error(
|
|
1648
|
+
"Agent prompt must be a string of at most 100000 characters",
|
|
1649
|
+
);
|
|
1269
1650
|
}
|
|
1270
1651
|
if (typeof options !== "object" || options === null) {
|
|
1271
1652
|
throw new Error("Spawn options must be an object");
|
|
1272
1653
|
}
|
|
1273
|
-
if (
|
|
1274
|
-
|
|
1275
|
-
|
|
1654
|
+
if (
|
|
1655
|
+
options.description !== undefined &&
|
|
1656
|
+
(typeof options.description !== "string" ||
|
|
1657
|
+
options.description.length > 1_000)
|
|
1658
|
+
) {
|
|
1659
|
+
throw new Error(
|
|
1660
|
+
"Agent description must be a string of at most 1000 characters",
|
|
1661
|
+
);
|
|
1276
1662
|
}
|
|
1277
1663
|
// Legacy in-process callers omitted description before it became required.
|
|
1278
1664
|
// Normalize that shape to an inert string while rejecting reference values.
|
|
1279
1665
|
options = { ...options, description: options.description ?? type };
|
|
1666
|
+
// Covers top-level, nested, managed, and queued spawns before allocating a
|
|
1667
|
+
// record, consuming a branch budget, or creating an isolation worktree.
|
|
1668
|
+
assertNoRawInheritance(
|
|
1669
|
+
internalOverride?.inheritContext ??
|
|
1670
|
+
options.inheritContext ??
|
|
1671
|
+
getAgentConfig(type)?.inheritContext,
|
|
1672
|
+
);
|
|
1280
1673
|
|
|
1281
1674
|
for (const [label, value] of [
|
|
1282
1675
|
["isBackground", options.isBackground],
|
|
@@ -1297,22 +1690,36 @@ export class AgentManager {
|
|
|
1297
1690
|
throw new Error(`${label} must be a non-negative safe integer`);
|
|
1298
1691
|
}
|
|
1299
1692
|
}
|
|
1300
|
-
if (
|
|
1693
|
+
if (
|
|
1694
|
+
options.parentAgentId !== undefined &&
|
|
1695
|
+
typeof options.parentAgentId !== "string"
|
|
1696
|
+
) {
|
|
1301
1697
|
throw new Error("parentAgentId must be a string");
|
|
1302
1698
|
}
|
|
1303
|
-
if (
|
|
1699
|
+
if (
|
|
1700
|
+
options.rootSessionId !== undefined &&
|
|
1701
|
+
typeof options.rootSessionId !== "string"
|
|
1702
|
+
) {
|
|
1304
1703
|
throw new Error("rootSessionId must be a string");
|
|
1305
1704
|
}
|
|
1705
|
+
// Admission must be synchronous and shape-only: a child can select another
|
|
1706
|
+
// model after this point, but no unsupported host may receive an ID, branch
|
|
1707
|
+
// budget, queue slot, lifecycle event, or transcript before the guard fails.
|
|
1708
|
+
assertParentModelRuntimeAvailable(ctx);
|
|
1306
1709
|
// Validate the owner capability before cwd checks, ID allocation, lifecycle
|
|
1307
1710
|
// callbacks, queueing, or worktree creation. A missing/terminal/sealed owner
|
|
1308
1711
|
// must fail closed without leaving any observable allocation behind.
|
|
1309
|
-
const nestedParent =
|
|
1310
|
-
|
|
1311
|
-
|
|
1712
|
+
const nestedParent =
|
|
1713
|
+
options.parentAgentId === undefined
|
|
1714
|
+
? undefined
|
|
1715
|
+
: this.assertNestedOwner(options.parentAgentId);
|
|
1312
1716
|
// Never trust lineage supplied through public SpawnOptions. It is copied only
|
|
1313
1717
|
// from the validated live immediate parent, before this record exists.
|
|
1314
1718
|
const ancestorAgentIds = nestedParent
|
|
1315
|
-
? Object.freeze([
|
|
1719
|
+
? Object.freeze([
|
|
1720
|
+
...(nestedParent.ancestorAgentIds ?? []),
|
|
1721
|
+
nestedParent.id,
|
|
1722
|
+
])
|
|
1316
1723
|
: undefined;
|
|
1317
1724
|
|
|
1318
1725
|
// Cumulative spawn budget for the branch. The depth cap bounds how DEEP
|
|
@@ -1362,7 +1769,9 @@ export class AgentManager {
|
|
|
1362
1769
|
// only filter excludes only explicit `false`, so undefined agents — which
|
|
1363
1770
|
// have no inline surface — stay visible instead of vanishing.
|
|
1364
1771
|
isBackground: options.isBackground,
|
|
1365
|
-
invocation: options.invocation
|
|
1772
|
+
invocation: options.invocation
|
|
1773
|
+
? cloneFrozenData(options.invocation)
|
|
1774
|
+
: undefined,
|
|
1366
1775
|
depth: options.depth ?? 1,
|
|
1367
1776
|
parentAgentId: options.parentAgentId,
|
|
1368
1777
|
...(ancestorAgentIds ? { ancestorAgentIds } : {}),
|
|
@@ -1373,22 +1782,44 @@ export class AgentManager {
|
|
|
1373
1782
|
// Top-level agents get a mention handle derived from their type. The name
|
|
1374
1783
|
// space covers live records and resumable entries, so a handle is never
|
|
1375
1784
|
// allocated twice while its conversation is still reachable.
|
|
1376
|
-
if (
|
|
1785
|
+
if (
|
|
1786
|
+
options.parentAgentId === undefined &&
|
|
1787
|
+
options.reclaimHandle === undefined
|
|
1788
|
+
) {
|
|
1377
1789
|
record.handle = assignHandle(handleBase(type), this.takenHandles());
|
|
1378
1790
|
} else if (options.reclaimHandle !== undefined) {
|
|
1379
1791
|
record.handle = options.reclaimHandle;
|
|
1380
1792
|
}
|
|
1381
1793
|
this.agents.set(id, record);
|
|
1382
1794
|
if (!record.detached) {
|
|
1383
|
-
try {
|
|
1384
|
-
|
|
1795
|
+
try {
|
|
1796
|
+
this.onCreated?.(record);
|
|
1797
|
+
} catch {
|
|
1798
|
+
/* observer failures cannot orphan a record */
|
|
1799
|
+
}
|
|
1800
|
+
try {
|
|
1801
|
+
options.onSpawned?.(id);
|
|
1802
|
+
} catch {
|
|
1803
|
+
/* observer failures cannot orphan a record */
|
|
1804
|
+
}
|
|
1385
1805
|
}
|
|
1386
1806
|
|
|
1387
|
-
const args: SpawnArgs = {
|
|
1807
|
+
const args: SpawnArgs = {
|
|
1808
|
+
pi,
|
|
1809
|
+
ctx,
|
|
1810
|
+
type,
|
|
1811
|
+
prompt,
|
|
1812
|
+
options,
|
|
1813
|
+
internalOverride,
|
|
1814
|
+
};
|
|
1388
1815
|
// Lifecycle observers may synchronously stop or dispose a freshly allocated
|
|
1389
1816
|
// record. Do not let the normal queue/start path resurrect that decision.
|
|
1390
|
-
if (
|
|
1391
|
-
|
|
1817
|
+
if (
|
|
1818
|
+
this.disposed ||
|
|
1819
|
+
this.agents.get(id) !== record ||
|
|
1820
|
+
record.detached ||
|
|
1821
|
+
record.status !== (options.isBackground ? "queued" : "running")
|
|
1822
|
+
) {
|
|
1392
1823
|
return id;
|
|
1393
1824
|
}
|
|
1394
1825
|
|
|
@@ -1397,7 +1828,8 @@ export class AgentManager {
|
|
|
1397
1828
|
// it only in startAgent loses both cases.
|
|
1398
1829
|
if (options.signal) {
|
|
1399
1830
|
const onParentAbort = () => this.abort(id);
|
|
1400
|
-
const cleanup = () =>
|
|
1831
|
+
const cleanup = () =>
|
|
1832
|
+
options.signal!.removeEventListener("abort", onParentAbort);
|
|
1401
1833
|
this.parentSignalCleanups.set(id, cleanup);
|
|
1402
1834
|
if (options.signal.aborted) {
|
|
1403
1835
|
this.clearParentSignal(id);
|
|
@@ -1418,7 +1850,11 @@ export class AgentManager {
|
|
|
1418
1850
|
}
|
|
1419
1851
|
}
|
|
1420
1852
|
|
|
1421
|
-
if (
|
|
1853
|
+
if (
|
|
1854
|
+
occupiesPoolSlot(record) &&
|
|
1855
|
+
!options.bypassQueue &&
|
|
1856
|
+
this.runningBackground >= this.maxConcurrent
|
|
1857
|
+
) {
|
|
1422
1858
|
// Queue it — will be started when a running agent completes
|
|
1423
1859
|
this.queue.push({ kind: "spawn", id, args });
|
|
1424
1860
|
return id;
|
|
@@ -1461,7 +1897,16 @@ export class AgentManager {
|
|
|
1461
1897
|
ctx: ExtensionContext,
|
|
1462
1898
|
request: ManagedSpawnRequest,
|
|
1463
1899
|
policy: ManagedSpawnPolicy,
|
|
1464
|
-
callbacks?: Pick<
|
|
1900
|
+
callbacks?: Pick<
|
|
1901
|
+
SpawnOptions,
|
|
1902
|
+
| "onToolActivity"
|
|
1903
|
+
| "onTextDelta"
|
|
1904
|
+
| "onSessionCreated"
|
|
1905
|
+
| "onTurnEnd"
|
|
1906
|
+
| "onAssistantUsage"
|
|
1907
|
+
| "onCompaction"
|
|
1908
|
+
| "onSpawned"
|
|
1909
|
+
>,
|
|
1465
1910
|
): ManagedSpawnResult {
|
|
1466
1911
|
if (this.disposed) throw new Error("AgentManager is disposed");
|
|
1467
1912
|
const normalized = parseManagedSpawnRequest(request);
|
|
@@ -1474,12 +1919,22 @@ export class AgentManager {
|
|
|
1474
1919
|
// non-null assertion on a field TypeScript cannot re-narrow across the
|
|
1475
1920
|
// intervening lookups.
|
|
1476
1921
|
const thread = normalized.thread;
|
|
1477
|
-
if (
|
|
1478
|
-
|
|
1922
|
+
if (
|
|
1923
|
+
thread &&
|
|
1924
|
+
(normalized.isolation === "worktree" || policy.isolation === "worktree")
|
|
1925
|
+
) {
|
|
1926
|
+
throw new Error(
|
|
1927
|
+
"Managed workflow threads cannot use worktree isolation; use separate calls instead.",
|
|
1928
|
+
);
|
|
1479
1929
|
}
|
|
1480
1930
|
const scope = normalized.spawnKey;
|
|
1481
|
-
if (
|
|
1482
|
-
|
|
1931
|
+
if (
|
|
1932
|
+
this.managedQuarantinedKeys.has(scope) ||
|
|
1933
|
+
this.managedRetiredKeys.has(scope)
|
|
1934
|
+
) {
|
|
1935
|
+
throw new Error(
|
|
1936
|
+
`Managed spawn key is quarantined and cannot be reused: "${scope}"`,
|
|
1937
|
+
);
|
|
1483
1938
|
}
|
|
1484
1939
|
const parentThinking = currentParentThinkingLevel(pi);
|
|
1485
1940
|
const parentPolicySnapshot: ParentPolicySnapshot = Object.freeze({
|
|
@@ -1499,23 +1954,71 @@ export class AgentManager {
|
|
|
1499
1954
|
if (previous.fingerprint !== fingerprint) {
|
|
1500
1955
|
throw new Error(`Managed spawn key conflict: "${normalized.spawnKey}"`);
|
|
1501
1956
|
}
|
|
1502
|
-
if (
|
|
1957
|
+
if (
|
|
1958
|
+
thread &&
|
|
1959
|
+
previous.threadPolicyFingerprint !== threadPolicyFingerprint
|
|
1960
|
+
) {
|
|
1503
1961
|
throw new Error(threadPolicyConflictMessage(thread, identityTier));
|
|
1504
1962
|
}
|
|
1505
1963
|
return this.managedResult(scope);
|
|
1506
1964
|
}
|
|
1965
|
+
// Existing idempotency keys remain readable even if the host subsequently
|
|
1966
|
+
// loses its runtime. A new key (including a resumed thread) must fail before
|
|
1967
|
+
// any tombstone, reservation, or persistent identity is allocated.
|
|
1968
|
+
assertParentModelRuntimeAvailable(ctx);
|
|
1969
|
+
const agentConfig = getAgentConfig(normalized.type);
|
|
1970
|
+
assertNoRawInheritance(
|
|
1971
|
+
policy.inheritContext ?? agentConfig?.inheritContext,
|
|
1972
|
+
);
|
|
1973
|
+
// A managed key is journaled before the runner starts. Resolve its tier now
|
|
1974
|
+
// so an unavailable selected model cannot poison the key or reserve a thread.
|
|
1975
|
+
// Checking only ctx.model would reject a valid explicit alternate tier.
|
|
1976
|
+
const selected = resolveAgentTier({
|
|
1977
|
+
requestedTier: normalized.tier,
|
|
1978
|
+
requireTier: true,
|
|
1979
|
+
agentConfig,
|
|
1980
|
+
parentModel: parentPolicySnapshot.model,
|
|
1981
|
+
parentThinking: parentPolicySnapshot.thinking,
|
|
1982
|
+
modelRegistry: ctx.modelRegistry,
|
|
1983
|
+
});
|
|
1984
|
+
if (!selected.snapshot) {
|
|
1985
|
+
throw new Error(
|
|
1986
|
+
`No agent tier selected for "${normalized.type}". A managed workflow call must name a tier, ` +
|
|
1987
|
+
"the agent must declare one, or agentTiers must offer a default; " +
|
|
1988
|
+
"inheriting the parent session's model is not a policy this call can fall back to.",
|
|
1989
|
+
);
|
|
1990
|
+
}
|
|
1991
|
+
parentModelSessionOptions(ctx, selected.model);
|
|
1507
1992
|
|
|
1508
|
-
const threadKey = thread
|
|
1993
|
+
const threadKey = thread
|
|
1994
|
+
? `${normalized.owner.runId}\u0000${thread}`
|
|
1995
|
+
: undefined;
|
|
1509
1996
|
if (threadKey && this.managedThreadReservations.has(threadKey)) {
|
|
1510
|
-
throw new Error(
|
|
1997
|
+
throw new Error(
|
|
1998
|
+
`Managed workflow thread "${thread}" is already running; calls must be sequential.`,
|
|
1999
|
+
);
|
|
1511
2000
|
}
|
|
1512
|
-
const threadedId = threadKey
|
|
2001
|
+
const threadedId = threadKey
|
|
2002
|
+
? this.managedThreads.get(threadKey)
|
|
2003
|
+
: undefined;
|
|
1513
2004
|
if (threadedId && threadKey && thread) {
|
|
1514
|
-
if (
|
|
2005
|
+
if (
|
|
2006
|
+
threadPolicyFingerprint !== undefined &&
|
|
2007
|
+
this.managedThreadPolicies.get(threadKey) !== threadPolicyFingerprint
|
|
2008
|
+
) {
|
|
1515
2009
|
throw new Error(threadPolicyConflictMessage(thread, identityTier));
|
|
1516
2010
|
}
|
|
1517
2011
|
const record = this.agents.get(threadedId);
|
|
1518
|
-
if (
|
|
2012
|
+
if (
|
|
2013
|
+
record?.session &&
|
|
2014
|
+
record.status !== "running" &&
|
|
2015
|
+
record.status !== "queued" &&
|
|
2016
|
+
!record.detached
|
|
2017
|
+
) {
|
|
2018
|
+
assertSafeChildSession(
|
|
2019
|
+
record.session,
|
|
2020
|
+
record.invocation?.inheritContext === true,
|
|
2021
|
+
);
|
|
1519
2022
|
const previousOwner = record.owner;
|
|
1520
2023
|
const previousInvocation = record.invocation;
|
|
1521
2024
|
const previousManagedKey = this.managedKeysById.get(threadedId);
|
|
@@ -1524,7 +2027,9 @@ export class AgentManager {
|
|
|
1524
2027
|
record.invocation = {
|
|
1525
2028
|
...(policy.invocation ?? {}),
|
|
1526
2029
|
...(resumedTier === undefined ? {} : { agentTier: resumedTier }),
|
|
1527
|
-
...(previousInvocation?.agentTierSnapshot
|
|
2030
|
+
...(previousInvocation?.agentTierSnapshot
|
|
2031
|
+
? { agentTierSnapshot: previousInvocation.agentTierSnapshot }
|
|
2032
|
+
: {}),
|
|
1528
2033
|
};
|
|
1529
2034
|
const now = Date.now();
|
|
1530
2035
|
const tombstone: ManagedSpawnTombstone = {
|
|
@@ -1542,7 +2047,9 @@ export class AgentManager {
|
|
|
1542
2047
|
createdAt: now,
|
|
1543
2048
|
updatedAt: now,
|
|
1544
2049
|
compactionCount: record.compactionCount,
|
|
1545
|
-
...(record.invocation?.agentTierSnapshot
|
|
2050
|
+
...(record.invocation?.agentTierSnapshot
|
|
2051
|
+
? { tierSnapshot: { ...record.invocation.agentTierSnapshot } }
|
|
2052
|
+
: {}),
|
|
1546
2053
|
};
|
|
1547
2054
|
this.clearManagedPersistenceRetry(scope);
|
|
1548
2055
|
this.managedSpawns.set(scope, tombstone);
|
|
@@ -1551,7 +2058,8 @@ export class AgentManager {
|
|
|
1551
2058
|
this.persistManaged(tombstone, true);
|
|
1552
2059
|
} catch (error: unknown) {
|
|
1553
2060
|
this.managedSpawns.delete(scope);
|
|
1554
|
-
if (previousManagedKey === undefined)
|
|
2061
|
+
if (previousManagedKey === undefined)
|
|
2062
|
+
this.managedKeysById.delete(threadedId);
|
|
1555
2063
|
else this.managedKeysById.set(threadedId, previousManagedKey);
|
|
1556
2064
|
record.owner = previousOwner;
|
|
1557
2065
|
record.invocation = previousInvocation;
|
|
@@ -1561,7 +2069,9 @@ export class AgentManager {
|
|
|
1561
2069
|
isBackground: true,
|
|
1562
2070
|
onToolActivity: callbacks?.onToolActivity,
|
|
1563
2071
|
onAssistantUsage: callbacks?.onAssistantUsage,
|
|
1564
|
-
onCompaction: callbacks?.onCompaction
|
|
2072
|
+
onCompaction: callbacks?.onCompaction
|
|
2073
|
+
? (info) => callbacks.onCompaction?.(info as CompactionInfo)
|
|
2074
|
+
: undefined,
|
|
1565
2075
|
}).catch(() => {});
|
|
1566
2076
|
this.syncManagedRecord(record, true);
|
|
1567
2077
|
const resumedState = String(record.status);
|
|
@@ -1572,8 +2082,14 @@ export class AgentManager {
|
|
|
1572
2082
|
created: true,
|
|
1573
2083
|
};
|
|
1574
2084
|
}
|
|
1575
|
-
if (
|
|
1576
|
-
|
|
2085
|
+
if (
|
|
2086
|
+
record &&
|
|
2087
|
+
!record.detached &&
|
|
2088
|
+
(record.status === "running" || record.status === "queued")
|
|
2089
|
+
) {
|
|
2090
|
+
throw new Error(
|
|
2091
|
+
`Managed workflow thread "${thread}" is already running; calls must be sequential.`,
|
|
2092
|
+
);
|
|
1577
2093
|
}
|
|
1578
2094
|
if (!record || record.detached || !record.session) {
|
|
1579
2095
|
this.managedThreads.delete(threadKey!);
|
|
@@ -1625,18 +2141,28 @@ export class AgentManager {
|
|
|
1625
2141
|
}
|
|
1626
2142
|
|
|
1627
2143
|
try {
|
|
1628
|
-
this.spawnInternal(
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
2144
|
+
this.spawnInternal(
|
|
2145
|
+
pi,
|
|
2146
|
+
ctx,
|
|
2147
|
+
normalized.type,
|
|
2148
|
+
normalized.prompt,
|
|
2149
|
+
{
|
|
2150
|
+
...policy,
|
|
2151
|
+
[INTERNAL_PARENT_POLICY_SNAPSHOT]: parentPolicySnapshot,
|
|
2152
|
+
// A managed workflow must resolve a tier or fail closed; it may not
|
|
2153
|
+
// silently inherit the parent session's model when nobody named one.
|
|
2154
|
+
requireAgentTier: true,
|
|
2155
|
+
...(normalized.tier === undefined
|
|
2156
|
+
? {}
|
|
2157
|
+
: { agentTier: normalized.tier }),
|
|
2158
|
+
...(thread === undefined ? {} : { thread }),
|
|
2159
|
+
description: normalized.description,
|
|
2160
|
+
isBackground: true,
|
|
2161
|
+
...callbacks,
|
|
2162
|
+
},
|
|
2163
|
+
normalized.owner,
|
|
2164
|
+
id,
|
|
2165
|
+
);
|
|
1640
2166
|
} catch (error: unknown) {
|
|
1641
2167
|
if (threadKey) {
|
|
1642
2168
|
this.managedThreads.delete(threadKey);
|
|
@@ -1644,7 +2170,8 @@ export class AgentManager {
|
|
|
1644
2170
|
this.managedThreadReservations.delete(threadKey);
|
|
1645
2171
|
}
|
|
1646
2172
|
const existing = this.managedSpawns.get(scope);
|
|
1647
|
-
if (existing?.state === "failed" && existing.terminal)
|
|
2173
|
+
if (existing?.state === "failed" && existing.terminal)
|
|
2174
|
+
return this.managedResult(scope, true);
|
|
1648
2175
|
const completedAt = Date.now();
|
|
1649
2176
|
this.replaceManagedTombstone(scope, {
|
|
1650
2177
|
...tombstone,
|
|
@@ -1652,7 +2179,10 @@ export class AgentManager {
|
|
|
1652
2179
|
updatedAt: completedAt,
|
|
1653
2180
|
terminal: {
|
|
1654
2181
|
status: "failed",
|
|
1655
|
-
error: capManagedText(
|
|
2182
|
+
error: capManagedText(
|
|
2183
|
+
error instanceof Error ? error.message : String(error),
|
|
2184
|
+
MANAGED_ERROR_LIMIT,
|
|
2185
|
+
),
|
|
1656
2186
|
compactionCount: 0,
|
|
1657
2187
|
completedAt,
|
|
1658
2188
|
},
|
|
@@ -1682,8 +2212,13 @@ export class AgentManager {
|
|
|
1682
2212
|
}
|
|
1683
2213
|
|
|
1684
2214
|
/** Actually start an agent (called immediately or from queue drain). */
|
|
1685
|
-
private startAgent(
|
|
1686
|
-
|
|
2215
|
+
private startAgent(
|
|
2216
|
+
id: string,
|
|
2217
|
+
record: AgentRecord,
|
|
2218
|
+
{ pi, ctx, type, prompt, options, internalOverride }: SpawnArgs,
|
|
2219
|
+
) {
|
|
2220
|
+
if (this.disposed || record.detached || this.agents.get(id) !== record)
|
|
2221
|
+
return;
|
|
1687
2222
|
// Re-validate a caller-supplied cwd: queued spawns can start minutes after
|
|
1688
2223
|
// spawn()'s check, and the directory may be gone by then (TOCTOU). Same
|
|
1689
2224
|
// curated errors; drainQueue parks a throw on the record as an error.
|
|
@@ -1703,13 +2238,15 @@ export class AgentManager {
|
|
|
1703
2238
|
// Explicit opt-out — no worktree, no check.
|
|
1704
2239
|
} else if (options.isolation === "worktree") {
|
|
1705
2240
|
if (!isWorktreeIsolationEnabled()) {
|
|
1706
|
-
throw new Error(
|
|
2241
|
+
throw new Error(
|
|
2242
|
+
'Cannot run with isolation: "worktree" — worktree isolation is disabled in project settings. Enable it or omit `isolation`.',
|
|
2243
|
+
);
|
|
1707
2244
|
}
|
|
1708
2245
|
const wt = createWorktree(baseCwd, id);
|
|
1709
2246
|
if (!wt) {
|
|
1710
2247
|
throw new Error(
|
|
1711
2248
|
'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
|
|
1712
|
-
|
|
2249
|
+
"Initialize git and commit at least once, or omit `isolation`.",
|
|
1713
2250
|
);
|
|
1714
2251
|
}
|
|
1715
2252
|
record.worktree = wt;
|
|
@@ -1725,16 +2262,29 @@ export class AgentManager {
|
|
|
1725
2262
|
this.runningBackground++;
|
|
1726
2263
|
}
|
|
1727
2264
|
if (!record.detached) {
|
|
1728
|
-
try {
|
|
2265
|
+
try {
|
|
2266
|
+
this.onStart?.(record);
|
|
2267
|
+
} catch {
|
|
2268
|
+
/* lifecycle observers are best effort */
|
|
2269
|
+
}
|
|
1729
2270
|
}
|
|
1730
2271
|
// `onStart` can synchronously stop the record (for example, an owner-scoped
|
|
1731
2272
|
// stop arriving from a lifecycle observer). Do not invoke the runner after
|
|
1732
2273
|
// that decision, and release the slot acquired above.
|
|
1733
|
-
if (
|
|
2274
|
+
if (
|
|
2275
|
+
this.disposed ||
|
|
2276
|
+
record.detached ||
|
|
2277
|
+
this.agents.get(id) !== record ||
|
|
2278
|
+
record.status !== "running"
|
|
2279
|
+
) {
|
|
1734
2280
|
this.clearParentSignal(id);
|
|
1735
2281
|
this.releasePoolSlot(id);
|
|
1736
2282
|
if (record.worktree) {
|
|
1737
|
-
this.cleanupRecordWorktree(
|
|
2283
|
+
this.cleanupRecordWorktree(
|
|
2284
|
+
record,
|
|
2285
|
+
worktreeRepoRoot ?? baseCwd,
|
|
2286
|
+
options.description,
|
|
2287
|
+
);
|
|
1738
2288
|
}
|
|
1739
2289
|
return;
|
|
1740
2290
|
}
|
|
@@ -1750,101 +2300,108 @@ export class AgentManager {
|
|
|
1750
2300
|
let rawPromise: ReturnType<typeof runAgent>;
|
|
1751
2301
|
try {
|
|
1752
2302
|
rawPromise = runAgent(ctx, type, prompt, {
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
options.
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
record.
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
return;
|
|
1834
|
-
}
|
|
1835
|
-
record.session = session;
|
|
1836
|
-
// Capture the persisted session file so an evicted record can be
|
|
1837
|
-
// reopened as a resumable entry (@handle reopen).
|
|
1838
|
-
record.sessionFile = session.sessionManager?.getSessionFile?.() ?? (session as { sessionFile?: string })?.sessionFile;
|
|
1839
|
-
// Flush any steers that arrived before the session was ready
|
|
1840
|
-
if (record.pendingSteers?.length) {
|
|
1841
|
-
for (const msg of record.pendingSteers) {
|
|
1842
|
-
session.steer(msg).catch(() => {});
|
|
2303
|
+
pi,
|
|
2304
|
+
agentId: id,
|
|
2305
|
+
model: options.model,
|
|
2306
|
+
maxTurns: options.maxTurns,
|
|
2307
|
+
isolated: internalOverride?.isolated ?? options.isolated,
|
|
2308
|
+
inheritContext:
|
|
2309
|
+
internalOverride?.inheritContext ?? options.inheritContext,
|
|
2310
|
+
...(internalOverride
|
|
2311
|
+
? { [INTERNAL_AGENT_CONFIG_OVERRIDE]: internalOverride }
|
|
2312
|
+
: {}),
|
|
2313
|
+
[INTERNAL_PARENT_POLICY_SNAPSHOT]:
|
|
2314
|
+
options[INTERNAL_PARENT_POLICY_SNAPSHOT],
|
|
2315
|
+
thinkingLevel: options.thinkingLevel,
|
|
2316
|
+
agentTier: options.agentTier,
|
|
2317
|
+
requireAgentTier: options.requireAgentTier,
|
|
2318
|
+
// Worktree wins for the working dir (the agent must run in the copy —
|
|
2319
|
+
// which, with a custom cwd, was created from that target). Config stays
|
|
2320
|
+
// with the parent project when a caller-supplied cwd is in play; it must
|
|
2321
|
+
// stay undefined otherwise so plain worktree runs keep resolving config
|
|
2322
|
+
// (incl. relative extension paths and memory) inside the worktree copy.
|
|
2323
|
+
cwd: worktreeCwd ?? customCwd,
|
|
2324
|
+
// Preserve the original repository top-level separately from the
|
|
2325
|
+
// worktree cwd so the child prompt can mark the whole base checkout
|
|
2326
|
+
// off-limits, even when the invocation started in a subdirectory.
|
|
2327
|
+
worktreeBase: worktreeRepoRoot,
|
|
2328
|
+
configCwd:
|
|
2329
|
+
options.configCwd ?? (customCwd !== undefined ? ctx.cwd : undefined),
|
|
2330
|
+
// Top-level conversations persist by default so `@handle` has something
|
|
2331
|
+
// to reopen after the record is evicted; frontmatter still overrides.
|
|
2332
|
+
rememberAgents: this.rememberAgents,
|
|
2333
|
+
supervisorQuestions: this.supervisorQuestions,
|
|
2334
|
+
resumeSessionFile: options.resumeSessionFile,
|
|
2335
|
+
toolset: options.toolset,
|
|
2336
|
+
excludeTools: options.excludeTools,
|
|
2337
|
+
thread: options.thread,
|
|
2338
|
+
signal: record.abortController!.signal,
|
|
2339
|
+
onToolActivity: (activity) => {
|
|
2340
|
+
if (record.detached) return;
|
|
2341
|
+
if (activity.type === "end") record.toolUses++;
|
|
2342
|
+
options.onToolActivity?.(activity);
|
|
2343
|
+
},
|
|
2344
|
+
onTurnEnd: (turnCount) => {
|
|
2345
|
+
if (!record.detached) options.onTurnEnd?.(turnCount);
|
|
2346
|
+
},
|
|
2347
|
+
onTextDelta: (delta, fullText) => {
|
|
2348
|
+
if (!record.detached) options.onTextDelta?.(delta, fullText);
|
|
2349
|
+
},
|
|
2350
|
+
onAssistantUsage: (usage) => {
|
|
2351
|
+
if (record.detached) return;
|
|
2352
|
+
addUsage(record.lifetimeUsage, usage);
|
|
2353
|
+
options.onAssistantUsage?.(usage);
|
|
2354
|
+
},
|
|
2355
|
+
onCompaction: (info) => {
|
|
2356
|
+
if (record.detached) return;
|
|
2357
|
+
record.compactionCount++;
|
|
2358
|
+
this.syncManagedRecord(record);
|
|
2359
|
+
this.onCompact?.(record, info);
|
|
2360
|
+
options.onCompaction?.(info);
|
|
2361
|
+
},
|
|
2362
|
+
nestedRuntime: {
|
|
2363
|
+
manager: this,
|
|
2364
|
+
parentAgentId: id,
|
|
2365
|
+
depth: record.depth ?? 1,
|
|
2366
|
+
maxSubagentDepth: record.maxSubagentDepth,
|
|
2367
|
+
},
|
|
2368
|
+
onAgentTierResolved: (snapshot, modelLabel) => {
|
|
2369
|
+
if (!record.detached) {
|
|
2370
|
+
record.invocation = {
|
|
2371
|
+
...(record.invocation ?? {}),
|
|
2372
|
+
// The tier owns model and thinking, so it owns their display too —
|
|
2373
|
+
// including clearing a label a caller pre-computed before the tier
|
|
2374
|
+
// resolved. Without this a tiered spawn shows its tier name and no
|
|
2375
|
+
// model at all, even when the profile pins one.
|
|
2376
|
+
modelName: modelLabel,
|
|
2377
|
+
agentTier: snapshot.tier,
|
|
2378
|
+
thinking: snapshot.thinking,
|
|
2379
|
+
agentTierSnapshot: { ...snapshot },
|
|
2380
|
+
};
|
|
2381
|
+
this.syncManagedRecord(record, true);
|
|
2382
|
+
options.onAgentTierResolved?.(snapshot, modelLabel);
|
|
1843
2383
|
}
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
2384
|
+
},
|
|
2385
|
+
onSessionCreated: (session) => {
|
|
2386
|
+
if (record.detached) {
|
|
2387
|
+
this.trackRecordSessionTeardown(id, session);
|
|
2388
|
+
return;
|
|
2389
|
+
}
|
|
2390
|
+
record.session = session;
|
|
2391
|
+
// Capture the persisted session file so an evicted record can be
|
|
2392
|
+
// reopened as a resumable entry (@handle reopen).
|
|
2393
|
+
record.sessionFile =
|
|
2394
|
+
session.sessionManager?.getSessionFile?.() ??
|
|
2395
|
+
(session as { sessionFile?: string })?.sessionFile;
|
|
2396
|
+
// Flush any steers that arrived before the session was ready
|
|
2397
|
+
if (record.pendingSteers?.length) {
|
|
2398
|
+
for (const msg of record.pendingSteers) {
|
|
2399
|
+
session.steer(msg).catch(() => {});
|
|
2400
|
+
}
|
|
2401
|
+
record.pendingSteers = undefined;
|
|
2402
|
+
}
|
|
2403
|
+
options.onSessionCreated?.(session);
|
|
2404
|
+
},
|
|
1848
2405
|
});
|
|
1849
2406
|
} catch (error) {
|
|
1850
2407
|
this.settlingRecords.delete(id);
|
|
@@ -1858,10 +2415,18 @@ export class AgentManager {
|
|
|
1858
2415
|
}
|
|
1859
2416
|
const cleanupFailedStartWorktree = (): void => {
|
|
1860
2417
|
if (this.disposed || !record.worktree) return;
|
|
1861
|
-
this.cleanupRecordWorktree(
|
|
2418
|
+
this.cleanupRecordWorktree(
|
|
2419
|
+
record,
|
|
2420
|
+
worktreeRepoRoot ?? baseCwd,
|
|
2421
|
+
options.description,
|
|
2422
|
+
);
|
|
1862
2423
|
this.retryDeferredRecordRemovals();
|
|
1863
2424
|
};
|
|
1864
|
-
if (sessionTeardown)
|
|
2425
|
+
if (sessionTeardown)
|
|
2426
|
+
void sessionTeardown.then(
|
|
2427
|
+
cleanupFailedStartWorktree,
|
|
2428
|
+
cleanupFailedStartWorktree,
|
|
2429
|
+
);
|
|
1865
2430
|
else cleanupFailedStartWorktree();
|
|
1866
2431
|
throw error;
|
|
1867
2432
|
}
|
|
@@ -1896,7 +2461,11 @@ export class AgentManager {
|
|
|
1896
2461
|
|
|
1897
2462
|
// Final flush of streaming output file
|
|
1898
2463
|
if (record.outputCleanup) {
|
|
1899
|
-
try {
|
|
2464
|
+
try {
|
|
2465
|
+
record.outputCleanup();
|
|
2466
|
+
} catch {
|
|
2467
|
+
/* ignore */
|
|
2468
|
+
}
|
|
1900
2469
|
record.outputCleanup = undefined;
|
|
1901
2470
|
}
|
|
1902
2471
|
|
|
@@ -1918,7 +2487,9 @@ export class AgentManager {
|
|
|
1918
2487
|
// undefined output or session.
|
|
1919
2488
|
record.result = responseText;
|
|
1920
2489
|
record.session = session;
|
|
1921
|
-
record.sessionFile =
|
|
2490
|
+
record.sessionFile =
|
|
2491
|
+
session.sessionManager?.getSessionFile?.() ??
|
|
2492
|
+
(session as { sessionFile?: string })?.sessionFile;
|
|
1922
2493
|
this.syncManagedRecord(record);
|
|
1923
2494
|
|
|
1924
2495
|
// Quiesce descendants before removing the parent's worktree. A nested
|
|
@@ -1939,14 +2510,19 @@ export class AgentManager {
|
|
|
1939
2510
|
if (record.worktree) {
|
|
1940
2511
|
worktreeResult = this.hasIncompleteRepoDependency(record)
|
|
1941
2512
|
? this.blockedWorktreeCleanup(record)
|
|
1942
|
-
: this.cleanupRecordWorktree(
|
|
2513
|
+
: this.cleanupRecordWorktree(
|
|
2514
|
+
record,
|
|
2515
|
+
worktreeRepoRoot ?? baseCwd,
|
|
2516
|
+
options.description,
|
|
2517
|
+
);
|
|
1943
2518
|
}
|
|
1944
2519
|
if (worktreeResult?.hasChanges && worktreeResult.branch) {
|
|
1945
2520
|
// With a caller-supplied cwd the branch lives in THAT repo, not the
|
|
1946
2521
|
// parent session's — say so, or the orchestrator merges in the wrong repo.
|
|
1947
|
-
const repoNote = worktreeRepoRoot
|
|
1948
|
-
|
|
1949
|
-
|
|
2522
|
+
const repoNote = worktreeRepoRoot
|
|
2523
|
+
? ` in \`${worktreeRepoRoot}\``
|
|
2524
|
+
: "";
|
|
2525
|
+
record.result += `\n\n---\nChanges saved to branch \`${worktreeResult.branch}\`${repoNote}. Merge with: \`git merge ${worktreeResult.branch}\`${worktreeRepoRoot ? ` (run in \`${worktreeRepoRoot}\`)` : ""}`;
|
|
1950
2526
|
}
|
|
1951
2527
|
if (worktreeResult && !worktreeResult.cleanupSucceeded) {
|
|
1952
2528
|
record.result +=
|
|
@@ -1989,7 +2565,11 @@ export class AgentManager {
|
|
|
1989
2565
|
|
|
1990
2566
|
// Final flush of streaming output file on error
|
|
1991
2567
|
if (record.outputCleanup) {
|
|
1992
|
-
try {
|
|
2568
|
+
try {
|
|
2569
|
+
record.outputCleanup();
|
|
2570
|
+
} catch {
|
|
2571
|
+
/* ignore */
|
|
2572
|
+
}
|
|
1993
2573
|
record.outputCleanup = undefined;
|
|
1994
2574
|
}
|
|
1995
2575
|
|
|
@@ -2008,7 +2588,11 @@ export class AgentManager {
|
|
|
2008
2588
|
}
|
|
2009
2589
|
|
|
2010
2590
|
if (record.worktree && !this.hasIncompleteRepoDependency(record)) {
|
|
2011
|
-
this.cleanupRecordWorktree(
|
|
2591
|
+
this.cleanupRecordWorktree(
|
|
2592
|
+
record,
|
|
2593
|
+
worktreeRepoRoot ?? baseCwd,
|
|
2594
|
+
options.description,
|
|
2595
|
+
);
|
|
2012
2596
|
}
|
|
2013
2597
|
|
|
2014
2598
|
if (record.detached) {
|
|
@@ -2048,7 +2632,11 @@ export class AgentManager {
|
|
|
2048
2632
|
// Called synchronously — onSessionCreated fires asynchronously inside runAgent.
|
|
2049
2633
|
// Used by spawnAndWait to set up output files before streaming starts.
|
|
2050
2634
|
if (!this.agents.get(id)?.detached) {
|
|
2051
|
-
try {
|
|
2635
|
+
try {
|
|
2636
|
+
this.onSpawned?.(id);
|
|
2637
|
+
} catch {
|
|
2638
|
+
/* observer failures cannot reject the run */
|
|
2639
|
+
}
|
|
2052
2640
|
}
|
|
2053
2641
|
}
|
|
2054
2642
|
|
|
@@ -2075,17 +2663,22 @@ export class AgentManager {
|
|
|
2075
2663
|
// Direct parent links are only an optimization for live records. Immutable
|
|
2076
2664
|
// lineage is authoritative, so a grandchild remains discoverable after its
|
|
2077
2665
|
// intermediate owner has been evicted.
|
|
2078
|
-
const descendants = this.descendantsOf(parentId)
|
|
2079
|
-
|
|
2666
|
+
const descendants = this.descendantsOf(parentId).sort(
|
|
2667
|
+
(left, right) => this.recordDepth(right) - this.recordDepth(left),
|
|
2668
|
+
);
|
|
2080
2669
|
for (const record of descendants) this.sealNestedSpawns(record.id);
|
|
2081
2670
|
|
|
2082
2671
|
const active = descendants.filter((record) => !this.isFullyCleaned(record));
|
|
2083
2672
|
for (const record of active) this.abortRecord(record.id, false);
|
|
2084
2673
|
this.drainQueue();
|
|
2085
2674
|
|
|
2086
|
-
const quiesced =
|
|
2087
|
-
|
|
2088
|
-
|
|
2675
|
+
const quiesced =
|
|
2676
|
+
active.length > 0
|
|
2677
|
+
? await this.waitForTerminalRecords(
|
|
2678
|
+
active,
|
|
2679
|
+
OWNED_CHILD_QUIESCE_TIMEOUT_MS,
|
|
2680
|
+
)
|
|
2681
|
+
: { settled: true, pending: [] as string[] };
|
|
2089
2682
|
if (quiesced.pending.length === 0) {
|
|
2090
2683
|
this.retryDeferredRecordRemovals();
|
|
2091
2684
|
return;
|
|
@@ -2103,14 +2696,20 @@ export class AgentManager {
|
|
|
2103
2696
|
this.queue = [];
|
|
2104
2697
|
return;
|
|
2105
2698
|
}
|
|
2106
|
-
while (
|
|
2699
|
+
while (
|
|
2700
|
+
this.queue.length > 0 &&
|
|
2701
|
+
this.runningBackground < this.maxConcurrent
|
|
2702
|
+
) {
|
|
2107
2703
|
const next = this.queue.shift()!;
|
|
2108
2704
|
const record = this.agents.get(next.id);
|
|
2109
2705
|
if (record?.status !== "queued") continue;
|
|
2110
2706
|
// A queued nested record can survive only while its immutable owner branch
|
|
2111
2707
|
// remains live. Never start one whose owner was sealed/removed while it
|
|
2112
2708
|
// waited in the queue.
|
|
2113
|
-
if (
|
|
2709
|
+
if (
|
|
2710
|
+
record.parentAgentId !== undefined &&
|
|
2711
|
+
this.nestedOwnerValidation(record.parentAgentId) !== undefined
|
|
2712
|
+
) {
|
|
2114
2713
|
this.abortRecord(record.id, false);
|
|
2115
2714
|
continue;
|
|
2116
2715
|
}
|
|
@@ -2146,7 +2745,7 @@ export class AgentManager {
|
|
|
2146
2745
|
this.clearParentSignal(record.id);
|
|
2147
2746
|
this.releasePoolSlot(record.id);
|
|
2148
2747
|
this.syncManagedRecord(record);
|
|
2149
|
-
this.notifyComplete(record)
|
|
2748
|
+
this.notifyComplete(record);
|
|
2150
2749
|
}
|
|
2151
2750
|
}
|
|
2152
2751
|
}
|
|
@@ -2195,7 +2794,12 @@ export class AgentManager {
|
|
|
2195
2794
|
ctx,
|
|
2196
2795
|
type,
|
|
2197
2796
|
prompt,
|
|
2198
|
-
{
|
|
2797
|
+
{
|
|
2798
|
+
...options,
|
|
2799
|
+
isolated: true,
|
|
2800
|
+
isolation: undefined,
|
|
2801
|
+
inheritContext: false,
|
|
2802
|
+
},
|
|
2199
2803
|
onSpawned,
|
|
2200
2804
|
internalOverride,
|
|
2201
2805
|
);
|
|
@@ -2240,21 +2844,28 @@ export class AgentManager {
|
|
|
2240
2844
|
return {
|
|
2241
2845
|
status: record.status,
|
|
2242
2846
|
startedAt: record.startedAt,
|
|
2243
|
-
...(record.completedAt === undefined
|
|
2847
|
+
...(record.completedAt === undefined
|
|
2848
|
+
? {}
|
|
2849
|
+
: { completedAt: record.completedAt }),
|
|
2244
2850
|
...(record.result === undefined ? {} : { result: record.result }),
|
|
2245
2851
|
...(record.error === undefined ? {} : { error: record.error }),
|
|
2246
|
-
...(record.invocation
|
|
2852
|
+
...(record.invocation
|
|
2853
|
+
? { invocation: cloneFrozenData(record.invocation) as AgentInvocation }
|
|
2854
|
+
: {}),
|
|
2247
2855
|
};
|
|
2248
2856
|
}
|
|
2249
2857
|
|
|
2250
|
-
private restoreResumeSnapshot(
|
|
2858
|
+
private restoreResumeSnapshot(
|
|
2859
|
+
record: AgentRecord,
|
|
2860
|
+
snapshot: ResumeTerminalSnapshot,
|
|
2861
|
+
): void {
|
|
2251
2862
|
record.status = snapshot.status;
|
|
2252
2863
|
record.startedAt = snapshot.startedAt;
|
|
2253
2864
|
record.completedAt = snapshot.completedAt;
|
|
2254
2865
|
record.result = snapshot.result;
|
|
2255
2866
|
record.error = snapshot.error;
|
|
2256
2867
|
record.invocation = snapshot.invocation
|
|
2257
|
-
? cloneFrozenData(snapshot.invocation) as AgentInvocation
|
|
2868
|
+
? (cloneFrozenData(snapshot.invocation) as AgentInvocation)
|
|
2258
2869
|
: undefined;
|
|
2259
2870
|
}
|
|
2260
2871
|
|
|
@@ -2269,14 +2880,22 @@ export class AgentManager {
|
|
|
2269
2880
|
let control!: ResumeControl;
|
|
2270
2881
|
const onAbort = (): void => {
|
|
2271
2882
|
controller.abort(signal?.reason);
|
|
2272
|
-
if (record.status === "queued" || record.status === "running")
|
|
2883
|
+
if (record.status === "queued" || record.status === "running")
|
|
2884
|
+
this.abort(id);
|
|
2273
2885
|
};
|
|
2274
2886
|
const cleanup = (): void => {
|
|
2275
2887
|
signal?.removeEventListener("abort", onAbort);
|
|
2276
|
-
if (this.resumeControls.get(id) === control)
|
|
2277
|
-
|
|
2888
|
+
if (this.resumeControls.get(id) === control)
|
|
2889
|
+
this.resumeControls.delete(id);
|
|
2890
|
+
if (record.abortController === controller)
|
|
2891
|
+
record.abortController = undefined;
|
|
2892
|
+
};
|
|
2893
|
+
control = {
|
|
2894
|
+
controller,
|
|
2895
|
+
cleanup,
|
|
2896
|
+
snapshot,
|
|
2897
|
+
...(deferred ? { deferred } : {}),
|
|
2278
2898
|
};
|
|
2279
|
-
control = { controller, cleanup, snapshot, ...(deferred ? { deferred } : {}) };
|
|
2280
2899
|
this.resumeControls.set(id, control);
|
|
2281
2900
|
record.abortController = controller;
|
|
2282
2901
|
if (signal) {
|
|
@@ -2303,8 +2922,15 @@ export class AgentManager {
|
|
|
2303
2922
|
signal?: AbortSignal,
|
|
2304
2923
|
options?: {
|
|
2305
2924
|
isBackground?: boolean;
|
|
2306
|
-
onToolActivity?: (activity: {
|
|
2307
|
-
|
|
2925
|
+
onToolActivity?: (activity: {
|
|
2926
|
+
type: "start" | "end";
|
|
2927
|
+
toolName: string;
|
|
2928
|
+
}) => void;
|
|
2929
|
+
onAssistantUsage?: (usage: {
|
|
2930
|
+
input: number;
|
|
2931
|
+
output: number;
|
|
2932
|
+
cacheWrite: number;
|
|
2933
|
+
}) => void;
|
|
2308
2934
|
onCompaction?: (info: unknown) => void;
|
|
2309
2935
|
/** Called once a queued background resume has acquired its run slot. */
|
|
2310
2936
|
onStarted?: () => void;
|
|
@@ -2315,8 +2941,17 @@ export class AgentManager {
|
|
|
2315
2941
|
if (!record) return undefined;
|
|
2316
2942
|
if (!this.canResumeNested(record)) return undefined;
|
|
2317
2943
|
if (signal?.aborted) return record;
|
|
2318
|
-
if (
|
|
2944
|
+
if (
|
|
2945
|
+
this.resumeControls.has(id) ||
|
|
2946
|
+
record.status === "running" ||
|
|
2947
|
+
record.status === "queued"
|
|
2948
|
+
)
|
|
2949
|
+
return undefined;
|
|
2319
2950
|
if (!record.session) return undefined;
|
|
2951
|
+
assertSafeChildSession(
|
|
2952
|
+
record.session,
|
|
2953
|
+
record.invocation?.inheritContext === true,
|
|
2954
|
+
);
|
|
2320
2955
|
|
|
2321
2956
|
const snapshot = this.resumeSnapshot(record);
|
|
2322
2957
|
if (options?.isBackground) {
|
|
@@ -2342,13 +2977,20 @@ export class AgentManager {
|
|
|
2342
2977
|
record.promise = lifecyclePromise;
|
|
2343
2978
|
|
|
2344
2979
|
const start = (): void => {
|
|
2345
|
-
const execution = this.startResume(
|
|
2980
|
+
const execution = this.startResume(
|
|
2981
|
+
id,
|
|
2982
|
+
record,
|
|
2983
|
+
prompt,
|
|
2984
|
+
control,
|
|
2985
|
+
options,
|
|
2986
|
+
);
|
|
2346
2987
|
record.promise = execution;
|
|
2347
|
-
void execution
|
|
2348
|
-
.then(resolveResume, rejectResume)
|
|
2349
|
-
.catch(() => {});
|
|
2988
|
+
void execution.then(resolveResume, rejectResume).catch(() => {});
|
|
2350
2989
|
};
|
|
2351
|
-
if (
|
|
2990
|
+
if (
|
|
2991
|
+
occupiesPoolSlot(record) &&
|
|
2992
|
+
this.runningBackground >= this.maxConcurrent
|
|
2993
|
+
) {
|
|
2352
2994
|
this.queue.push({ kind: "resume", id, start });
|
|
2353
2995
|
} else {
|
|
2354
2996
|
start();
|
|
@@ -2379,16 +3021,24 @@ export class AgentManager {
|
|
|
2379
3021
|
},
|
|
2380
3022
|
signal: control.controller.signal,
|
|
2381
3023
|
});
|
|
2382
|
-
if (
|
|
3024
|
+
if (
|
|
3025
|
+
!record.detached &&
|
|
3026
|
+
!control.controller.signal.aborted &&
|
|
3027
|
+
(record.status as AgentRecord["status"]) !== "stopped"
|
|
3028
|
+
) {
|
|
2383
3029
|
record.status = failure ? "error" : "completed";
|
|
2384
3030
|
if (failure) record.error = failure;
|
|
2385
3031
|
record.result = text;
|
|
2386
3032
|
record.completedAt = Date.now();
|
|
2387
3033
|
}
|
|
2388
3034
|
} catch (err) {
|
|
2389
|
-
if (
|
|
3035
|
+
if (
|
|
3036
|
+
!record.detached &&
|
|
3037
|
+
(record.status as AgentRecord["status"]) !== "stopped"
|
|
3038
|
+
) {
|
|
2390
3039
|
record.status = control.controller.signal.aborted ? "stopped" : "error";
|
|
2391
|
-
if (record.status === "error")
|
|
3040
|
+
if (record.status === "error")
|
|
3041
|
+
record.error = err instanceof Error ? err.message : String(err);
|
|
2392
3042
|
record.completedAt = Date.now();
|
|
2393
3043
|
}
|
|
2394
3044
|
} finally {
|
|
@@ -2423,8 +3073,15 @@ export class AgentManager {
|
|
|
2423
3073
|
control: ResumeControl,
|
|
2424
3074
|
options?: {
|
|
2425
3075
|
isBackground?: boolean;
|
|
2426
|
-
onToolActivity?: (activity: {
|
|
2427
|
-
|
|
3076
|
+
onToolActivity?: (activity: {
|
|
3077
|
+
type: "start" | "end";
|
|
3078
|
+
toolName: string;
|
|
3079
|
+
}) => void;
|
|
3080
|
+
onAssistantUsage?: (usage: {
|
|
3081
|
+
input: number;
|
|
3082
|
+
output: number;
|
|
3083
|
+
cacheWrite: number;
|
|
3084
|
+
}) => void;
|
|
2428
3085
|
onCompaction?: (info: unknown) => void;
|
|
2429
3086
|
/** Called once a queued background resume has acquired its run slot. */
|
|
2430
3087
|
onStarted?: () => void;
|
|
@@ -2499,16 +3156,26 @@ export class AgentManager {
|
|
|
2499
3156
|
},
|
|
2500
3157
|
signal: control.controller.signal,
|
|
2501
3158
|
});
|
|
2502
|
-
if (
|
|
3159
|
+
if (
|
|
3160
|
+
!record.detached &&
|
|
3161
|
+
!control.controller.signal.aborted &&
|
|
3162
|
+
(record.status as AgentRecord["status"]) !== "stopped"
|
|
3163
|
+
) {
|
|
2503
3164
|
record.status = failure ? "error" : "completed";
|
|
2504
3165
|
if (failure) record.error = failure;
|
|
2505
3166
|
record.result = text;
|
|
2506
3167
|
record.completedAt = Date.now();
|
|
2507
3168
|
}
|
|
2508
3169
|
} catch (err) {
|
|
2509
|
-
if (
|
|
2510
|
-
record.
|
|
2511
|
-
|
|
3170
|
+
if (
|
|
3171
|
+
!record.detached &&
|
|
3172
|
+
(record.status as AgentRecord["status"]) !== "stopped"
|
|
3173
|
+
) {
|
|
3174
|
+
record.status = control.controller.signal.aborted
|
|
3175
|
+
? "stopped"
|
|
3176
|
+
: "error";
|
|
3177
|
+
if (record.status === "error")
|
|
3178
|
+
record.error = err instanceof Error ? err.message : String(err);
|
|
2512
3179
|
record.completedAt = Date.now();
|
|
2513
3180
|
}
|
|
2514
3181
|
}
|
|
@@ -2559,7 +3226,7 @@ export class AgentManager {
|
|
|
2559
3226
|
return true;
|
|
2560
3227
|
}
|
|
2561
3228
|
|
|
2562
|
-
/**
|
|
3229
|
+
/**
|
|
2563
3230
|
* Return an immutable observation snapshot. Internal manager/index code must
|
|
2564
3231
|
* use `getRecordMutable()` when it needs to update authoritative state.
|
|
2565
3232
|
*/
|
|
@@ -2577,7 +3244,6 @@ export class AgentManager {
|
|
|
2577
3244
|
return this.agents.get(id);
|
|
2578
3245
|
}
|
|
2579
3246
|
|
|
2580
|
-
|
|
2581
3247
|
/** Internal live list for this extension's UI and lifecycle wiring. */
|
|
2582
3248
|
listAgentsMutable(): AgentRecord[] {
|
|
2583
3249
|
return [...this.agents.values()]
|
|
@@ -2597,7 +3263,7 @@ export class AgentManager {
|
|
|
2597
3263
|
|
|
2598
3264
|
// Remove from queue if queued.
|
|
2599
3265
|
if (record.status === "queued") {
|
|
2600
|
-
this.queue = this.queue.filter(q => q.id !== id);
|
|
3266
|
+
this.queue = this.queue.filter((q) => q.id !== id);
|
|
2601
3267
|
record.status = "stopped";
|
|
2602
3268
|
record.completedAt = Date.now();
|
|
2603
3269
|
this.clearParentSignal(record.id);
|
|
@@ -2610,7 +3276,11 @@ export class AgentManager {
|
|
|
2610
3276
|
this.syncManagedRecord(record);
|
|
2611
3277
|
// Queued agents have no run promise yet. Still use the normal terminal
|
|
2612
3278
|
// callback so lifecycle consumers (including workflow waits) cannot hang.
|
|
2613
|
-
try {
|
|
3279
|
+
try {
|
|
3280
|
+
this.notifyComplete(record);
|
|
3281
|
+
} catch {
|
|
3282
|
+
/* ignore side-effect errors */
|
|
3283
|
+
}
|
|
2614
3284
|
if (drain) this.drainQueue();
|
|
2615
3285
|
return true;
|
|
2616
3286
|
}
|
|
@@ -2635,7 +3305,9 @@ export class AgentManager {
|
|
|
2635
3305
|
if (!record) return false;
|
|
2636
3306
|
|
|
2637
3307
|
const descendants = this.descendantsOf(id);
|
|
2638
|
-
const hasActiveDescendant = descendants.some(
|
|
3308
|
+
const hasActiveDescendant = descendants.some(
|
|
3309
|
+
(child) => !this.isFullyCleaned(child),
|
|
3310
|
+
);
|
|
2639
3311
|
const isActive = record.status === "queued" || record.status === "running";
|
|
2640
3312
|
if (!isActive && !hasActiveDescendant) return false;
|
|
2641
3313
|
|
|
@@ -2666,37 +3338,52 @@ export class AgentManager {
|
|
|
2666
3338
|
}
|
|
2667
3339
|
|
|
2668
3340
|
private descendantsOf(rootId: string): AgentRecord[] {
|
|
2669
|
-
return [...this.agents.values()]
|
|
2670
|
-
|
|
3341
|
+
return [...this.agents.values()].filter(
|
|
3342
|
+
(record) =>
|
|
3343
|
+
record.id !== rootId &&
|
|
3344
|
+
record.ancestorAgentIds?.includes(rootId) === true,
|
|
3345
|
+
);
|
|
2671
3346
|
}
|
|
2672
3347
|
|
|
2673
3348
|
private hasIncompleteRepoDependency(record: AgentRecord): boolean {
|
|
2674
3349
|
const worktreePath = record.worktree?.path;
|
|
2675
3350
|
if (!worktreePath) return false;
|
|
2676
|
-
return this.descendantsOf(record.id).some(
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
child.worktree
|
|
3351
|
+
return this.descendantsOf(record.id).some(
|
|
3352
|
+
(child) =>
|
|
3353
|
+
!this.isFullyCleaned(child) &&
|
|
3354
|
+
// Isolated children depend on the checkout they were created from. A
|
|
3355
|
+
// non-isolated nested child has no worktree record and may execute in any
|
|
3356
|
+
// ancestor checkout, so retaining the ancestor is the fail-closed choice.
|
|
3357
|
+
((child.worktree !== undefined &&
|
|
3358
|
+
sameFilesystemPath(child.worktree.repoRoot, worktreePath)) ||
|
|
3359
|
+
child.worktree === undefined),
|
|
2683
3360
|
);
|
|
2684
3361
|
}
|
|
2685
3362
|
|
|
2686
|
-
private blockedWorktreeCleanup(
|
|
3363
|
+
private blockedWorktreeCleanup(
|
|
3364
|
+
record: AgentRecord,
|
|
3365
|
+
diagnostic?: string,
|
|
3366
|
+
): WorktreeCleanupResult | undefined {
|
|
2687
3367
|
const worktree = record.worktree;
|
|
2688
3368
|
if (!worktree) return undefined;
|
|
2689
|
-
const dependent = this.descendantsOf(record.id).find(
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
child.worktree
|
|
3369
|
+
const dependent = this.descendantsOf(record.id).find(
|
|
3370
|
+
(child) =>
|
|
3371
|
+
!this.isFullyCleaned(child) &&
|
|
3372
|
+
((child.worktree !== undefined &&
|
|
3373
|
+
sameFilesystemPath(child.worktree.repoRoot, worktree.path)) ||
|
|
3374
|
+
child.worktree === undefined),
|
|
2693
3375
|
);
|
|
2694
3376
|
const result: WorktreeCleanupResult = {
|
|
2695
3377
|
hasChanges: false,
|
|
2696
3378
|
path: worktree.path,
|
|
2697
3379
|
cleanupSucceeded: false,
|
|
2698
|
-
cleanupDiagnostic:
|
|
2699
|
-
|
|
3380
|
+
cleanupDiagnostic:
|
|
3381
|
+
diagnostic ??
|
|
3382
|
+
`Worktree cleanup is pinned because descendant ${dependent?.id ?? "agent"} still depends on ${worktree.path}`,
|
|
3383
|
+
recoveryCommands: worktreeRecoveryCommands(
|
|
3384
|
+
worktree.repoRoot,
|
|
3385
|
+
worktree.path,
|
|
3386
|
+
),
|
|
2700
3387
|
};
|
|
2701
3388
|
record.worktreeResult = result;
|
|
2702
3389
|
return result;
|
|
@@ -2706,15 +3393,21 @@ export class AgentManager {
|
|
|
2706
3393
|
// A quarantined record remains non-quiescent until its provider promise,
|
|
2707
3394
|
// worktree cleanup, transcript cleanup, and session teardown all finish.
|
|
2708
3395
|
// Terminal status alone is never proof that the branch is safe to replace.
|
|
2709
|
-
return
|
|
3396
|
+
return (
|
|
3397
|
+
record.status !== "queued" &&
|
|
3398
|
+
record.status !== "running" &&
|
|
2710
3399
|
!this.settlingRecords.has(record.id) &&
|
|
3400
|
+
!this.providerPendingRecords.has(record.id) &&
|
|
2711
3401
|
!this.recordSessionTeardowns.has(record.id) &&
|
|
2712
|
-
record.worktree === undefined &&
|
|
3402
|
+
record.worktree === undefined &&
|
|
3403
|
+
record.outputCleanup === undefined
|
|
3404
|
+
);
|
|
2713
3405
|
}
|
|
2714
3406
|
|
|
2715
3407
|
private abortDescendantsSynchronously(rootId: string): void {
|
|
2716
|
-
const descendants = this.descendantsOf(rootId)
|
|
2717
|
-
|
|
3408
|
+
const descendants = this.descendantsOf(rootId).sort(
|
|
3409
|
+
(left, right) => this.recordDepth(right) - this.recordDepth(left),
|
|
3410
|
+
);
|
|
2718
3411
|
for (const child of descendants) this.sealNestedSpawns(child.id);
|
|
2719
3412
|
for (const child of descendants) this.abortRecord(child.id, false);
|
|
2720
3413
|
this.drainQueue();
|
|
@@ -2724,8 +3417,8 @@ export class AgentManager {
|
|
|
2724
3417
|
if (this.disposed) return;
|
|
2725
3418
|
for (const id of this.nestedSpawnSeals) {
|
|
2726
3419
|
if (this.removingRecords.has(id) || this.agents.has(id)) continue;
|
|
2727
|
-
const stillReferenced = [...this.agents.values()].some(
|
|
2728
|
-
record.ancestorAgentIds?.includes(id) === true,
|
|
3420
|
+
const stillReferenced = [...this.agents.values()].some(
|
|
3421
|
+
(record) => record.ancestorAgentIds?.includes(id) === true,
|
|
2729
3422
|
);
|
|
2730
3423
|
if (!stillReferenced) this.nestedSpawnSeals.delete(id);
|
|
2731
3424
|
}
|
|
@@ -2744,20 +3437,22 @@ export class AgentManager {
|
|
|
2744
3437
|
private retryPinnedWorktreeCleanup(): void {
|
|
2745
3438
|
if (this.disposed) return;
|
|
2746
3439
|
const records = [...this.agents.values()]
|
|
2747
|
-
.filter(
|
|
2748
|
-
record
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
3440
|
+
.filter(
|
|
3441
|
+
(record) =>
|
|
3442
|
+
record.status !== "queued" &&
|
|
3443
|
+
record.status !== "running" &&
|
|
3444
|
+
record.worktree &&
|
|
3445
|
+
!this.recordSessionTeardowns.has(record.id) &&
|
|
3446
|
+
!this.settlingRecords.has(record.id),
|
|
2752
3447
|
)
|
|
2753
3448
|
.sort((left, right) => this.recordDepth(right) - this.recordDepth(left));
|
|
2754
3449
|
for (const record of records) {
|
|
2755
|
-
if (this.hasIncompleteRepoDependency(record))
|
|
3450
|
+
if (this.hasIncompleteRepoDependency(record))
|
|
3451
|
+
this.blockedWorktreeCleanup(record);
|
|
2756
3452
|
else this.cleanupDetachedWorktree(record);
|
|
2757
3453
|
}
|
|
2758
3454
|
}
|
|
2759
3455
|
|
|
2760
|
-
|
|
2761
3456
|
private retryDeferredRecordRemovals(): void {
|
|
2762
3457
|
if (this.disposed || this.deferredRecordRemovals.size === 0) {
|
|
2763
3458
|
this.pruneNestedMetadata();
|
|
@@ -2790,7 +3485,9 @@ export class AgentManager {
|
|
|
2790
3485
|
// invoke extension callbacks, and those callbacks must not reopen this owner.
|
|
2791
3486
|
this.sealNestedSpawns(id);
|
|
2792
3487
|
this.abortDescendantsSynchronously(id);
|
|
2793
|
-
const blockedByDescendant = this.descendantsOf(id).some(
|
|
3488
|
+
const blockedByDescendant = this.descendantsOf(id).some(
|
|
3489
|
+
(child) => !this.isFullyCleaned(child),
|
|
3490
|
+
);
|
|
2794
3491
|
if (!this.isFullyCleaned(record) || blockedByDescendant) {
|
|
2795
3492
|
// Synchronous cleanup cannot await provider quiescence. Leave the owner
|
|
2796
3493
|
// visible and retry after descendant settlement or on the next interval.
|
|
@@ -2803,7 +3500,11 @@ export class AgentManager {
|
|
|
2803
3500
|
this.removingRecords.add(id);
|
|
2804
3501
|
try {
|
|
2805
3502
|
if (record.outputCleanup) {
|
|
2806
|
-
try {
|
|
3503
|
+
try {
|
|
3504
|
+
record.outputCleanup();
|
|
3505
|
+
} catch {
|
|
3506
|
+
/* ignore stale transcript cleanup errors */
|
|
3507
|
+
}
|
|
2807
3508
|
record.outputCleanup = undefined;
|
|
2808
3509
|
}
|
|
2809
3510
|
this.indexResumable(record);
|
|
@@ -2851,7 +3552,8 @@ export class AgentManager {
|
|
|
2851
3552
|
while (this.resumable.size > MAX_RESUMABLE_ENTRIES) {
|
|
2852
3553
|
let oldest: ResumableAgentEntry | undefined;
|
|
2853
3554
|
for (const candidate of this.resumable.values()) {
|
|
2854
|
-
if (!oldest || candidate.completedAt <= oldest.completedAt)
|
|
3555
|
+
if (!oldest || candidate.completedAt <= oldest.completedAt)
|
|
3556
|
+
oldest = candidate;
|
|
2855
3557
|
}
|
|
2856
3558
|
if (!oldest) break;
|
|
2857
3559
|
this.resumable.delete(oldest.handle);
|
|
@@ -2881,11 +3583,17 @@ export class AgentManager {
|
|
|
2881
3583
|
* time), so both can hold the same handle at once — and while a record is
|
|
2882
3584
|
* live, it is the one the user means.
|
|
2883
3585
|
*/
|
|
2884
|
-
resolveMention(
|
|
3586
|
+
resolveMention(
|
|
3587
|
+
handle: string,
|
|
3588
|
+
):
|
|
3589
|
+
| { kind: "live"; record: AgentRecord }
|
|
3590
|
+
| { kind: "resumable"; entry: ResumableAgentEntry }
|
|
3591
|
+
| undefined {
|
|
2885
3592
|
const wanted = handle.toLowerCase();
|
|
2886
3593
|
for (const record of this.agents.values()) {
|
|
2887
3594
|
if (record.detached) continue;
|
|
2888
|
-
if (record.handle?.toLowerCase() === wanted || record.id === handle)
|
|
3595
|
+
if (record.handle?.toLowerCase() === wanted || record.id === handle)
|
|
3596
|
+
return { kind: "live", record };
|
|
2889
3597
|
}
|
|
2890
3598
|
const entry = this.getResumable(handle);
|
|
2891
3599
|
return entry ? { kind: "resumable", entry } : undefined;
|
|
@@ -2895,14 +3603,17 @@ export class AgentManager {
|
|
|
2895
3603
|
getResumable(name: string): ResumableAgentEntry | undefined {
|
|
2896
3604
|
const wanted = name.toLowerCase();
|
|
2897
3605
|
for (const entry of this.resumable.values()) {
|
|
2898
|
-
if (entry.handle.toLowerCase() === wanted || entry.id === name)
|
|
3606
|
+
if (entry.handle.toLowerCase() === wanted || entry.id === name)
|
|
3607
|
+
return entry;
|
|
2899
3608
|
}
|
|
2900
3609
|
return undefined;
|
|
2901
3610
|
}
|
|
2902
3611
|
|
|
2903
3612
|
/** Evicted agents whose conversation can still be reopened, newest first. */
|
|
2904
3613
|
listResumable(): ResumableAgentEntry[] {
|
|
2905
|
-
return [...this.resumable.values()].sort(
|
|
3614
|
+
return [...this.resumable.values()].sort(
|
|
3615
|
+
(a, b) => b.completedAt - a.completedAt,
|
|
3616
|
+
);
|
|
2906
3617
|
}
|
|
2907
3618
|
|
|
2908
3619
|
/** Forget an evicted agent, by handle or id. */
|
|
@@ -2961,7 +3672,7 @@ export class AgentManager {
|
|
|
2961
3672
|
this.removeRecord(id, record);
|
|
2962
3673
|
}
|
|
2963
3674
|
this.retryDeferredRecordRemovals();
|
|
2964
|
-
}
|
|
3675
|
+
} /**
|
|
2965
3676
|
* Remove all completed/stopped/errored records immediately.
|
|
2966
3677
|
* Called on session start/switch so tasks from a prior session don't persist.
|
|
2967
3678
|
* Pass skipUnconsumed=true to preserve records the LLM hasn't read yet
|
|
@@ -2979,7 +3690,21 @@ export class AgentManager {
|
|
|
2979
3690
|
/** Whether any agents are still running or queued. */
|
|
2980
3691
|
hasRunning(): boolean {
|
|
2981
3692
|
return [...this.agents.values()].some(
|
|
2982
|
-
r => r.status === "running" || r.status === "queued",
|
|
3693
|
+
(r) => r.status === "running" || r.status === "queued",
|
|
3694
|
+
);
|
|
3695
|
+
}
|
|
3696
|
+
|
|
3697
|
+
/** Synchronous navigation preflight: terminal status does not imply cleanup. */
|
|
3698
|
+
hasUnsettledWork(): boolean {
|
|
3699
|
+
return (
|
|
3700
|
+
this.sessionTeardowns.size > 0 ||
|
|
3701
|
+
this.managedPersistenceRetries.size > 0 ||
|
|
3702
|
+
[...this.agents.values()].some(
|
|
3703
|
+
(record) => !this.isFullyCleaned(record),
|
|
3704
|
+
) ||
|
|
3705
|
+
[...this.managedSpawns.values()].some(
|
|
3706
|
+
(spawn) => !isManagedTerminalState(spawn.state),
|
|
3707
|
+
)
|
|
2983
3708
|
);
|
|
2984
3709
|
}
|
|
2985
3710
|
|
|
@@ -3005,8 +3730,16 @@ export class AgentManager {
|
|
|
3005
3730
|
}
|
|
3006
3731
|
for (const [index, owner] of owners.entries()) {
|
|
3007
3732
|
const id = agentIds[index];
|
|
3008
|
-
if (
|
|
3009
|
-
|
|
3733
|
+
if (
|
|
3734
|
+
!id ||
|
|
3735
|
+
owner.extension !== "pi-workflows" ||
|
|
3736
|
+
owner.runId !== runId ||
|
|
3737
|
+
!owner.attemptId
|
|
3738
|
+
) {
|
|
3739
|
+
return {
|
|
3740
|
+
settled: false,
|
|
3741
|
+
pending: [...new Set(agentIds)].slice(0, 256),
|
|
3742
|
+
};
|
|
3010
3743
|
}
|
|
3011
3744
|
ownerById.set(id, owner);
|
|
3012
3745
|
}
|
|
@@ -3017,12 +3750,21 @@ export class AgentManager {
|
|
|
3017
3750
|
record.owner?.extension !== "pi-workflows" ||
|
|
3018
3751
|
record.owner.runId !== runId ||
|
|
3019
3752
|
!expected.has(record.id)
|
|
3020
|
-
)
|
|
3753
|
+
)
|
|
3754
|
+
continue;
|
|
3021
3755
|
const expectedOwner = ownerById.get(record.id);
|
|
3022
|
-
if (
|
|
3756
|
+
if (
|
|
3757
|
+
!expectedOwner ||
|
|
3023
3758
|
record.owner.nodeId !== expectedOwner.nodeId ||
|
|
3024
|
-
record.owner.attemptId !== expectedOwner.attemptId
|
|
3025
|
-
|
|
3759
|
+
record.owner.attemptId !== expectedOwner.attemptId
|
|
3760
|
+
)
|
|
3761
|
+
continue;
|
|
3762
|
+
if (
|
|
3763
|
+
!this.isFullyCleaned(record) ||
|
|
3764
|
+
this.descendantsOf(record.id).some(
|
|
3765
|
+
(child) => !this.isFullyCleaned(child),
|
|
3766
|
+
)
|
|
3767
|
+
) {
|
|
3026
3768
|
records.push(record);
|
|
3027
3769
|
} else {
|
|
3028
3770
|
// An exact-owner terminal record with no live descendant is already
|
|
@@ -3031,7 +3773,10 @@ export class AgentManager {
|
|
|
3031
3773
|
settledIds.add(record.id);
|
|
3032
3774
|
}
|
|
3033
3775
|
}
|
|
3034
|
-
const matched = new Set([
|
|
3776
|
+
const matched = new Set([
|
|
3777
|
+
...records.map((record) => record.id),
|
|
3778
|
+
...settledIds,
|
|
3779
|
+
]);
|
|
3035
3780
|
const pendingOwners = agentIds.filter((id) => !matched.has(id));
|
|
3036
3781
|
|
|
3037
3782
|
// Managed callers name only top-level attempts, but nested descendants can
|
|
@@ -3040,32 +3785,45 @@ export class AgentManager {
|
|
|
3040
3785
|
const quiescenceRecords = [...records];
|
|
3041
3786
|
const branchOwnerById = new Map<string, string>();
|
|
3042
3787
|
for (const record of records) branchOwnerById.set(record.id, record.id);
|
|
3043
|
-
const branchRootIds = new Set([
|
|
3788
|
+
const branchRootIds = new Set([
|
|
3789
|
+
...agentIds,
|
|
3790
|
+
...records.map((record) => record.id),
|
|
3791
|
+
]);
|
|
3044
3792
|
for (const rootId of branchRootIds) {
|
|
3045
3793
|
for (const child of this.descendantsOf(rootId)) {
|
|
3046
|
-
if (this.isFullyCleaned(child) || branchOwnerById.has(child.id))
|
|
3794
|
+
if (this.isFullyCleaned(child) || branchOwnerById.has(child.id))
|
|
3795
|
+
continue;
|
|
3047
3796
|
branchOwnerById.set(child.id, rootId);
|
|
3048
3797
|
quiescenceRecords.push(child);
|
|
3049
3798
|
}
|
|
3050
3799
|
}
|
|
3051
3800
|
for (const record of quiescenceRecords) this.abort(record.id);
|
|
3052
|
-
const result = await this.waitForTerminalRecords(
|
|
3801
|
+
const result = await this.waitForTerminalRecords(
|
|
3802
|
+
quiescenceRecords,
|
|
3803
|
+
timeoutMs,
|
|
3804
|
+
);
|
|
3053
3805
|
const pendingBranchOwners = result.pending
|
|
3054
|
-
.map((id) => expected.has(id) ? undefined : branchOwnerById.get(id))
|
|
3806
|
+
.map((id) => (expected.has(id) ? undefined : branchOwnerById.get(id)))
|
|
3055
3807
|
.filter((id): id is string => id !== undefined);
|
|
3056
3808
|
return {
|
|
3057
3809
|
settled: result.settled && pendingOwners.length === 0,
|
|
3058
|
-
pending: [
|
|
3059
|
-
...
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3810
|
+
pending: [
|
|
3811
|
+
...new Set([
|
|
3812
|
+
...result.pending.filter((id) => expected.has(id)),
|
|
3813
|
+
...pendingOwners,
|
|
3814
|
+
...pendingBranchOwners,
|
|
3815
|
+
]),
|
|
3816
|
+
].slice(0, 256),
|
|
3063
3817
|
};
|
|
3064
3818
|
}
|
|
3065
3819
|
|
|
3066
3820
|
/** Stop every active record for session-tree preparation. */
|
|
3067
|
-
async quiesceAll(
|
|
3068
|
-
|
|
3821
|
+
async quiesceAll(
|
|
3822
|
+
timeoutMs: number,
|
|
3823
|
+
): Promise<{ settled: boolean; pending: string[] }> {
|
|
3824
|
+
const records = [...this.agents.values()].filter(
|
|
3825
|
+
(record) => !this.isFullyCleaned(record),
|
|
3826
|
+
);
|
|
3069
3827
|
for (const record of records) this.abort(record.id);
|
|
3070
3828
|
return this.waitForTerminalRecords(records, timeoutMs);
|
|
3071
3829
|
}
|
|
@@ -3086,14 +3844,20 @@ export class AgentManager {
|
|
|
3086
3844
|
if (!worktree) return undefined;
|
|
3087
3845
|
|
|
3088
3846
|
if (this.providerPendingRecords.has(record.id)) {
|
|
3089
|
-
return this.blockedWorktreeCleanup(
|
|
3847
|
+
return this.blockedWorktreeCleanup(
|
|
3848
|
+
record,
|
|
3849
|
+
`Worktree cleanup is pinned because provider/tool settlement for ${record.id} is still pending`,
|
|
3850
|
+
);
|
|
3090
3851
|
}
|
|
3091
3852
|
|
|
3092
3853
|
if (this.hasIncompleteRepoDependency(record)) {
|
|
3093
3854
|
return this.blockedWorktreeCleanup(record);
|
|
3094
3855
|
}
|
|
3095
3856
|
if (this.recordSessionTeardowns.has(record.id)) {
|
|
3096
|
-
return this.blockedWorktreeCleanup(
|
|
3857
|
+
return this.blockedWorktreeCleanup(
|
|
3858
|
+
record,
|
|
3859
|
+
`Worktree cleanup is pinned because child session teardown for ${record.id} is still pending`,
|
|
3860
|
+
);
|
|
3097
3861
|
}
|
|
3098
3862
|
if (this.disposed && !allowDuringDispose) return record.worktreeResult;
|
|
3099
3863
|
|
|
@@ -3106,7 +3870,9 @@ export class AgentManager {
|
|
|
3106
3870
|
if (!result.cleanupSucceeded && !result.recoveryCommands) {
|
|
3107
3871
|
result = {
|
|
3108
3872
|
...result,
|
|
3109
|
-
cleanupDiagnostic:
|
|
3873
|
+
cleanupDiagnostic:
|
|
3874
|
+
result.cleanupDiagnostic ??
|
|
3875
|
+
`Worktree cleanup failed for ${worktree.path}`,
|
|
3110
3876
|
recoveryCommands: worktreeRecoveryCommands(cwd, worktree.path),
|
|
3111
3877
|
};
|
|
3112
3878
|
}
|
|
@@ -3126,28 +3892,37 @@ export class AgentManager {
|
|
|
3126
3892
|
if (!worktree) return undefined;
|
|
3127
3893
|
|
|
3128
3894
|
if (this.providerPendingRecords.has(record.id)) {
|
|
3129
|
-
return this.blockedWorktreeCleanup(
|
|
3895
|
+
return this.blockedWorktreeCleanup(
|
|
3896
|
+
record,
|
|
3897
|
+
`Worktree cleanup is pinned because provider/tool settlement for ${record.id} is still pending`,
|
|
3898
|
+
);
|
|
3130
3899
|
}
|
|
3131
3900
|
|
|
3132
3901
|
if (this.hasIncompleteRepoDependency(record)) {
|
|
3133
3902
|
return this.blockedWorktreeCleanup(record);
|
|
3134
3903
|
}
|
|
3135
3904
|
if (this.recordSessionTeardowns.has(record.id)) {
|
|
3136
|
-
return this.blockedWorktreeCleanup(
|
|
3905
|
+
return this.blockedWorktreeCleanup(
|
|
3906
|
+
record,
|
|
3907
|
+
`Worktree cleanup is pinned because child session teardown for ${record.id} is still pending`,
|
|
3908
|
+
);
|
|
3137
3909
|
}
|
|
3138
3910
|
|
|
3139
3911
|
let result: WorktreeCleanupResult;
|
|
3140
3912
|
try {
|
|
3141
|
-
result =
|
|
3142
|
-
|
|
3143
|
-
|
|
3913
|
+
result =
|
|
3914
|
+
typeof cleanupWorktreeAsync === "function"
|
|
3915
|
+
? await cleanupWorktreeAsync(cwd, worktree, description)
|
|
3916
|
+
: cleanupWorktree(cwd, worktree, description);
|
|
3144
3917
|
} catch (error: unknown) {
|
|
3145
3918
|
result = cleanupFailureResult(cwd, worktree, error);
|
|
3146
3919
|
}
|
|
3147
3920
|
if (!result.cleanupSucceeded && !result.recoveryCommands) {
|
|
3148
3921
|
result = {
|
|
3149
3922
|
...result,
|
|
3150
|
-
cleanupDiagnostic:
|
|
3923
|
+
cleanupDiagnostic:
|
|
3924
|
+
result.cleanupDiagnostic ??
|
|
3925
|
+
`Worktree cleanup failed for ${worktree.path}`,
|
|
3151
3926
|
recoveryCommands: worktreeRecoveryCommands(cwd, worktree.path),
|
|
3152
3927
|
};
|
|
3153
3928
|
}
|
|
@@ -3156,7 +3931,10 @@ export class AgentManager {
|
|
|
3156
3931
|
return result;
|
|
3157
3932
|
}
|
|
3158
3933
|
|
|
3159
|
-
private cleanupDetachedWorktree(
|
|
3934
|
+
private cleanupDetachedWorktree(
|
|
3935
|
+
record: AgentRecord,
|
|
3936
|
+
allowDuringDispose = false,
|
|
3937
|
+
): WorktreeCleanupResult | undefined {
|
|
3160
3938
|
if (this.disposed && !allowDuringDispose) return record.worktreeResult;
|
|
3161
3939
|
const worktree = record.worktree;
|
|
3162
3940
|
if (!worktree) return undefined;
|
|
@@ -3164,7 +3942,12 @@ export class AgentManager {
|
|
|
3164
3942
|
if (this.hasIncompleteRepoDependency(record)) {
|
|
3165
3943
|
return this.blockedWorktreeCleanup(record);
|
|
3166
3944
|
}
|
|
3167
|
-
return this.cleanupRecordWorktree(
|
|
3945
|
+
return this.cleanupRecordWorktree(
|
|
3946
|
+
record,
|
|
3947
|
+
worktree.repoRoot,
|
|
3948
|
+
record.description,
|
|
3949
|
+
allowDuringDispose,
|
|
3950
|
+
);
|
|
3168
3951
|
}
|
|
3169
3952
|
|
|
3170
3953
|
/** Quarantine a record before any late provider continuation can observe it. */
|
|
@@ -3186,7 +3969,11 @@ export class AgentManager {
|
|
|
3186
3969
|
this.sealNestedSpawns(id);
|
|
3187
3970
|
this.clearParentSignal(id);
|
|
3188
3971
|
if (record.outputCleanup) {
|
|
3189
|
-
try {
|
|
3972
|
+
try {
|
|
3973
|
+
record.outputCleanup();
|
|
3974
|
+
} catch {
|
|
3975
|
+
/* ignore stale transcript cleanup errors */
|
|
3976
|
+
}
|
|
3190
3977
|
record.outputCleanup = undefined;
|
|
3191
3978
|
}
|
|
3192
3979
|
if (record.session) {
|
|
@@ -3201,7 +3988,9 @@ export class AgentManager {
|
|
|
3201
3988
|
timeoutMs: number,
|
|
3202
3989
|
): Promise<{ settled: boolean; pending: string[] }> {
|
|
3203
3990
|
const pendingIds = new Set(
|
|
3204
|
-
records
|
|
3991
|
+
records
|
|
3992
|
+
.filter((record) => !this.isFullyCleaned(record))
|
|
3993
|
+
.map((record) => record.id),
|
|
3205
3994
|
);
|
|
3206
3995
|
const recheck = (record: AgentRecord): void => {
|
|
3207
3996
|
if (this.isFullyCleaned(record)) pendingIds.delete(record.id);
|
|
@@ -3213,7 +4002,10 @@ export class AgentManager {
|
|
|
3213
4002
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
3214
4003
|
const allSettled = Promise.allSettled(pendingPromises).then(() => true);
|
|
3215
4004
|
const timedOut = new Promise<boolean>((resolve) => {
|
|
3216
|
-
timer = setTimeout(
|
|
4005
|
+
timer = setTimeout(
|
|
4006
|
+
() => resolve(false),
|
|
4007
|
+
Math.max(0, deadline - Date.now()),
|
|
4008
|
+
);
|
|
3217
4009
|
});
|
|
3218
4010
|
const completed = await Promise.race([allSettled, timedOut]);
|
|
3219
4011
|
if (timer) clearTimeout(timer);
|
|
@@ -3238,10 +4030,15 @@ export class AgentManager {
|
|
|
3238
4030
|
.map((record) => this.recordSessionTeardowns.get(record.id))
|
|
3239
4031
|
.filter((promise): promise is Promise<void> => promise !== undefined);
|
|
3240
4032
|
if (teardownPromises.length > 0) {
|
|
3241
|
-
const allTeardowns = Promise.allSettled(teardownPromises).then(
|
|
4033
|
+
const allTeardowns = Promise.allSettled(teardownPromises).then(
|
|
4034
|
+
() => true,
|
|
4035
|
+
);
|
|
3242
4036
|
let teardownTimer: ReturnType<typeof setTimeout> | undefined;
|
|
3243
4037
|
const teardownTimeout = new Promise<boolean>((resolve) => {
|
|
3244
|
-
teardownTimer = setTimeout(
|
|
4038
|
+
teardownTimer = setTimeout(
|
|
4039
|
+
() => resolve(false),
|
|
4040
|
+
Math.max(0, deadline - Date.now()),
|
|
4041
|
+
);
|
|
3245
4042
|
});
|
|
3246
4043
|
await Promise.race([allTeardowns, teardownTimeout]);
|
|
3247
4044
|
if (teardownTimer) clearTimeout(teardownTimer);
|
|
@@ -3254,9 +4051,15 @@ export class AgentManager {
|
|
|
3254
4051
|
.sort((left, right) => this.recordDepth(right) - this.recordDepth(left))
|
|
3255
4052
|
.forEach((record) => {
|
|
3256
4053
|
if (this.settlingRecords.has(record.id)) {
|
|
3257
|
-
this.blockedWorktreeCleanup(
|
|
4054
|
+
this.blockedWorktreeCleanup(
|
|
4055
|
+
record,
|
|
4056
|
+
`Worktree cleanup is pinned because provider settlement for ${record.id} is still pending`,
|
|
4057
|
+
);
|
|
3258
4058
|
} else if (this.recordSessionTeardowns.has(record.id)) {
|
|
3259
|
-
this.blockedWorktreeCleanup(
|
|
4059
|
+
this.blockedWorktreeCleanup(
|
|
4060
|
+
record,
|
|
4061
|
+
`Worktree cleanup is pinned because child session teardown for ${record.id} is still pending`,
|
|
4062
|
+
);
|
|
3260
4063
|
} else if (this.hasIncompleteRepoDependency(record)) {
|
|
3261
4064
|
this.blockedWorktreeCleanup(record);
|
|
3262
4065
|
} else {
|
|
@@ -3282,11 +4085,18 @@ export class AgentManager {
|
|
|
3282
4085
|
const records = [...this.agents.values()]
|
|
3283
4086
|
.filter((record) => !this.isFullyCleaned(record))
|
|
3284
4087
|
.sort((left, right) => this.recordDepth(right) - this.recordDepth(left));
|
|
4088
|
+
// An abort listener for one record can synchronously stop another owned
|
|
4089
|
+
// record. Seal every journal owner before aborting any provider on the new leaf.
|
|
4090
|
+
for (const record of records) record.detached = true;
|
|
3285
4091
|
for (const record of records) this.quarantineRecord(record.id, record);
|
|
3286
4092
|
for (const record of records) {
|
|
3287
4093
|
if (this.settlingRecords.has(record.id)) {
|
|
3288
|
-
this.blockedWorktreeCleanup(
|
|
3289
|
-
|
|
4094
|
+
this.blockedWorktreeCleanup(
|
|
4095
|
+
record,
|
|
4096
|
+
`Worktree cleanup is pinned because provider settlement for ${record.id} is still pending`,
|
|
4097
|
+
);
|
|
4098
|
+
} else if (this.hasIncompleteRepoDependency(record))
|
|
4099
|
+
this.blockedWorktreeCleanup(record);
|
|
3290
4100
|
else this.cleanupDetachedWorktree(record);
|
|
3291
4101
|
}
|
|
3292
4102
|
this.drainQueue();
|
|
@@ -3315,8 +4125,8 @@ export class AgentManager {
|
|
|
3315
4125
|
while (true) {
|
|
3316
4126
|
this.drainQueue();
|
|
3317
4127
|
const pending = [...this.agents.values()]
|
|
3318
|
-
.filter(r => r.status === "running" || r.status === "queued")
|
|
3319
|
-
.map(r => r.promise)
|
|
4128
|
+
.filter((r) => r.status === "running" || r.status === "queued")
|
|
4129
|
+
.map((r) => r.promise)
|
|
3320
4130
|
.filter(Boolean);
|
|
3321
4131
|
if (pending.length === 0) break;
|
|
3322
4132
|
await Promise.allSettled(pending);
|
|
@@ -3339,13 +4149,21 @@ export class AgentManager {
|
|
|
3339
4149
|
for (const record of records) {
|
|
3340
4150
|
if (record.worktree) reposToPrune.add(record.worktree.repoRoot);
|
|
3341
4151
|
}
|
|
3342
|
-
try {
|
|
4152
|
+
try {
|
|
4153
|
+
reposToPrune.add(process.cwd());
|
|
4154
|
+
} catch {
|
|
4155
|
+
/* ignore an invalid cwd */
|
|
4156
|
+
}
|
|
3343
4157
|
|
|
3344
4158
|
// Stop dispatch first. A late settlement must not start queued work while
|
|
3345
4159
|
// the old session is being torn down.
|
|
3346
4160
|
this.queue = [];
|
|
3347
4161
|
for (const cleanup of this.parentSignalCleanups.values()) {
|
|
3348
|
-
try {
|
|
4162
|
+
try {
|
|
4163
|
+
cleanup();
|
|
4164
|
+
} catch {
|
|
4165
|
+
/* ignore stale signal cleanup errors */
|
|
4166
|
+
}
|
|
3349
4167
|
}
|
|
3350
4168
|
this.parentSignalCleanups.clear();
|
|
3351
4169
|
this.heldPoolSlots.clear();
|
|
@@ -3365,7 +4183,11 @@ export class AgentManager {
|
|
|
3365
4183
|
this.nestedSpawnSeals.add(record.id);
|
|
3366
4184
|
this.removingRecords.add(record.id);
|
|
3367
4185
|
if (record.outputCleanup) {
|
|
3368
|
-
try {
|
|
4186
|
+
try {
|
|
4187
|
+
record.outputCleanup();
|
|
4188
|
+
} catch {
|
|
4189
|
+
/* ignore stale transcript cleanup errors */
|
|
4190
|
+
}
|
|
3369
4191
|
record.outputCleanup = undefined;
|
|
3370
4192
|
}
|
|
3371
4193
|
}
|
|
@@ -3382,14 +4204,24 @@ export class AgentManager {
|
|
|
3382
4204
|
// Abort every record first, then start every session teardown before awaiting
|
|
3383
4205
|
// any one of them. A failing handler therefore cannot starve its siblings.
|
|
3384
4206
|
for (const record of records) {
|
|
3385
|
-
try {
|
|
4207
|
+
try {
|
|
4208
|
+
await record.abortController?.abort();
|
|
4209
|
+
} catch {
|
|
4210
|
+
/* ignore stale abort errors */
|
|
4211
|
+
}
|
|
3386
4212
|
if (record.session) {
|
|
3387
4213
|
this.trackRecordSessionTeardown(record.id, record.session);
|
|
3388
4214
|
record.session = undefined;
|
|
3389
4215
|
}
|
|
3390
4216
|
}
|
|
3391
|
-
await this.awaitSessionTeardowns(
|
|
3392
|
-
|
|
4217
|
+
const teardownsSettled = await this.awaitSessionTeardowns(
|
|
4218
|
+
DISPOSE_SESSION_TEARDOWN_TIMEOUT_MS,
|
|
4219
|
+
);
|
|
4220
|
+
if (!teardownsSettled) {
|
|
4221
|
+
console.warn(
|
|
4222
|
+
`[pi-subagents] child session shutdown timed out; ${this.sessionTeardowns.size} teardown(s) still pending; attached worktrees remain pinned`,
|
|
4223
|
+
);
|
|
4224
|
+
}
|
|
3393
4225
|
|
|
3394
4226
|
const providerPromises = records
|
|
3395
4227
|
.map((record) => record.promise)
|
|
@@ -3397,30 +4229,49 @@ export class AgentManager {
|
|
|
3397
4229
|
if (providerPromises.length > 0) {
|
|
3398
4230
|
let providerTimer: ReturnType<typeof setTimeout> | undefined;
|
|
3399
4231
|
const providerTimeout = new Promise<void>((resolve) => {
|
|
3400
|
-
providerTimer = setTimeout(
|
|
4232
|
+
providerTimer = setTimeout(
|
|
4233
|
+
resolve,
|
|
4234
|
+
DISPOSE_PROVIDER_QUIESCE_TIMEOUT_MS,
|
|
4235
|
+
);
|
|
3401
4236
|
});
|
|
3402
|
-
await Promise.race([
|
|
4237
|
+
await Promise.race([
|
|
4238
|
+
Promise.allSettled(providerPromises).then(() => undefined),
|
|
4239
|
+
providerTimeout,
|
|
4240
|
+
]);
|
|
3403
4241
|
if (providerTimer) clearTimeout(providerTimer);
|
|
3404
4242
|
}
|
|
3405
4243
|
|
|
3406
|
-
const orderedRecords = records.sort(
|
|
4244
|
+
const orderedRecords = records.sort(
|
|
4245
|
+
(left, right) => this.recordDepth(right) - this.recordDepth(left),
|
|
4246
|
+
);
|
|
3407
4247
|
const attemptCleanup = async (record: AgentRecord): Promise<void> => {
|
|
3408
4248
|
if (!record.worktree) return;
|
|
3409
4249
|
try {
|
|
3410
4250
|
if (this.settlingRecords.has(record.id)) {
|
|
3411
|
-
this.blockedWorktreeCleanup(
|
|
4251
|
+
this.blockedWorktreeCleanup(
|
|
4252
|
+
record,
|
|
4253
|
+
`Worktree cleanup is pinned because provider settlement for ${record.id} is still pending`,
|
|
4254
|
+
);
|
|
3412
4255
|
return;
|
|
3413
4256
|
}
|
|
3414
4257
|
if (this.hasIncompleteRepoDependency(record)) {
|
|
3415
4258
|
this.blockedWorktreeCleanup(record);
|
|
3416
4259
|
return;
|
|
3417
4260
|
}
|
|
3418
|
-
await this.cleanupRecordWorktreeAsync(
|
|
4261
|
+
await this.cleanupRecordWorktreeAsync(
|
|
4262
|
+
record,
|
|
4263
|
+
record.worktree.repoRoot,
|
|
4264
|
+
record.description,
|
|
4265
|
+
);
|
|
3419
4266
|
} catch (error: unknown) {
|
|
3420
4267
|
// Keep the live worktree reference so the final diagnostic remains
|
|
3421
4268
|
// actionable, while continuing through every sibling and ancestor.
|
|
3422
4269
|
if (record.worktree) {
|
|
3423
|
-
record.worktreeResult = cleanupFailureResult(
|
|
4270
|
+
record.worktreeResult = cleanupFailureResult(
|
|
4271
|
+
record.worktree.repoRoot,
|
|
4272
|
+
record.worktree,
|
|
4273
|
+
error,
|
|
4274
|
+
);
|
|
3424
4275
|
}
|
|
3425
4276
|
}
|
|
3426
4277
|
};
|
|
@@ -3430,7 +4281,8 @@ export class AgentManager {
|
|
|
3430
4281
|
for (const record of orderedRecords) await attemptCleanup(record);
|
|
3431
4282
|
const pruneRepository = async (repo: string): Promise<void> => {
|
|
3432
4283
|
try {
|
|
3433
|
-
if (typeof pruneWorktreesAsync === "function")
|
|
4284
|
+
if (typeof pruneWorktreesAsync === "function")
|
|
4285
|
+
await pruneWorktreesAsync(repo);
|
|
3434
4286
|
else pruneWorktrees(repo);
|
|
3435
4287
|
} catch {
|
|
3436
4288
|
// Pruning is best effort; individual cleanup results retain recovery data.
|
|
@@ -3449,22 +4301,89 @@ export class AgentManager {
|
|
|
3449
4301
|
await pruneRepositories();
|
|
3450
4302
|
|
|
3451
4303
|
const failures = orderedRecords
|
|
3452
|
-
.filter(
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
4304
|
+
.filter(
|
|
4305
|
+
(record): record is AgentRecord & { worktree: WorktreeInfo } =>
|
|
4306
|
+
record.worktree !== undefined,
|
|
4307
|
+
)
|
|
4308
|
+
.map((record) =>
|
|
4309
|
+
snapshotCleanupFailure(
|
|
4310
|
+
record.worktree,
|
|
4311
|
+
record.worktreeResult ?? {
|
|
4312
|
+
hasChanges: false,
|
|
4313
|
+
path: record.worktree.path,
|
|
4314
|
+
cleanupSucceeded: false,
|
|
4315
|
+
cleanupDiagnostic: `Worktree cleanup did not report an outcome for ${record.worktree.path}`,
|
|
4316
|
+
recoveryCommands: worktreeRecoveryCommands(
|
|
4317
|
+
record.worktree.repoRoot,
|
|
4318
|
+
record.worktree.path,
|
|
4319
|
+
),
|
|
4320
|
+
},
|
|
4321
|
+
),
|
|
4322
|
+
);
|
|
3460
4323
|
this.worktreeCleanupFailures = Object.freeze(failures);
|
|
3461
4324
|
if (failures.length > 0) {
|
|
3462
4325
|
console.warn(
|
|
3463
|
-
`[pi-subagents] Worktree cleanup failures:\n${failures
|
|
3464
|
-
|
|
4326
|
+
`[pi-subagents] Worktree cleanup failures:\n${failures
|
|
4327
|
+
.map(
|
|
4328
|
+
(failure) =>
|
|
4329
|
+
`${failure.path}: ${failure.reason} Recovery: ${failure.recoveryCommands.join(" && ")}`,
|
|
4330
|
+
)
|
|
4331
|
+
.join("\n")}`,
|
|
3465
4332
|
);
|
|
3466
4333
|
}
|
|
3467
4334
|
|
|
4335
|
+
if (!teardownsSettled && failures.length > 0) {
|
|
4336
|
+
// Do not make host shutdown wait forever for an extension-owned child
|
|
4337
|
+
// session. Keep worktrees attached until *both* child teardown and all
|
|
4338
|
+
// providers settle; only then retry cleanup, deepest first. Detached
|
|
4339
|
+
// records cannot publish into a replacement session while this waits.
|
|
4340
|
+
const pending = [...this.sessionTeardowns, ...providerPromises];
|
|
4341
|
+
void Promise.allSettled(pending)
|
|
4342
|
+
.then(async (outcomes) => {
|
|
4343
|
+
if (outcomes.some((outcome) => outcome.status === "rejected")) return;
|
|
4344
|
+
const laterTeardowns = await Promise.allSettled([
|
|
4345
|
+
...this.sessionTeardowns,
|
|
4346
|
+
]);
|
|
4347
|
+
if (laterTeardowns.some((outcome) => outcome.status === "rejected"))
|
|
4348
|
+
return;
|
|
4349
|
+
for (const record of orderedRecords) {
|
|
4350
|
+
if (!record.worktree) continue;
|
|
4351
|
+
await this.cleanupRecordWorktreeAsync(
|
|
4352
|
+
record,
|
|
4353
|
+
record.worktree.repoRoot,
|
|
4354
|
+
record.description,
|
|
4355
|
+
);
|
|
4356
|
+
}
|
|
4357
|
+
this.worktreeCleanupFailures = Object.freeze(
|
|
4358
|
+
orderedRecords
|
|
4359
|
+
.filter(
|
|
4360
|
+
(record): record is AgentRecord & { worktree: WorktreeInfo } =>
|
|
4361
|
+
record.worktree !== undefined,
|
|
4362
|
+
)
|
|
4363
|
+
.map((record) =>
|
|
4364
|
+
snapshotCleanupFailure(
|
|
4365
|
+
record.worktree,
|
|
4366
|
+
record.worktreeResult ?? {
|
|
4367
|
+
hasChanges: false,
|
|
4368
|
+
path: record.worktree.path,
|
|
4369
|
+
cleanupSucceeded: false,
|
|
4370
|
+
cleanupDiagnostic: `Worktree cleanup did not report an outcome for ${record.worktree.path}`,
|
|
4371
|
+
recoveryCommands: worktreeRecoveryCommands(
|
|
4372
|
+
record.worktree.repoRoot,
|
|
4373
|
+
record.worktree.path,
|
|
4374
|
+
),
|
|
4375
|
+
},
|
|
4376
|
+
),
|
|
4377
|
+
),
|
|
4378
|
+
);
|
|
4379
|
+
})
|
|
4380
|
+
.catch(() => {
|
|
4381
|
+
console.warn(
|
|
4382
|
+
"[pi-subagents] deferred child worktree cleanup failed; use the retained recovery instructions",
|
|
4383
|
+
);
|
|
4384
|
+
});
|
|
4385
|
+
}
|
|
4386
|
+
|
|
3468
4387
|
// Clear authoritative and ownership metadata only after quarantine, session
|
|
3469
4388
|
// shutdown, and all cleanup attempts. Detached continuations retain their
|
|
3470
4389
|
// record object but cannot find it through this manager or recreate anything.
|