@signalridge/pi-subagents 1.12.0 → 1.14.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 +26 -0
- package/README.md +24 -5
- package/package.json +3 -2
- package/src/agent-manager.ts +368 -64
- package/src/agent-runner.ts +150 -35
- package/src/index.ts +177 -67
- package/src/operation-approvals.ts +49 -0
- package/src/output-file.ts +28 -9
- package/src/types.ts +4 -3
- package/src/ui/conversation-viewer.ts +607 -287
- package/src/ui/fleet-list.ts +154 -29
- package/src/ui/owner-group.ts +12 -4
- package/src/ui/workflow-ui-client.ts +189 -0
package/src/agent-manager.ts
CHANGED
|
@@ -56,7 +56,7 @@ import type {
|
|
|
56
56
|
SubagentType,
|
|
57
57
|
ThinkingLevel,
|
|
58
58
|
} from "./types.js";
|
|
59
|
-
import { addUsage } from "./usage.js";
|
|
59
|
+
import { addUsage, type LifetimeUsage } from "./usage.js";
|
|
60
60
|
import {
|
|
61
61
|
cleanupWorktree,
|
|
62
62
|
cleanupWorktreeAsync,
|
|
@@ -71,6 +71,13 @@ import {
|
|
|
71
71
|
export type OnAgentComplete = (record: AgentRecord) => void;
|
|
72
72
|
export type OnAgentCreated = (record: AgentRecord) => void;
|
|
73
73
|
export type OnAgentStart = (record: AgentRecord) => void;
|
|
74
|
+
export type OnAgentHandoff = (record: AgentRecord) => void;
|
|
75
|
+
|
|
76
|
+
/** Optional UI identity captured before submitting asynchronous steering. */
|
|
77
|
+
export interface SteeringIdentity {
|
|
78
|
+
session: AgentSession;
|
|
79
|
+
owner?: AgentOwner;
|
|
80
|
+
}
|
|
74
81
|
export type OnAgentCompact = (
|
|
75
82
|
record: AgentRecord,
|
|
76
83
|
info: CompactionInfo,
|
|
@@ -1004,6 +1011,20 @@ export type ManagedSpawnPolicy = Pick<
|
|
|
1004
1011
|
policyFingerprint?: string;
|
|
1005
1012
|
};
|
|
1006
1013
|
|
|
1014
|
+
export type ManagedSpawnCallbacks = Pick<
|
|
1015
|
+
SpawnOptions,
|
|
1016
|
+
| "onToolActivity"
|
|
1017
|
+
| "onTextDelta"
|
|
1018
|
+
| "onSessionCreated"
|
|
1019
|
+
| "onTurnEnd"
|
|
1020
|
+
| "onAssistantUsage"
|
|
1021
|
+
| "onCompaction"
|
|
1022
|
+
| "onSpawned"
|
|
1023
|
+
> & {
|
|
1024
|
+
/** Reused thread's actual run boundary, before native steering or prompt. */
|
|
1025
|
+
onResumeStarting?: (record: AgentRecord, startMessageIndex: number) => void;
|
|
1026
|
+
};
|
|
1027
|
+
|
|
1007
1028
|
interface ResumeTerminalSnapshot {
|
|
1008
1029
|
status: AgentRecord["status"];
|
|
1009
1030
|
startedAt: number;
|
|
@@ -1031,8 +1052,32 @@ export class AgentManager {
|
|
|
1031
1052
|
private onCreated?: OnAgentCreated;
|
|
1032
1053
|
private onStart?: OnAgentStart;
|
|
1033
1054
|
private onCompact?: OnAgentCompact;
|
|
1055
|
+
private onHandoff?: OnAgentHandoff;
|
|
1056
|
+
/** Only public, fresh foreground waiters hold this one-way capability. */
|
|
1057
|
+
private readonly foregroundWaiters = new Map<string, {
|
|
1058
|
+
record: AgentRecord;
|
|
1059
|
+
promise: Promise<string>;
|
|
1060
|
+
resolve: () => void;
|
|
1061
|
+
}>();
|
|
1062
|
+
/** Native input handlers may await before writing to their session's queue. */
|
|
1063
|
+
private readonly steeringOperations = new Map<AgentRecord, Set<Promise<void>>>();
|
|
1064
|
+
/** Keep UI provenance without changing legacy/public pendingSteers strings. */
|
|
1065
|
+
private readonly acceptedPendingSteers = new WeakMap<string[], Map<number, {
|
|
1066
|
+
owner: AgentOwner | undefined;
|
|
1067
|
+
controller: AbortController | undefined;
|
|
1068
|
+
}>>();
|
|
1069
|
+
private readonly steeringThreadPins = new Map<string, number>();
|
|
1070
|
+
/** An accepted native queue must not leak into a replacement attempt. */
|
|
1071
|
+
private readonly steeringQueuePins = new WeakMap<AgentRecord, AgentSession>();
|
|
1072
|
+
private readonly steeringQueueObservers = new WeakMap<AgentRecord, () => void>();
|
|
1034
1073
|
private managedSpawns = new Map<string, ManagedSpawnTombstone>();
|
|
1035
1074
|
private managedKeysById = new Map<string, string>();
|
|
1075
|
+
/** Runtime-only attempt baselines; session lifetime counters never reset. */
|
|
1076
|
+
private readonly managedUsageBaselines = new Map<string, {
|
|
1077
|
+
record: AgentRecord;
|
|
1078
|
+
owner: AgentOwner;
|
|
1079
|
+
usage: LifetimeUsage;
|
|
1080
|
+
}>();
|
|
1036
1081
|
/** Spawn keys found in unsupported/corrupt persisted tombstones. */
|
|
1037
1082
|
private managedQuarantinedKeys = new Set<string>();
|
|
1038
1083
|
/**
|
|
@@ -1131,11 +1176,13 @@ export class AgentManager {
|
|
|
1131
1176
|
onCompact?: OnAgentCompact,
|
|
1132
1177
|
onCreated?: OnAgentCreated,
|
|
1133
1178
|
managedPersistence?: ManagedSpawnPersistence,
|
|
1179
|
+
onHandoff?: OnAgentHandoff,
|
|
1134
1180
|
) {
|
|
1135
1181
|
this.onComplete = onComplete;
|
|
1136
1182
|
this.onCreated = onCreated;
|
|
1137
1183
|
this.onStart = onStart;
|
|
1138
1184
|
this.onCompact = onCompact;
|
|
1185
|
+
this.onHandoff = onHandoff;
|
|
1139
1186
|
this.managedPersistence = managedPersistence;
|
|
1140
1187
|
this.maxConcurrent = maxConcurrent;
|
|
1141
1188
|
// Cleanup completed agents after 10 minutes (but keep sessions for resume)
|
|
@@ -1290,12 +1337,22 @@ export class AgentManager {
|
|
|
1290
1337
|
!record.detached &&
|
|
1291
1338
|
!this.removingRecords.has(record.id) &&
|
|
1292
1339
|
!this.settlingRecords.has(record.id) &&
|
|
1340
|
+
!this.providerPendingRecords.has(record.id) &&
|
|
1341
|
+
!this.recordSessionTeardowns.has(record.id) &&
|
|
1342
|
+
!this.hasPendingSteering(record) &&
|
|
1293
1343
|
!this.deferredRecordRemovals.has(record.id) &&
|
|
1294
1344
|
(record.parentAgentId === undefined ||
|
|
1295
1345
|
this.nestedOwnerValidation(record.parentAgentId) === undefined)
|
|
1296
1346
|
);
|
|
1297
1347
|
}
|
|
1298
1348
|
|
|
1349
|
+
private canResumeRecord(record: AgentRecord): boolean {
|
|
1350
|
+
return this.canResumeNested(record) &&
|
|
1351
|
+
!this.resumeControls.has(record.id) &&
|
|
1352
|
+
record.status !== "running" && record.status !== "queued" &&
|
|
1353
|
+
record.session !== undefined;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1299
1356
|
private clearManagedPersistenceRetry(key: string): void {
|
|
1300
1357
|
const pending = this.managedPersistenceRetries.get(key);
|
|
1301
1358
|
if (!pending) return;
|
|
@@ -1413,6 +1470,7 @@ export class AgentManager {
|
|
|
1413
1470
|
const state: ManagedSpawnState =
|
|
1414
1471
|
terminalStatus ?? (record.status === "queued" ? "queued" : "running");
|
|
1415
1472
|
const compactionCount = Math.max(0, Math.floor(record.compactionCount));
|
|
1473
|
+
const usage = this.getManagedUsage(record.id, tombstone.owner);
|
|
1416
1474
|
const terminal = terminalStatus
|
|
1417
1475
|
? {
|
|
1418
1476
|
status: terminalStatus,
|
|
@@ -1425,10 +1483,10 @@ export class AgentManager {
|
|
|
1425
1483
|
...(record.outputFile
|
|
1426
1484
|
? { outputFile: record.outputFile.slice(0, MANAGED_PATH_LIMIT) }
|
|
1427
1485
|
: {}),
|
|
1428
|
-
...(
|
|
1486
|
+
...(usage
|
|
1429
1487
|
? {
|
|
1430
1488
|
tokenCount: Math.floor(
|
|
1431
|
-
|
|
1489
|
+
usage.input + usage.output + usage.cacheWrite,
|
|
1432
1490
|
),
|
|
1433
1491
|
}
|
|
1434
1492
|
: {}),
|
|
@@ -1595,6 +1653,23 @@ export class AgentManager {
|
|
|
1595
1653
|
return recovered;
|
|
1596
1654
|
}
|
|
1597
1655
|
|
|
1656
|
+
/** Fresh per-attempt usage, available only for the current exact live owner. */
|
|
1657
|
+
getManagedUsage(id: string, owner: AgentOwner): LifetimeUsage | undefined {
|
|
1658
|
+
const baseline = this.managedUsageBaselines.get(id);
|
|
1659
|
+
const record = this.agents.get(id);
|
|
1660
|
+
if (!baseline || !record || record.detached || this.disposed ||
|
|
1661
|
+
baseline.record !== record || baseline.owner !== record.owner ||
|
|
1662
|
+
baseline.owner.extension !== owner.extension ||
|
|
1663
|
+
baseline.owner.runId !== owner.runId ||
|
|
1664
|
+
baseline.owner.nodeId !== owner.nodeId ||
|
|
1665
|
+
baseline.owner.attemptId !== owner.attemptId) return undefined;
|
|
1666
|
+
const delta = (component: keyof LifetimeUsage): number => {
|
|
1667
|
+
const value = record.lifetimeUsage[component] - baseline.usage[component];
|
|
1668
|
+
return Number.isFinite(value) ? Math.max(0, value) : 0;
|
|
1669
|
+
};
|
|
1670
|
+
return { input: delta("input"), output: delta("output"), cacheWrite: delta("cacheWrite") };
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1598
1673
|
getManagedSpawn(spawnKey: string): ManagedSpawnTombstone | undefined {
|
|
1599
1674
|
const tombstone = this.managedSpawns.get(spawnKey.trim());
|
|
1600
1675
|
return tombstone ? cloneManagedTombstone(tombstone) : undefined;
|
|
@@ -1810,6 +1885,10 @@ export class AgentManager {
|
|
|
1810
1885
|
record.handle = options.reclaimHandle;
|
|
1811
1886
|
}
|
|
1812
1887
|
this.agents.set(id, record);
|
|
1888
|
+
// Capture before observers or a synchronous runner can report early usage.
|
|
1889
|
+
if (record.owner) this.managedUsageBaselines.set(id, {
|
|
1890
|
+
record, owner: record.owner, usage: { ...record.lifetimeUsage },
|
|
1891
|
+
});
|
|
1813
1892
|
if (!record.detached) {
|
|
1814
1893
|
try {
|
|
1815
1894
|
this.onCreated?.(record);
|
|
@@ -1918,16 +1997,7 @@ export class AgentManager {
|
|
|
1918
1997
|
ctx: ExtensionContext,
|
|
1919
1998
|
request: ManagedSpawnRequest,
|
|
1920
1999
|
policy: ManagedSpawnPolicy,
|
|
1921
|
-
callbacks?:
|
|
1922
|
-
SpawnOptions,
|
|
1923
|
-
| "onToolActivity"
|
|
1924
|
-
| "onTextDelta"
|
|
1925
|
-
| "onSessionCreated"
|
|
1926
|
-
| "onTurnEnd"
|
|
1927
|
-
| "onAssistantUsage"
|
|
1928
|
-
| "onCompaction"
|
|
1929
|
-
| "onSpawned"
|
|
1930
|
-
>,
|
|
2000
|
+
callbacks?: ManagedSpawnCallbacks,
|
|
1931
2001
|
): ManagedSpawnResult {
|
|
1932
2002
|
if (this.disposed) throw new Error("AgentManager is disposed");
|
|
1933
2003
|
const normalized = parseManagedSpawnRequest(request);
|
|
@@ -2014,6 +2084,9 @@ export class AgentManager {
|
|
|
2014
2084
|
const threadKey = thread
|
|
2015
2085
|
? `${normalized.owner.runId}\u0000${thread}`
|
|
2016
2086
|
: undefined;
|
|
2087
|
+
if (threadKey && this.steeringThreadPins.has(threadKey)) {
|
|
2088
|
+
throw new Error(`Managed workflow thread "${thread}" has pending UI steering; retry after it settles.`);
|
|
2089
|
+
}
|
|
2017
2090
|
if (threadKey && this.managedThreadReservations.has(threadKey)) {
|
|
2018
2091
|
throw new Error(
|
|
2019
2092
|
`Managed workflow thread "${thread}" is already running; calls must be sequential.`,
|
|
@@ -2030,21 +2103,34 @@ export class AgentManager {
|
|
|
2030
2103
|
throw new Error(threadPolicyConflictMessage(thread, identityTier));
|
|
2031
2104
|
}
|
|
2032
2105
|
const record = this.agents.get(threadedId);
|
|
2106
|
+
if (record && this.hasPendingSteering(record)) {
|
|
2107
|
+
throw new Error(`Managed workflow thread "${thread}" has pending UI steering; retry after it settles.`);
|
|
2108
|
+
}
|
|
2033
2109
|
if (
|
|
2034
2110
|
record?.session &&
|
|
2035
2111
|
record.status !== "running" &&
|
|
2036
2112
|
record.status !== "queued" &&
|
|
2037
2113
|
!record.detached
|
|
2038
2114
|
) {
|
|
2115
|
+
// Admission must precede every attempt identity and persistence mutation.
|
|
2116
|
+
// A stopped status alone says nothing about provider/session quiescence.
|
|
2117
|
+
if (!this.canResumeRecord(record)) {
|
|
2118
|
+
throw new Error(`Managed workflow thread "${thread}" is still settling and is not resumable; retry after it settles.`);
|
|
2119
|
+
}
|
|
2039
2120
|
assertSafeChildSession(
|
|
2040
2121
|
record.session,
|
|
2041
2122
|
record.invocation?.inheritContext === true,
|
|
2042
2123
|
);
|
|
2043
2124
|
const previousOwner = record.owner;
|
|
2125
|
+
const previousUsageBaseline = this.managedUsageBaselines.get(threadedId);
|
|
2044
2126
|
const previousInvocation = record.invocation;
|
|
2045
2127
|
const previousManagedKey = this.managedKeysById.get(threadedId);
|
|
2046
2128
|
const resumedTier = identityTier ?? previousInvocation?.agentTier;
|
|
2047
2129
|
record.owner = Object.freeze({ ...normalized.owner });
|
|
2130
|
+
// Owner rotation, not resume start/settlement, is the attempt boundary.
|
|
2131
|
+
this.managedUsageBaselines.set(threadedId, {
|
|
2132
|
+
record, owner: record.owner, usage: { ...record.lifetimeUsage },
|
|
2133
|
+
});
|
|
2048
2134
|
record.invocation = {
|
|
2049
2135
|
...(policy.invocation ?? {}),
|
|
2050
2136
|
...(resumedTier === undefined ? {} : { agentTier: resumedTier }),
|
|
@@ -2083,11 +2169,16 @@ export class AgentManager {
|
|
|
2083
2169
|
this.managedKeysById.delete(threadedId);
|
|
2084
2170
|
else this.managedKeysById.set(threadedId, previousManagedKey);
|
|
2085
2171
|
record.owner = previousOwner;
|
|
2172
|
+
if (previousUsageBaseline) this.managedUsageBaselines.set(threadedId, previousUsageBaseline);
|
|
2173
|
+
else this.managedUsageBaselines.delete(threadedId);
|
|
2086
2174
|
record.invocation = previousInvocation;
|
|
2087
2175
|
throw error;
|
|
2088
2176
|
}
|
|
2089
2177
|
void this.resume(threadedId, normalized.prompt, undefined, {
|
|
2090
2178
|
isBackground: true,
|
|
2179
|
+
onStarted: () => {
|
|
2180
|
+
void Promise.resolve(callbacks?.onResumeStarting?.(record, record.session!.messages.length)).catch(() => {});
|
|
2181
|
+
},
|
|
2091
2182
|
onToolActivity: callbacks?.onToolActivity,
|
|
2092
2183
|
onAssistantUsage: callbacks?.onAssistantUsage,
|
|
2093
2184
|
onCompaction: callbacks?.onCompaction
|
|
@@ -2112,6 +2203,9 @@ export class AgentManager {
|
|
|
2112
2203
|
`Managed workflow thread "${thread}" is already running; calls must be sequential.`,
|
|
2113
2204
|
);
|
|
2114
2205
|
}
|
|
2206
|
+
if (record && !this.isFullyCleaned(record)) {
|
|
2207
|
+
throw new Error(`Managed workflow thread "${thread}" is still settling and is not resumable; retry after it settles.`);
|
|
2208
|
+
}
|
|
2115
2209
|
if (!record || record.detached || !record.session) {
|
|
2116
2210
|
this.managedThreads.delete(threadKey!);
|
|
2117
2211
|
this.managedThreadPolicies.delete(threadKey!);
|
|
@@ -2416,11 +2510,8 @@ export class AgentManager {
|
|
|
2416
2510
|
session.sessionManager?.getSessionFile?.() ??
|
|
2417
2511
|
(session as { sessionFile?: string })?.sessionFile;
|
|
2418
2512
|
// Flush any steers that arrived before the session was ready
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
session.steer(msg).catch(() => {});
|
|
2422
|
-
}
|
|
2423
|
-
record.pendingSteers = undefined;
|
|
2513
|
+
for (const pending of this.takePendingSteers(record)) {
|
|
2514
|
+
void this.submitPendingSteer(record, session, pending).catch(() => {});
|
|
2424
2515
|
}
|
|
2425
2516
|
options.onSessionCreated?.(session);
|
|
2426
2517
|
},
|
|
@@ -2496,7 +2587,7 @@ export class AgentManager {
|
|
|
2496
2587
|
await this.awaitSessionTeardown(session);
|
|
2497
2588
|
this.cleanupDetachedWorktree(record);
|
|
2498
2589
|
if (record.session === session) record.session = undefined;
|
|
2499
|
-
if (
|
|
2590
|
+
if (record.isBackground) {
|
|
2500
2591
|
this.releasePoolSlot(record.id);
|
|
2501
2592
|
this.drainQueue();
|
|
2502
2593
|
}
|
|
@@ -2521,7 +2612,7 @@ export class AgentManager {
|
|
|
2521
2612
|
await this.awaitSessionTeardown(session);
|
|
2522
2613
|
this.cleanupDetachedWorktree(record);
|
|
2523
2614
|
if (record.session === session) record.session = undefined;
|
|
2524
|
-
if (
|
|
2615
|
+
if (record.isBackground) {
|
|
2525
2616
|
this.releasePoolSlot(record.id);
|
|
2526
2617
|
this.drainQueue();
|
|
2527
2618
|
}
|
|
@@ -2556,7 +2647,7 @@ export class AgentManager {
|
|
|
2556
2647
|
|
|
2557
2648
|
// Fire onComplete for foreground agents too — lifecycle symmetry.
|
|
2558
2649
|
// Mark resultConsumed so the callback skips notifications (result returned inline).
|
|
2559
|
-
if (!
|
|
2650
|
+
if (!record.isBackground) {
|
|
2560
2651
|
record.resultConsumed = true;
|
|
2561
2652
|
this.notifyComplete(record);
|
|
2562
2653
|
} else {
|
|
@@ -2602,7 +2693,7 @@ export class AgentManager {
|
|
|
2602
2693
|
if (record.detached) {
|
|
2603
2694
|
await this.awaitSessionTeardown(record.session);
|
|
2604
2695
|
this.cleanupDetachedWorktree(record);
|
|
2605
|
-
if (
|
|
2696
|
+
if (record.isBackground) {
|
|
2606
2697
|
this.releasePoolSlot(record.id);
|
|
2607
2698
|
this.drainQueue();
|
|
2608
2699
|
}
|
|
@@ -2620,7 +2711,7 @@ export class AgentManager {
|
|
|
2620
2711
|
if (record.detached) {
|
|
2621
2712
|
await this.awaitSessionTeardown(record.session);
|
|
2622
2713
|
this.cleanupDetachedWorktree(record);
|
|
2623
|
-
if (
|
|
2714
|
+
if (record.isBackground) {
|
|
2624
2715
|
this.releasePoolSlot(record.id);
|
|
2625
2716
|
this.drainQueue();
|
|
2626
2717
|
}
|
|
@@ -2630,7 +2721,7 @@ export class AgentManager {
|
|
|
2630
2721
|
|
|
2631
2722
|
// Fire onComplete for foreground agents too — lifecycle symmetry.
|
|
2632
2723
|
// Mark resultConsumed so the callback skips notifications (result returned inline).
|
|
2633
|
-
if (!
|
|
2724
|
+
if (!record.isBackground) {
|
|
2634
2725
|
record.resultConsumed = true;
|
|
2635
2726
|
this.notifyComplete(record);
|
|
2636
2727
|
} else {
|
|
@@ -2794,7 +2885,7 @@ export class AgentManager {
|
|
|
2794
2885
|
prompt: string,
|
|
2795
2886
|
options: Omit<SpawnOptions, "isBackground">,
|
|
2796
2887
|
onSpawned?: (id: string) => void,
|
|
2797
|
-
): Promise<{ id: string; record: AgentRecord }> {
|
|
2888
|
+
): Promise<{ id: string; record: AgentRecord; handedOff?: boolean }> {
|
|
2798
2889
|
return this.spawnAndWaitCore(pi, ctx, type, prompt, options, onSpawned);
|
|
2799
2890
|
}
|
|
2800
2891
|
|
|
@@ -2835,7 +2926,7 @@ export class AgentManager {
|
|
|
2835
2926
|
options: Omit<SpawnOptions, "isBackground">,
|
|
2836
2927
|
onSpawned?: (id: string) => void,
|
|
2837
2928
|
internalOverride?: InternalAgentConfigOverride,
|
|
2838
|
-
): Promise<{ id: string; record: AgentRecord }> {
|
|
2929
|
+
): Promise<{ id: string; record: AgentRecord; handedOff?: boolean }> {
|
|
2839
2930
|
// Temporarily register the onSpawned hook so startAgent can call it.
|
|
2840
2931
|
const prevOnSpawned = this.onSpawned;
|
|
2841
2932
|
this.onSpawned = onSpawned;
|
|
@@ -2858,8 +2949,69 @@ export class AgentManager {
|
|
|
2858
2949
|
this.onSpawned = prevOnSpawned;
|
|
2859
2950
|
}
|
|
2860
2951
|
const record = this.agents.get(id)!;
|
|
2861
|
-
|
|
2862
|
-
|
|
2952
|
+
if (internalOverride || options.resumeSessionFile || options.parentAgentId !== undefined || (options.depth ?? 1) !== 1 || !record.promise) {
|
|
2953
|
+
await record.promise;
|
|
2954
|
+
return { id, record };
|
|
2955
|
+
}
|
|
2956
|
+
let resolveHandoff!: () => void;
|
|
2957
|
+
const handedOff = new Promise<true>((resolve) => {
|
|
2958
|
+
resolveHandoff = () => resolve(true);
|
|
2959
|
+
});
|
|
2960
|
+
const waiter = { record, promise: record.promise, resolve: resolveHandoff };
|
|
2961
|
+
this.foregroundWaiters.set(id, waiter);
|
|
2962
|
+
try {
|
|
2963
|
+
const transferred = await Promise.race([
|
|
2964
|
+
record.promise.then(() => false),
|
|
2965
|
+
handedOff,
|
|
2966
|
+
]);
|
|
2967
|
+
return { id, record, ...(transferred ? { handedOff: true } : {}) };
|
|
2968
|
+
} finally {
|
|
2969
|
+
if (this.foregroundWaiters.get(id) === waiter) this.foregroundWaiters.delete(id);
|
|
2970
|
+
}
|
|
2971
|
+
}
|
|
2972
|
+
|
|
2973
|
+
/** A diagnostic suitable for the UI; undefined means transfer is available. */
|
|
2974
|
+
handoffUnavailableReason(id: string): string | undefined {
|
|
2975
|
+
const record = this.agents.get(id);
|
|
2976
|
+
const waiter = this.foregroundWaiters.get(id);
|
|
2977
|
+
if (this.disposed || !record || record.detached || record.status !== "running")
|
|
2978
|
+
return "Agent is no longer running.";
|
|
2979
|
+
if (!waiter || waiter.record !== record || waiter.promise !== record.promise ||
|
|
2980
|
+
record.isBackground !== false || record.owner || record.parentAgentId !== undefined ||
|
|
2981
|
+
(record.depth ?? 1) !== 1 || this.resumeControls.has(id))
|
|
2982
|
+
return "Only a fresh top-level foreground Agent call can be handed off.";
|
|
2983
|
+
if (!record.session || !this.providerPendingRecords.has(id) || record.abortController?.signal.aborted)
|
|
2984
|
+
return "Agent is not ready for handoff or has already settled.";
|
|
2985
|
+
if (this.runningBackground >= this.maxConcurrent)
|
|
2986
|
+
return `Background pool is full (${this.runningBackground}/${this.maxConcurrent}); foreground Agent remains running.`;
|
|
2987
|
+
return undefined;
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2990
|
+
canHandoff(id: string): boolean {
|
|
2991
|
+
return this.handoffUnavailableReason(id) === undefined;
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
/** Transfer the live run, never its provider promise, session or worktree. */
|
|
2995
|
+
handoff(id: string): boolean {
|
|
2996
|
+
if (!this.canHandoff(id)) return false;
|
|
2997
|
+
const waiter = this.foregroundWaiters.get(id)!;
|
|
2998
|
+
const record = waiter.record;
|
|
2999
|
+
// Cut parent cancellation BEFORE releasing its waiter. Explicit child stop
|
|
3000
|
+
// and manager shutdown retain the child's own controller.
|
|
3001
|
+
this.clearParentSignal(id);
|
|
3002
|
+
this.heldPoolSlots.add(id);
|
|
3003
|
+
this.runningBackground++;
|
|
3004
|
+
record.isBackground = true;
|
|
3005
|
+
record.invocation = { ...(record.invocation ?? {}), runInBackground: true };
|
|
3006
|
+
record.resultConsumed = false;
|
|
3007
|
+
this.foregroundWaiters.delete(id);
|
|
3008
|
+
waiter.resolve();
|
|
3009
|
+
try {
|
|
3010
|
+
void Promise.resolve(this.onHandoff?.(record)).catch(() => {});
|
|
3011
|
+
} catch {
|
|
3012
|
+
// This callback observes a committed transfer, never controls it.
|
|
3013
|
+
}
|
|
3014
|
+
return true;
|
|
2863
3015
|
}
|
|
2864
3016
|
|
|
2865
3017
|
private resumeSnapshot(record: AgentRecord): ResumeTerminalSnapshot {
|
|
@@ -2961,14 +3113,8 @@ export class AgentManager {
|
|
|
2961
3113
|
if (this.disposed) return undefined;
|
|
2962
3114
|
const record = this.agents.get(id);
|
|
2963
3115
|
if (!record) return undefined;
|
|
2964
|
-
if (!this.
|
|
3116
|
+
if (!this.canResumeRecord(record)) return undefined;
|
|
2965
3117
|
if (signal?.aborted) return record;
|
|
2966
|
-
if (
|
|
2967
|
-
this.resumeControls.has(id) ||
|
|
2968
|
-
record.status === "running" ||
|
|
2969
|
-
record.status === "queued"
|
|
2970
|
-
)
|
|
2971
|
-
return undefined;
|
|
2972
3118
|
if (!record.session) return undefined;
|
|
2973
3119
|
if (this.parentQuestionAgents.has(record) && options?.isBackground !== true) {
|
|
2974
3120
|
throw new Error('This child uses parent clarification; resume it with run_in_background: true to avoid blocking the answering model.');
|
|
@@ -3149,16 +3295,12 @@ export class AgentManager {
|
|
|
3149
3295
|
// sent while it waits must be flushed here rather than through the
|
|
3150
3296
|
// session's idle steering queue. Otherwise cancelling this queued run
|
|
3151
3297
|
// would leave those messages behind for a later resume.
|
|
3152
|
-
const
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
await record.session!.steer(message);
|
|
3159
|
-
} catch {
|
|
3160
|
-
// A malformed or stale queued message must not prevent the resume.
|
|
3161
|
-
}
|
|
3298
|
+
for (const pending of this.takePendingSteers(record)) {
|
|
3299
|
+
if (control.controller.signal.aborted) break;
|
|
3300
|
+
try {
|
|
3301
|
+
await this.submitPendingSteer(record, record.session!, pending);
|
|
3302
|
+
} catch {
|
|
3303
|
+
// A malformed or stale queued message must not prevent the resume.
|
|
3162
3304
|
}
|
|
3163
3305
|
}
|
|
3164
3306
|
|
|
@@ -3246,11 +3388,139 @@ export class AgentManager {
|
|
|
3246
3388
|
if (!record.pendingSteers) record.pendingSteers = [];
|
|
3247
3389
|
record.pendingSteers.push(message);
|
|
3248
3390
|
} else {
|
|
3391
|
+
this.retireEmptySteeringQueue(record, record.session);
|
|
3249
3392
|
record.session.steer(message).catch(() => {});
|
|
3250
3393
|
}
|
|
3251
3394
|
return true;
|
|
3252
3395
|
}
|
|
3253
3396
|
|
|
3397
|
+
private takePendingSteers(record: AgentRecord) {
|
|
3398
|
+
const messages = record.pendingSteers;
|
|
3399
|
+
record.pendingSteers = undefined;
|
|
3400
|
+
const accepted = messages ? this.acceptedPendingSteers.get(messages) : undefined;
|
|
3401
|
+
return (messages ?? []).map((message, index) => ({ message, acceptance: accepted?.get(index) }));
|
|
3402
|
+
}
|
|
3403
|
+
|
|
3404
|
+
private async submitPendingSteer(
|
|
3405
|
+
record: AgentRecord,
|
|
3406
|
+
session: AgentSession,
|
|
3407
|
+
pending: ReturnType<AgentManager["takePendingSteers"]>[number],
|
|
3408
|
+
): Promise<void> {
|
|
3409
|
+
const { message, acceptance } = pending;
|
|
3410
|
+
if (!acceptance) {
|
|
3411
|
+
// Preserve optimistic legacy boolean steering, including its queue lifetime.
|
|
3412
|
+
this.retireEmptySteeringQueue(record, session);
|
|
3413
|
+
await session.steer(message);
|
|
3414
|
+
return;
|
|
3415
|
+
}
|
|
3416
|
+
if (this.disposed || record.detached || this.agents.get(record.id) !== record ||
|
|
3417
|
+
record.status !== "running" || record.session !== session ||
|
|
3418
|
+
record.owner !== acceptance.owner || record.abortController !== acceptance.controller ||
|
|
3419
|
+
acceptance.controller?.signal.aborted) return;
|
|
3420
|
+
await this.steerNativeAccepted(record, session, message);
|
|
3421
|
+
}
|
|
3422
|
+
|
|
3423
|
+
/** Queue provenance ends at an observed empty queue, not at a record's lifetime. */
|
|
3424
|
+
private retireEmptySteeringQueue(record: AgentRecord, session = record.session ?? this.steeringQueuePins.get(record)): void {
|
|
3425
|
+
if (session?.getSteeringMessages?.().length !== 0) return;
|
|
3426
|
+
this.steeringQueuePins.delete(record);
|
|
3427
|
+
// An empty queue cannot release an input handler still awaiting acceptance.
|
|
3428
|
+
// Its eventual native write will establish fresh queue provenance.
|
|
3429
|
+
if (this.steeringOperations.has(record)) return;
|
|
3430
|
+
const unsubscribe = this.steeringQueueObservers.get(record);
|
|
3431
|
+
this.steeringQueueObservers.delete(record);
|
|
3432
|
+
// SDK dispatch visits a live listener array. Retire ownership now, but
|
|
3433
|
+
// defer removal even when a later subscriber reenters the manager.
|
|
3434
|
+
// Capture the old observer so a replacement installed now stays intact.
|
|
3435
|
+
if (unsubscribe) queueMicrotask(unsubscribe);
|
|
3436
|
+
}
|
|
3437
|
+
|
|
3438
|
+
private hasPendingSteering(record: AgentRecord): boolean {
|
|
3439
|
+
this.retireEmptySteeringQueue(record);
|
|
3440
|
+
return this.steeringOperations.has(record) ||
|
|
3441
|
+
(this.steeringQueuePins.get(record)?.getSteeringMessages?.().length ?? 0) > 0;
|
|
3442
|
+
}
|
|
3443
|
+
|
|
3444
|
+
/**
|
|
3445
|
+
* Truthful UI submission. False means stale/inactive identity; native input
|
|
3446
|
+
* rejection propagates to the caller. Queued messages remain manager-owned.
|
|
3447
|
+
* Native steer awaits input handlers BEFORE enqueueing, even if its run ends
|
|
3448
|
+
* meanwhile. Pin both record and managed thread until that await settles;
|
|
3449
|
+
* an accepted queue also pins reuse until the SDK consumes it. Never rewrite
|
|
3450
|
+
* private native queues or clear unrelated legacy callers' messages.
|
|
3451
|
+
*/
|
|
3452
|
+
async steerAccepted(id: string, message: string, expected?: SteeringIdentity): Promise<boolean> {
|
|
3453
|
+
const record = this.agents.get(id);
|
|
3454
|
+
if (this.disposed || !record || record.detached ||
|
|
3455
|
+
(record.status !== "running" && record.status !== "queued")) return false;
|
|
3456
|
+
const session = record.session;
|
|
3457
|
+
const owner = record.owner;
|
|
3458
|
+
if ((expected !== undefined && expected.session !== session) ||
|
|
3459
|
+
(expected?.owner !== undefined && (!owner ||
|
|
3460
|
+
expected.owner.extension !== owner.extension || expected.owner.runId !== owner.runId ||
|
|
3461
|
+
expected.owner.nodeId !== owner.nodeId || expected.owner.attemptId !== owner.attemptId))) return false;
|
|
3462
|
+
if (record.status === "queued" || !session) {
|
|
3463
|
+
record.pendingSteers ??= [];
|
|
3464
|
+
let accepted = this.acceptedPendingSteers.get(record.pendingSteers);
|
|
3465
|
+
if (!accepted) {
|
|
3466
|
+
accepted = new Map();
|
|
3467
|
+
this.acceptedPendingSteers.set(record.pendingSteers, accepted);
|
|
3468
|
+
}
|
|
3469
|
+
accepted.set(record.pendingSteers.length, { owner, controller: record.abortController });
|
|
3470
|
+
record.pendingSteers.push(message);
|
|
3471
|
+
return true;
|
|
3472
|
+
}
|
|
3473
|
+
return this.steerNativeAccepted(record, session, message);
|
|
3474
|
+
}
|
|
3475
|
+
|
|
3476
|
+
/** Every accepted native submission, including both queued flushes, owns its await. */
|
|
3477
|
+
private async steerNativeAccepted(record: AgentRecord, session: AgentSession, message: string): Promise<boolean> {
|
|
3478
|
+
this.retireEmptySteeringQueue(record, session);
|
|
3479
|
+
const id = record.id;
|
|
3480
|
+
const owner = record.owner;
|
|
3481
|
+
const promise = record.promise;
|
|
3482
|
+
const controller = record.abortController;
|
|
3483
|
+
const thread = this.managedSpawns.get(this.managedKeyForId(id) ?? "")?.thread;
|
|
3484
|
+
const threadKey = owner && thread ? `${owner.runId}\u0000${thread}` : undefined;
|
|
3485
|
+
let settle!: () => void;
|
|
3486
|
+
const settled = new Promise<void>(resolve => { settle = resolve; });
|
|
3487
|
+
let operations = this.steeringOperations.get(record);
|
|
3488
|
+
if (!operations) {
|
|
3489
|
+
operations = new Set();
|
|
3490
|
+
this.steeringOperations.set(record, operations);
|
|
3491
|
+
}
|
|
3492
|
+
operations.add(settled);
|
|
3493
|
+
if (threadKey) this.steeringThreadPins.set(threadKey, (this.steeringThreadPins.get(threadKey) ?? 0) + 1);
|
|
3494
|
+
try {
|
|
3495
|
+
if (!this.steeringQueueObservers.has(record) && session.subscribe) {
|
|
3496
|
+
this.steeringQueueObservers.set(record, session.subscribe(event => {
|
|
3497
|
+
if (event.type !== "queue_update" || event.steering.length !== 0) return;
|
|
3498
|
+
this.retireEmptySteeringQueue(record, session);
|
|
3499
|
+
}));
|
|
3500
|
+
}
|
|
3501
|
+
const disposition = await session.steer(message);
|
|
3502
|
+
// Handled input writes nothing: a nonempty queue can belong entirely to
|
|
3503
|
+
// legacy steering. Keep existing accepted pins without acquiring one.
|
|
3504
|
+
if (disposition === "queued" && (session.getSteeringMessages?.().length ?? 0) > 0)
|
|
3505
|
+
this.steeringQueuePins.set(record, session);
|
|
3506
|
+
return !this.disposed && !record.detached && this.agents.get(id) === record &&
|
|
3507
|
+
record.session === session && record.owner === owner && record.promise === promise &&
|
|
3508
|
+
record.abortController === controller && record.status === "running";
|
|
3509
|
+
} finally {
|
|
3510
|
+
operations.delete(settled);
|
|
3511
|
+
if (operations.size === 0) this.steeringOperations.delete(record);
|
|
3512
|
+
this.retireEmptySteeringQueue(record, session);
|
|
3513
|
+
if (threadKey) {
|
|
3514
|
+
const pendingThread = this.steeringThreadPins.get(threadKey)! - 1;
|
|
3515
|
+
if (pendingThread > 0) this.steeringThreadPins.set(threadKey, pendingThread);
|
|
3516
|
+
else this.steeringThreadPins.delete(threadKey);
|
|
3517
|
+
}
|
|
3518
|
+
settle();
|
|
3519
|
+
this.retryPinnedWorktreeCleanup();
|
|
3520
|
+
this.retryDeferredRecordRemovals();
|
|
3521
|
+
}
|
|
3522
|
+
}
|
|
3523
|
+
|
|
3254
3524
|
/**
|
|
3255
3525
|
* Return an immutable observation snapshot. Internal manager/index code must
|
|
3256
3526
|
* use `getRecordMutable()` when it needs to update authoritative state.
|
|
@@ -3423,6 +3693,7 @@ export class AgentManager {
|
|
|
3423
3693
|
record.status !== "running" &&
|
|
3424
3694
|
!this.settlingRecords.has(record.id) &&
|
|
3425
3695
|
!this.providerPendingRecords.has(record.id) &&
|
|
3696
|
+
!this.hasPendingSteering(record) &&
|
|
3426
3697
|
!this.recordSessionTeardowns.has(record.id) &&
|
|
3427
3698
|
record.worktree === undefined &&
|
|
3428
3699
|
record.outputCleanup === undefined
|
|
@@ -3543,6 +3814,7 @@ export class AgentManager {
|
|
|
3543
3814
|
this.managedThreadReservations.delete(thread);
|
|
3544
3815
|
}
|
|
3545
3816
|
this.agents.delete(id);
|
|
3817
|
+
this.managedUsageBaselines.delete(id);
|
|
3546
3818
|
return true;
|
|
3547
3819
|
} finally {
|
|
3548
3820
|
// The map deletion is the point at which a later continuation becomes an
|
|
@@ -3868,6 +4140,14 @@ export class AgentManager {
|
|
|
3868
4140
|
const worktree = record.worktree;
|
|
3869
4141
|
if (!worktree) return undefined;
|
|
3870
4142
|
|
|
4143
|
+
// Queue pins guard reuse/eviction, not cwd lifetime. Only native handlers
|
|
4144
|
+
// still executing can touch the checkout after the provider has ended.
|
|
4145
|
+
if (this.steeringOperations.has(record)) {
|
|
4146
|
+
return this.blockedWorktreeCleanup(
|
|
4147
|
+
record,
|
|
4148
|
+
`Worktree cleanup is pinned because native input handling for ${record.id} is still pending`,
|
|
4149
|
+
);
|
|
4150
|
+
}
|
|
3871
4151
|
if (this.providerPendingRecords.has(record.id)) {
|
|
3872
4152
|
return this.blockedWorktreeCleanup(
|
|
3873
4153
|
record,
|
|
@@ -3916,6 +4196,12 @@ export class AgentManager {
|
|
|
3916
4196
|
const worktree = record.worktree;
|
|
3917
4197
|
if (!worktree) return undefined;
|
|
3918
4198
|
|
|
4199
|
+
if (this.steeringOperations.has(record)) {
|
|
4200
|
+
return this.blockedWorktreeCleanup(
|
|
4201
|
+
record,
|
|
4202
|
+
`Worktree cleanup is pinned because native input handling for ${record.id} is still pending`,
|
|
4203
|
+
);
|
|
4204
|
+
}
|
|
3919
4205
|
if (this.providerPendingRecords.has(record.id)) {
|
|
3920
4206
|
return this.blockedWorktreeCleanup(
|
|
3921
4207
|
record,
|
|
@@ -4021,19 +4307,35 @@ export class AgentManager {
|
|
|
4021
4307
|
if (this.isFullyCleaned(record)) pendingIds.delete(record.id);
|
|
4022
4308
|
};
|
|
4023
4309
|
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
4024
|
-
const
|
|
4025
|
-
|
|
4026
|
-
|
|
4027
|
-
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
4310
|
+
const observed = new Set<Promise<unknown>>();
|
|
4311
|
+
let completed = true;
|
|
4312
|
+
while (pendingIds.size > 0) {
|
|
4313
|
+
// These exact records own native input awaits independently of their
|
|
4314
|
+
// already-settled provider promises. Recollect after settlement to cover
|
|
4315
|
+
// queued flushes admitted during the wait, using one shared deadline.
|
|
4316
|
+
const pendingPromises: Promise<unknown>[] = [];
|
|
4317
|
+
for (const record of records) {
|
|
4318
|
+
if (!pendingIds.has(record.id)) continue;
|
|
4319
|
+
for (const promise of [record.promise, ...(this.steeringOperations.get(record) ?? [])]) {
|
|
4320
|
+
if (!promise || observed.has(promise)) continue;
|
|
4321
|
+
observed.add(promise);
|
|
4322
|
+
pendingPromises.push(promise);
|
|
4323
|
+
}
|
|
4324
|
+
}
|
|
4325
|
+
if (pendingPromises.length === 0) break;
|
|
4326
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
4327
|
+
const allSettled = Promise.allSettled(pendingPromises).then(() => true);
|
|
4328
|
+
const timedOut = new Promise<boolean>((resolve) => {
|
|
4329
|
+
timer = setTimeout(
|
|
4330
|
+
() => resolve(false),
|
|
4331
|
+
Math.max(0, deadline - Date.now()),
|
|
4332
|
+
);
|
|
4333
|
+
});
|
|
4334
|
+
completed = await Promise.race([allSettled, timedOut]);
|
|
4335
|
+
if (timer) clearTimeout(timer);
|
|
4336
|
+
for (const record of records) recheck(record);
|
|
4337
|
+
if (!completed) break;
|
|
4338
|
+
}
|
|
4037
4339
|
|
|
4038
4340
|
let pending = [...pendingIds];
|
|
4039
4341
|
if (!completed || pending.length > 0) {
|
|
@@ -4167,6 +4469,7 @@ export class AgentManager {
|
|
|
4167
4469
|
this.clearManagedPersistenceRetries();
|
|
4168
4470
|
if (this.disposePromise) return this.disposePromise;
|
|
4169
4471
|
this.disposed = true;
|
|
4472
|
+
this.managedUsageBaselines.clear();
|
|
4170
4473
|
clearInterval(this.cleanupInterval);
|
|
4171
4474
|
|
|
4172
4475
|
const records = [...this.agents.values()];
|
|
@@ -4251,7 +4554,8 @@ export class AgentManager {
|
|
|
4251
4554
|
const providerPromises = records
|
|
4252
4555
|
.map((record) => record.promise)
|
|
4253
4556
|
.filter((promise): promise is Promise<string> => promise !== undefined);
|
|
4254
|
-
|
|
4557
|
+
const steeringPromises = records.flatMap(record => [...(this.steeringOperations.get(record) ?? [])]);
|
|
4558
|
+
if (providerPromises.length > 0 || steeringPromises.length > 0) {
|
|
4255
4559
|
let providerTimer: ReturnType<typeof setTimeout> | undefined;
|
|
4256
4560
|
const providerTimeout = new Promise<void>((resolve) => {
|
|
4257
4561
|
providerTimer = setTimeout(
|
|
@@ -4260,7 +4564,7 @@ export class AgentManager {
|
|
|
4260
4564
|
);
|
|
4261
4565
|
});
|
|
4262
4566
|
await Promise.race([
|
|
4263
|
-
Promise.allSettled(providerPromises).then(() => undefined),
|
|
4567
|
+
Promise.allSettled([...providerPromises, ...steeringPromises]).then(() => undefined),
|
|
4264
4568
|
providerTimeout,
|
|
4265
4569
|
]);
|
|
4266
4570
|
if (providerTimer) clearTimeout(providerTimer);
|
|
@@ -4357,12 +4661,12 @@ export class AgentManager {
|
|
|
4357
4661
|
);
|
|
4358
4662
|
}
|
|
4359
4663
|
|
|
4360
|
-
if (!teardownsSettled && failures.length > 0) {
|
|
4664
|
+
if ((!teardownsSettled || steeringPromises.length > 0) && failures.length > 0) {
|
|
4361
4665
|
// Do not make host shutdown wait forever for an extension-owned child
|
|
4362
4666
|
// session. Keep worktrees attached until *both* child teardown and all
|
|
4363
|
-
// providers settle; only then retry cleanup,
|
|
4364
|
-
// records cannot publish into a replacement session
|
|
4365
|
-
const pending = [...this.sessionTeardowns, ...providerPromises];
|
|
4667
|
+
// providers and native input handlers settle; only then retry cleanup,
|
|
4668
|
+
// deepest first. Detached records cannot publish into a replacement session.
|
|
4669
|
+
const pending = [...this.sessionTeardowns, ...providerPromises, ...steeringPromises];
|
|
4366
4670
|
void Promise.allSettled(pending)
|
|
4367
4671
|
.then(async (outcomes) => {
|
|
4368
4672
|
if (outcomes.some((outcome) => outcome.status === "rejected")) return;
|