@signalridge/pi-subagents 1.11.1 → 1.13.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 +27 -0
- package/README.md +77 -10
- package/package.json +3 -3
- package/src/agent-color.ts +1 -1
- package/src/agent-manager.ts +395 -66
- package/src/agent-runner.ts +83 -35
- package/src/extension-resource-names.ts +105 -0
- package/src/index.ts +221 -69
- package/src/output-file.ts +28 -9
- package/src/parent-questions.ts +453 -0
- package/src/question-origin.ts +24 -0
- package/src/supervisor.ts +28 -8
- package/src/types.ts +4 -3
- package/src/ui/agent-display.ts +2 -1
- package/src/ui/conversation-viewer.ts +641 -290
- package/src/ui/fleet-list.ts +258 -66
- package/src/ui/owner-group.ts +110 -0
- package/src/ui/schedule-menu.ts +14 -7
- package/src/ui/workflow-ui-client.ts +189 -0
package/src/agent-manager.ts
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
assertParentModelRuntimeAvailable,
|
|
44
44
|
parentModelSessionOptions,
|
|
45
45
|
} from "./model-runtime-bridge.js";
|
|
46
|
+
import type { ParentQuestionHandler } from "./parent-questions.js";
|
|
46
47
|
import { shutdownAndDisposeSession } from "./session-lifecycle.js";
|
|
47
48
|
import type { TierThinking } from "./settings.js";
|
|
48
49
|
import type {
|
|
@@ -55,7 +56,7 @@ import type {
|
|
|
55
56
|
SubagentType,
|
|
56
57
|
ThinkingLevel,
|
|
57
58
|
} from "./types.js";
|
|
58
|
-
import { addUsage } from "./usage.js";
|
|
59
|
+
import { addUsage, type LifetimeUsage } from "./usage.js";
|
|
59
60
|
import {
|
|
60
61
|
cleanupWorktree,
|
|
61
62
|
cleanupWorktreeAsync,
|
|
@@ -70,6 +71,13 @@ import {
|
|
|
70
71
|
export type OnAgentComplete = (record: AgentRecord) => void;
|
|
71
72
|
export type OnAgentCreated = (record: AgentRecord) => void;
|
|
72
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
|
+
}
|
|
73
81
|
export type OnAgentCompact = (
|
|
74
82
|
record: AgentRecord,
|
|
75
83
|
info: CompactionInfo,
|
|
@@ -314,6 +322,7 @@ interface SpawnArgs {
|
|
|
314
322
|
prompt: string;
|
|
315
323
|
options: SpawnOptions;
|
|
316
324
|
internalOverride?: InternalAgentConfigOverride;
|
|
325
|
+
parentQuestion?: ParentQuestionHandler;
|
|
317
326
|
}
|
|
318
327
|
|
|
319
328
|
export type ManagedSpawnRequest = ProtocolManagedSpawnRequest;
|
|
@@ -912,6 +921,8 @@ export interface SpawnOptions {
|
|
|
912
921
|
/** Named sequential-thread hint for workflow orchestration. */
|
|
913
922
|
thread?: string;
|
|
914
923
|
isBackground?: boolean;
|
|
924
|
+
/** Explicit clarification routing. Parent mode requires a fresh root background spawn. */
|
|
925
|
+
supervisorQuestions?: boolean | "parent";
|
|
915
926
|
/**
|
|
916
927
|
* Skip the maxConcurrent queue check for this spawn — start immediately even
|
|
917
928
|
* if the configured concurrency limit would otherwise queue it. Used by the
|
|
@@ -1000,6 +1011,20 @@ export type ManagedSpawnPolicy = Pick<
|
|
|
1000
1011
|
policyFingerprint?: string;
|
|
1001
1012
|
};
|
|
1002
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
|
+
|
|
1003
1028
|
interface ResumeTerminalSnapshot {
|
|
1004
1029
|
status: AgentRecord["status"];
|
|
1005
1030
|
startedAt: number;
|
|
@@ -1027,8 +1052,32 @@ export class AgentManager {
|
|
|
1027
1052
|
private onCreated?: OnAgentCreated;
|
|
1028
1053
|
private onStart?: OnAgentStart;
|
|
1029
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>();
|
|
1030
1073
|
private managedSpawns = new Map<string, ManagedSpawnTombstone>();
|
|
1031
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
|
+
}>();
|
|
1032
1081
|
/** Spawn keys found in unsupported/corrupt persisted tombstones. */
|
|
1033
1082
|
private managedQuarantinedKeys = new Set<string>();
|
|
1034
1083
|
/**
|
|
@@ -1074,6 +1123,13 @@ export class AgentManager {
|
|
|
1074
1123
|
private rememberAgents = true;
|
|
1075
1124
|
/** Whether spawned agents get `contact_supervisor` (settings). */
|
|
1076
1125
|
private supervisorQuestions = true;
|
|
1126
|
+
private parentQuestionFactory?: (record: AgentRecord, ctx: ExtensionContext) => ParentQuestionHandler;
|
|
1127
|
+
private parentQuestionAgents = new WeakSet<AgentRecord>();
|
|
1128
|
+
|
|
1129
|
+
/** Package-local root capability; no managed RPC schema or global settings change. */
|
|
1130
|
+
setParentQuestionFactory(factory: (record: AgentRecord, ctx: ExtensionContext) => ParentQuestionHandler): void {
|
|
1131
|
+
this.parentQuestionFactory = factory;
|
|
1132
|
+
}
|
|
1077
1133
|
/**
|
|
1078
1134
|
* Descendants spawned so far under each top-level agent, keyed by that
|
|
1079
1135
|
* agent's id. Cumulative for the branch's lifetime — see the budget check in
|
|
@@ -1120,11 +1176,13 @@ export class AgentManager {
|
|
|
1120
1176
|
onCompact?: OnAgentCompact,
|
|
1121
1177
|
onCreated?: OnAgentCreated,
|
|
1122
1178
|
managedPersistence?: ManagedSpawnPersistence,
|
|
1179
|
+
onHandoff?: OnAgentHandoff,
|
|
1123
1180
|
) {
|
|
1124
1181
|
this.onComplete = onComplete;
|
|
1125
1182
|
this.onCreated = onCreated;
|
|
1126
1183
|
this.onStart = onStart;
|
|
1127
1184
|
this.onCompact = onCompact;
|
|
1185
|
+
this.onHandoff = onHandoff;
|
|
1128
1186
|
this.managedPersistence = managedPersistence;
|
|
1129
1187
|
this.maxConcurrent = maxConcurrent;
|
|
1130
1188
|
// Cleanup completed agents after 10 minutes (but keep sessions for resume)
|
|
@@ -1279,12 +1337,22 @@ export class AgentManager {
|
|
|
1279
1337
|
!record.detached &&
|
|
1280
1338
|
!this.removingRecords.has(record.id) &&
|
|
1281
1339
|
!this.settlingRecords.has(record.id) &&
|
|
1340
|
+
!this.providerPendingRecords.has(record.id) &&
|
|
1341
|
+
!this.recordSessionTeardowns.has(record.id) &&
|
|
1342
|
+
!this.hasPendingSteering(record) &&
|
|
1282
1343
|
!this.deferredRecordRemovals.has(record.id) &&
|
|
1283
1344
|
(record.parentAgentId === undefined ||
|
|
1284
1345
|
this.nestedOwnerValidation(record.parentAgentId) === undefined)
|
|
1285
1346
|
);
|
|
1286
1347
|
}
|
|
1287
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
|
+
|
|
1288
1356
|
private clearManagedPersistenceRetry(key: string): void {
|
|
1289
1357
|
const pending = this.managedPersistenceRetries.get(key);
|
|
1290
1358
|
if (!pending) return;
|
|
@@ -1402,6 +1470,7 @@ export class AgentManager {
|
|
|
1402
1470
|
const state: ManagedSpawnState =
|
|
1403
1471
|
terminalStatus ?? (record.status === "queued" ? "queued" : "running");
|
|
1404
1472
|
const compactionCount = Math.max(0, Math.floor(record.compactionCount));
|
|
1473
|
+
const usage = this.getManagedUsage(record.id, tombstone.owner);
|
|
1405
1474
|
const terminal = terminalStatus
|
|
1406
1475
|
? {
|
|
1407
1476
|
status: terminalStatus,
|
|
@@ -1414,10 +1483,10 @@ export class AgentManager {
|
|
|
1414
1483
|
...(record.outputFile
|
|
1415
1484
|
? { outputFile: record.outputFile.slice(0, MANAGED_PATH_LIMIT) }
|
|
1416
1485
|
: {}),
|
|
1417
|
-
...(
|
|
1486
|
+
...(usage
|
|
1418
1487
|
? {
|
|
1419
1488
|
tokenCount: Math.floor(
|
|
1420
|
-
|
|
1489
|
+
usage.input + usage.output + usage.cacheWrite,
|
|
1421
1490
|
),
|
|
1422
1491
|
}
|
|
1423
1492
|
: {}),
|
|
@@ -1584,6 +1653,23 @@ export class AgentManager {
|
|
|
1584
1653
|
return recovered;
|
|
1585
1654
|
}
|
|
1586
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
|
+
|
|
1587
1673
|
getManagedSpawn(spawnKey: string): ManagedSpawnTombstone | undefined {
|
|
1588
1674
|
const tombstone = this.managedSpawns.get(spawnKey.trim());
|
|
1589
1675
|
return tombstone ? cloneManagedTombstone(tombstone) : undefined;
|
|
@@ -1663,6 +1749,14 @@ export class AgentManager {
|
|
|
1663
1749
|
// Legacy in-process callers omitted description before it became required.
|
|
1664
1750
|
// Normalize that shape to an inert string while rejecting reference values.
|
|
1665
1751
|
options = { ...options, description: options.description ?? type };
|
|
1752
|
+
if (options.supervisorQuestions !== undefined && typeof options.supervisorQuestions !== "boolean" && options.supervisorQuestions !== "parent") {
|
|
1753
|
+
throw new Error('supervisorQuestions must be boolean or "parent".');
|
|
1754
|
+
}
|
|
1755
|
+
if (options.supervisorQuestions === "parent" &&
|
|
1756
|
+
(options.isBackground !== true || options.parentAgentId !== undefined || options.resumeSessionFile ||
|
|
1757
|
+
options.isolated || getAgentConfig(type)?.isolated || !this.supervisorQuestions || !this.parentQuestionFactory)) {
|
|
1758
|
+
throw new Error('Parent clarification requires a fresh, non-isolated top-level background Agent with supervisor questions enabled. Set run_in_background: true; foreground calls cannot answer while blocked.');
|
|
1759
|
+
}
|
|
1666
1760
|
// Covers top-level, nested, managed, and queued spawns before allocating a
|
|
1667
1761
|
// record, consuming a branch budget, or creating an isolation worktree.
|
|
1668
1762
|
assertNoRawInheritance(
|
|
@@ -1791,6 +1885,10 @@ export class AgentManager {
|
|
|
1791
1885
|
record.handle = options.reclaimHandle;
|
|
1792
1886
|
}
|
|
1793
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
|
+
});
|
|
1794
1892
|
if (!record.detached) {
|
|
1795
1893
|
try {
|
|
1796
1894
|
this.onCreated?.(record);
|
|
@@ -1811,7 +1909,9 @@ export class AgentManager {
|
|
|
1811
1909
|
prompt,
|
|
1812
1910
|
options,
|
|
1813
1911
|
internalOverride,
|
|
1912
|
+
...(options.supervisorQuestions === "parent" ? { parentQuestion: this.parentQuestionFactory!(record, ctx) } : {}),
|
|
1814
1913
|
};
|
|
1914
|
+
if (options.supervisorQuestions === "parent") this.parentQuestionAgents.add(record);
|
|
1815
1915
|
// Lifecycle observers may synchronously stop or dispose a freshly allocated
|
|
1816
1916
|
// record. Do not let the normal queue/start path resurrect that decision.
|
|
1817
1917
|
if (
|
|
@@ -1897,16 +1997,7 @@ export class AgentManager {
|
|
|
1897
1997
|
ctx: ExtensionContext,
|
|
1898
1998
|
request: ManagedSpawnRequest,
|
|
1899
1999
|
policy: ManagedSpawnPolicy,
|
|
1900
|
-
callbacks?:
|
|
1901
|
-
SpawnOptions,
|
|
1902
|
-
| "onToolActivity"
|
|
1903
|
-
| "onTextDelta"
|
|
1904
|
-
| "onSessionCreated"
|
|
1905
|
-
| "onTurnEnd"
|
|
1906
|
-
| "onAssistantUsage"
|
|
1907
|
-
| "onCompaction"
|
|
1908
|
-
| "onSpawned"
|
|
1909
|
-
>,
|
|
2000
|
+
callbacks?: ManagedSpawnCallbacks,
|
|
1910
2001
|
): ManagedSpawnResult {
|
|
1911
2002
|
if (this.disposed) throw new Error("AgentManager is disposed");
|
|
1912
2003
|
const normalized = parseManagedSpawnRequest(request);
|
|
@@ -1993,6 +2084,9 @@ export class AgentManager {
|
|
|
1993
2084
|
const threadKey = thread
|
|
1994
2085
|
? `${normalized.owner.runId}\u0000${thread}`
|
|
1995
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
|
+
}
|
|
1996
2090
|
if (threadKey && this.managedThreadReservations.has(threadKey)) {
|
|
1997
2091
|
throw new Error(
|
|
1998
2092
|
`Managed workflow thread "${thread}" is already running; calls must be sequential.`,
|
|
@@ -2009,21 +2103,34 @@ export class AgentManager {
|
|
|
2009
2103
|
throw new Error(threadPolicyConflictMessage(thread, identityTier));
|
|
2010
2104
|
}
|
|
2011
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
|
+
}
|
|
2012
2109
|
if (
|
|
2013
2110
|
record?.session &&
|
|
2014
2111
|
record.status !== "running" &&
|
|
2015
2112
|
record.status !== "queued" &&
|
|
2016
2113
|
!record.detached
|
|
2017
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
|
+
}
|
|
2018
2120
|
assertSafeChildSession(
|
|
2019
2121
|
record.session,
|
|
2020
2122
|
record.invocation?.inheritContext === true,
|
|
2021
2123
|
);
|
|
2022
2124
|
const previousOwner = record.owner;
|
|
2125
|
+
const previousUsageBaseline = this.managedUsageBaselines.get(threadedId);
|
|
2023
2126
|
const previousInvocation = record.invocation;
|
|
2024
2127
|
const previousManagedKey = this.managedKeysById.get(threadedId);
|
|
2025
2128
|
const resumedTier = identityTier ?? previousInvocation?.agentTier;
|
|
2026
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
|
+
});
|
|
2027
2134
|
record.invocation = {
|
|
2028
2135
|
...(policy.invocation ?? {}),
|
|
2029
2136
|
...(resumedTier === undefined ? {} : { agentTier: resumedTier }),
|
|
@@ -2062,11 +2169,16 @@ export class AgentManager {
|
|
|
2062
2169
|
this.managedKeysById.delete(threadedId);
|
|
2063
2170
|
else this.managedKeysById.set(threadedId, previousManagedKey);
|
|
2064
2171
|
record.owner = previousOwner;
|
|
2172
|
+
if (previousUsageBaseline) this.managedUsageBaselines.set(threadedId, previousUsageBaseline);
|
|
2173
|
+
else this.managedUsageBaselines.delete(threadedId);
|
|
2065
2174
|
record.invocation = previousInvocation;
|
|
2066
2175
|
throw error;
|
|
2067
2176
|
}
|
|
2068
2177
|
void this.resume(threadedId, normalized.prompt, undefined, {
|
|
2069
2178
|
isBackground: true,
|
|
2179
|
+
onStarted: () => {
|
|
2180
|
+
void Promise.resolve(callbacks?.onResumeStarting?.(record, record.session!.messages.length)).catch(() => {});
|
|
2181
|
+
},
|
|
2070
2182
|
onToolActivity: callbacks?.onToolActivity,
|
|
2071
2183
|
onAssistantUsage: callbacks?.onAssistantUsage,
|
|
2072
2184
|
onCompaction: callbacks?.onCompaction
|
|
@@ -2091,6 +2203,9 @@ export class AgentManager {
|
|
|
2091
2203
|
`Managed workflow thread "${thread}" is already running; calls must be sequential.`,
|
|
2092
2204
|
);
|
|
2093
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
|
+
}
|
|
2094
2209
|
if (!record || record.detached || !record.session) {
|
|
2095
2210
|
this.managedThreads.delete(threadKey!);
|
|
2096
2211
|
this.managedThreadPolicies.delete(threadKey!);
|
|
@@ -2215,7 +2330,7 @@ export class AgentManager {
|
|
|
2215
2330
|
private startAgent(
|
|
2216
2331
|
id: string,
|
|
2217
2332
|
record: AgentRecord,
|
|
2218
|
-
{ pi, ctx, type, prompt, options, internalOverride }: SpawnArgs,
|
|
2333
|
+
{ pi, ctx, type, prompt, options, internalOverride, parentQuestion }: SpawnArgs,
|
|
2219
2334
|
) {
|
|
2220
2335
|
if (this.disposed || record.detached || this.agents.get(id) !== record)
|
|
2221
2336
|
return;
|
|
@@ -2330,7 +2445,8 @@ export class AgentManager {
|
|
|
2330
2445
|
// Top-level conversations persist by default so `@handle` has something
|
|
2331
2446
|
// to reopen after the record is evicted; frontmatter still overrides.
|
|
2332
2447
|
rememberAgents: this.rememberAgents,
|
|
2333
|
-
supervisorQuestions: this.supervisorQuestions,
|
|
2448
|
+
supervisorQuestions: options.supervisorQuestions ?? this.supervisorQuestions,
|
|
2449
|
+
parentQuestion,
|
|
2334
2450
|
resumeSessionFile: options.resumeSessionFile,
|
|
2335
2451
|
toolset: options.toolset,
|
|
2336
2452
|
excludeTools: options.excludeTools,
|
|
@@ -2394,11 +2510,8 @@ export class AgentManager {
|
|
|
2394
2510
|
session.sessionManager?.getSessionFile?.() ??
|
|
2395
2511
|
(session as { sessionFile?: string })?.sessionFile;
|
|
2396
2512
|
// Flush any steers that arrived before the session was ready
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
session.steer(msg).catch(() => {});
|
|
2400
|
-
}
|
|
2401
|
-
record.pendingSteers = undefined;
|
|
2513
|
+
for (const pending of this.takePendingSteers(record)) {
|
|
2514
|
+
void this.submitPendingSteer(record, session, pending).catch(() => {});
|
|
2402
2515
|
}
|
|
2403
2516
|
options.onSessionCreated?.(session);
|
|
2404
2517
|
},
|
|
@@ -2474,7 +2587,7 @@ export class AgentManager {
|
|
|
2474
2587
|
await this.awaitSessionTeardown(session);
|
|
2475
2588
|
this.cleanupDetachedWorktree(record);
|
|
2476
2589
|
if (record.session === session) record.session = undefined;
|
|
2477
|
-
if (
|
|
2590
|
+
if (record.isBackground) {
|
|
2478
2591
|
this.releasePoolSlot(record.id);
|
|
2479
2592
|
this.drainQueue();
|
|
2480
2593
|
}
|
|
@@ -2499,7 +2612,7 @@ export class AgentManager {
|
|
|
2499
2612
|
await this.awaitSessionTeardown(session);
|
|
2500
2613
|
this.cleanupDetachedWorktree(record);
|
|
2501
2614
|
if (record.session === session) record.session = undefined;
|
|
2502
|
-
if (
|
|
2615
|
+
if (record.isBackground) {
|
|
2503
2616
|
this.releasePoolSlot(record.id);
|
|
2504
2617
|
this.drainQueue();
|
|
2505
2618
|
}
|
|
@@ -2534,7 +2647,7 @@ export class AgentManager {
|
|
|
2534
2647
|
|
|
2535
2648
|
// Fire onComplete for foreground agents too — lifecycle symmetry.
|
|
2536
2649
|
// Mark resultConsumed so the callback skips notifications (result returned inline).
|
|
2537
|
-
if (!
|
|
2650
|
+
if (!record.isBackground) {
|
|
2538
2651
|
record.resultConsumed = true;
|
|
2539
2652
|
this.notifyComplete(record);
|
|
2540
2653
|
} else {
|
|
@@ -2580,7 +2693,7 @@ export class AgentManager {
|
|
|
2580
2693
|
if (record.detached) {
|
|
2581
2694
|
await this.awaitSessionTeardown(record.session);
|
|
2582
2695
|
this.cleanupDetachedWorktree(record);
|
|
2583
|
-
if (
|
|
2696
|
+
if (record.isBackground) {
|
|
2584
2697
|
this.releasePoolSlot(record.id);
|
|
2585
2698
|
this.drainQueue();
|
|
2586
2699
|
}
|
|
@@ -2598,7 +2711,7 @@ export class AgentManager {
|
|
|
2598
2711
|
if (record.detached) {
|
|
2599
2712
|
await this.awaitSessionTeardown(record.session);
|
|
2600
2713
|
this.cleanupDetachedWorktree(record);
|
|
2601
|
-
if (
|
|
2714
|
+
if (record.isBackground) {
|
|
2602
2715
|
this.releasePoolSlot(record.id);
|
|
2603
2716
|
this.drainQueue();
|
|
2604
2717
|
}
|
|
@@ -2608,7 +2721,7 @@ export class AgentManager {
|
|
|
2608
2721
|
|
|
2609
2722
|
// Fire onComplete for foreground agents too — lifecycle symmetry.
|
|
2610
2723
|
// Mark resultConsumed so the callback skips notifications (result returned inline).
|
|
2611
|
-
if (!
|
|
2724
|
+
if (!record.isBackground) {
|
|
2612
2725
|
record.resultConsumed = true;
|
|
2613
2726
|
this.notifyComplete(record);
|
|
2614
2727
|
} else {
|
|
@@ -2772,7 +2885,7 @@ export class AgentManager {
|
|
|
2772
2885
|
prompt: string,
|
|
2773
2886
|
options: Omit<SpawnOptions, "isBackground">,
|
|
2774
2887
|
onSpawned?: (id: string) => void,
|
|
2775
|
-
): Promise<{ id: string; record: AgentRecord }> {
|
|
2888
|
+
): Promise<{ id: string; record: AgentRecord; handedOff?: boolean }> {
|
|
2776
2889
|
return this.spawnAndWaitCore(pi, ctx, type, prompt, options, onSpawned);
|
|
2777
2890
|
}
|
|
2778
2891
|
|
|
@@ -2813,7 +2926,7 @@ export class AgentManager {
|
|
|
2813
2926
|
options: Omit<SpawnOptions, "isBackground">,
|
|
2814
2927
|
onSpawned?: (id: string) => void,
|
|
2815
2928
|
internalOverride?: InternalAgentConfigOverride,
|
|
2816
|
-
): Promise<{ id: string; record: AgentRecord }> {
|
|
2929
|
+
): Promise<{ id: string; record: AgentRecord; handedOff?: boolean }> {
|
|
2817
2930
|
// Temporarily register the onSpawned hook so startAgent can call it.
|
|
2818
2931
|
const prevOnSpawned = this.onSpawned;
|
|
2819
2932
|
this.onSpawned = onSpawned;
|
|
@@ -2836,8 +2949,69 @@ export class AgentManager {
|
|
|
2836
2949
|
this.onSpawned = prevOnSpawned;
|
|
2837
2950
|
}
|
|
2838
2951
|
const record = this.agents.get(id)!;
|
|
2839
|
-
|
|
2840
|
-
|
|
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;
|
|
2841
3015
|
}
|
|
2842
3016
|
|
|
2843
3017
|
private resumeSnapshot(record: AgentRecord): ResumeTerminalSnapshot {
|
|
@@ -2939,15 +3113,12 @@ export class AgentManager {
|
|
|
2939
3113
|
if (this.disposed) return undefined;
|
|
2940
3114
|
const record = this.agents.get(id);
|
|
2941
3115
|
if (!record) return undefined;
|
|
2942
|
-
if (!this.
|
|
3116
|
+
if (!this.canResumeRecord(record)) return undefined;
|
|
2943
3117
|
if (signal?.aborted) return record;
|
|
2944
|
-
if (
|
|
2945
|
-
this.resumeControls.has(id) ||
|
|
2946
|
-
record.status === "running" ||
|
|
2947
|
-
record.status === "queued"
|
|
2948
|
-
)
|
|
2949
|
-
return undefined;
|
|
2950
3118
|
if (!record.session) return undefined;
|
|
3119
|
+
if (this.parentQuestionAgents.has(record) && options?.isBackground !== true) {
|
|
3120
|
+
throw new Error('This child uses parent clarification; resume it with run_in_background: true to avoid blocking the answering model.');
|
|
3121
|
+
}
|
|
2951
3122
|
assertSafeChildSession(
|
|
2952
3123
|
record.session,
|
|
2953
3124
|
record.invocation?.inheritContext === true,
|
|
@@ -3124,16 +3295,12 @@ export class AgentManager {
|
|
|
3124
3295
|
// sent while it waits must be flushed here rather than through the
|
|
3125
3296
|
// session's idle steering queue. Otherwise cancelling this queued run
|
|
3126
3297
|
// would leave those messages behind for a later resume.
|
|
3127
|
-
const
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
await record.session!.steer(message);
|
|
3134
|
-
} catch {
|
|
3135
|
-
// A malformed or stale queued message must not prevent the resume.
|
|
3136
|
-
}
|
|
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.
|
|
3137
3304
|
}
|
|
3138
3305
|
}
|
|
3139
3306
|
|
|
@@ -3221,11 +3388,139 @@ export class AgentManager {
|
|
|
3221
3388
|
if (!record.pendingSteers) record.pendingSteers = [];
|
|
3222
3389
|
record.pendingSteers.push(message);
|
|
3223
3390
|
} else {
|
|
3391
|
+
this.retireEmptySteeringQueue(record, record.session);
|
|
3224
3392
|
record.session.steer(message).catch(() => {});
|
|
3225
3393
|
}
|
|
3226
3394
|
return true;
|
|
3227
3395
|
}
|
|
3228
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
|
+
|
|
3229
3524
|
/**
|
|
3230
3525
|
* Return an immutable observation snapshot. Internal manager/index code must
|
|
3231
3526
|
* use `getRecordMutable()` when it needs to update authoritative state.
|
|
@@ -3398,6 +3693,7 @@ export class AgentManager {
|
|
|
3398
3693
|
record.status !== "running" &&
|
|
3399
3694
|
!this.settlingRecords.has(record.id) &&
|
|
3400
3695
|
!this.providerPendingRecords.has(record.id) &&
|
|
3696
|
+
!this.hasPendingSteering(record) &&
|
|
3401
3697
|
!this.recordSessionTeardowns.has(record.id) &&
|
|
3402
3698
|
record.worktree === undefined &&
|
|
3403
3699
|
record.outputCleanup === undefined
|
|
@@ -3518,6 +3814,7 @@ export class AgentManager {
|
|
|
3518
3814
|
this.managedThreadReservations.delete(thread);
|
|
3519
3815
|
}
|
|
3520
3816
|
this.agents.delete(id);
|
|
3817
|
+
this.managedUsageBaselines.delete(id);
|
|
3521
3818
|
return true;
|
|
3522
3819
|
} finally {
|
|
3523
3820
|
// The map deletion is the point at which a later continuation becomes an
|
|
@@ -3843,6 +4140,14 @@ export class AgentManager {
|
|
|
3843
4140
|
const worktree = record.worktree;
|
|
3844
4141
|
if (!worktree) return undefined;
|
|
3845
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
|
+
}
|
|
3846
4151
|
if (this.providerPendingRecords.has(record.id)) {
|
|
3847
4152
|
return this.blockedWorktreeCleanup(
|
|
3848
4153
|
record,
|
|
@@ -3891,6 +4196,12 @@ export class AgentManager {
|
|
|
3891
4196
|
const worktree = record.worktree;
|
|
3892
4197
|
if (!worktree) return undefined;
|
|
3893
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
|
+
}
|
|
3894
4205
|
if (this.providerPendingRecords.has(record.id)) {
|
|
3895
4206
|
return this.blockedWorktreeCleanup(
|
|
3896
4207
|
record,
|
|
@@ -3996,19 +4307,35 @@ export class AgentManager {
|
|
|
3996
4307
|
if (this.isFullyCleaned(record)) pendingIds.delete(record.id);
|
|
3997
4308
|
};
|
|
3998
4309
|
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
3999
|
-
const
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
|
|
4010
|
-
|
|
4011
|
-
|
|
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
|
+
}
|
|
4012
4339
|
|
|
4013
4340
|
let pending = [...pendingIds];
|
|
4014
4341
|
if (!completed || pending.length > 0) {
|
|
@@ -4142,6 +4469,7 @@ export class AgentManager {
|
|
|
4142
4469
|
this.clearManagedPersistenceRetries();
|
|
4143
4470
|
if (this.disposePromise) return this.disposePromise;
|
|
4144
4471
|
this.disposed = true;
|
|
4472
|
+
this.managedUsageBaselines.clear();
|
|
4145
4473
|
clearInterval(this.cleanupInterval);
|
|
4146
4474
|
|
|
4147
4475
|
const records = [...this.agents.values()];
|
|
@@ -4226,7 +4554,8 @@ export class AgentManager {
|
|
|
4226
4554
|
const providerPromises = records
|
|
4227
4555
|
.map((record) => record.promise)
|
|
4228
4556
|
.filter((promise): promise is Promise<string> => promise !== undefined);
|
|
4229
|
-
|
|
4557
|
+
const steeringPromises = records.flatMap(record => [...(this.steeringOperations.get(record) ?? [])]);
|
|
4558
|
+
if (providerPromises.length > 0 || steeringPromises.length > 0) {
|
|
4230
4559
|
let providerTimer: ReturnType<typeof setTimeout> | undefined;
|
|
4231
4560
|
const providerTimeout = new Promise<void>((resolve) => {
|
|
4232
4561
|
providerTimer = setTimeout(
|
|
@@ -4235,7 +4564,7 @@ export class AgentManager {
|
|
|
4235
4564
|
);
|
|
4236
4565
|
});
|
|
4237
4566
|
await Promise.race([
|
|
4238
|
-
Promise.allSettled(providerPromises).then(() => undefined),
|
|
4567
|
+
Promise.allSettled([...providerPromises, ...steeringPromises]).then(() => undefined),
|
|
4239
4568
|
providerTimeout,
|
|
4240
4569
|
]);
|
|
4241
4570
|
if (providerTimer) clearTimeout(providerTimer);
|
|
@@ -4332,12 +4661,12 @@ export class AgentManager {
|
|
|
4332
4661
|
);
|
|
4333
4662
|
}
|
|
4334
4663
|
|
|
4335
|
-
if (!teardownsSettled && failures.length > 0) {
|
|
4664
|
+
if ((!teardownsSettled || steeringPromises.length > 0) && failures.length > 0) {
|
|
4336
4665
|
// Do not make host shutdown wait forever for an extension-owned child
|
|
4337
4666
|
// session. Keep worktrees attached until *both* child teardown and all
|
|
4338
|
-
// providers settle; only then retry cleanup,
|
|
4339
|
-
// records cannot publish into a replacement session
|
|
4340
|
-
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];
|
|
4341
4670
|
void Promise.allSettled(pending)
|
|
4342
4671
|
.then(async (outcomes) => {
|
|
4343
4672
|
if (outcomes.some((outcome) => outcome.status === "rejected")) return;
|