@modelprofile.com/flexharness 3.4.0 → 3.5.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 +10 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.flexharness.d.ts +19 -0
- package/dist_ts/classes.flexharness.js +753 -96
- package/dist_ts/interfaces.d.ts +25 -1
- package/dist_ts/plugins.d.ts +2 -2
- package/dist_ts/plugins.js +3 -3
- package/dist_ts/utils.json.js +176 -8
- package/package.json +1 -1
- package/readme.hints.md +11 -0
- package/readme.md +49 -2
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.flexharness.ts +998 -100
- package/ts/interfaces.ts +26 -1
- package/ts/plugins.ts +4 -0
- package/ts/utils.json.ts +202 -8
|
@@ -50,6 +50,7 @@ import type {
|
|
|
50
50
|
IFlexScopeSnapshot,
|
|
51
51
|
IFlexSession,
|
|
52
52
|
IFlexSessionTombstone,
|
|
53
|
+
IFlexSubagentDefinition,
|
|
53
54
|
IFlexTerminalProjection,
|
|
54
55
|
IFlexToolHandle,
|
|
55
56
|
IFlexToolMessagePart,
|
|
@@ -199,6 +200,8 @@ interface IActiveRun {
|
|
|
199
200
|
callbacksClosed: boolean;
|
|
200
201
|
reasoningPartIds: Map<string, string>;
|
|
201
202
|
toolPartIds: Map<string, string>;
|
|
203
|
+
subagentCallCount: number;
|
|
204
|
+
subagentSessionIds: Set<string>;
|
|
202
205
|
pendingPermissionIds: Set<string>;
|
|
203
206
|
phase: TRunPhase;
|
|
204
207
|
reservedUserMessage?: IFlexMessage;
|
|
@@ -281,6 +284,18 @@ interface IExecutableToolRecord extends Record<string, unknown> {
|
|
|
281
284
|
execute?: (input: unknown, options: unknown) => unknown;
|
|
282
285
|
}
|
|
283
286
|
|
|
287
|
+
interface IFlexSubagentTaskInput {
|
|
288
|
+
description: string;
|
|
289
|
+
prompt: string;
|
|
290
|
+
subagentType: string;
|
|
291
|
+
taskId?: string;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
interface IFlexSubagentAcquisition {
|
|
295
|
+
stored: IStoredSessionState;
|
|
296
|
+
created: boolean;
|
|
297
|
+
}
|
|
298
|
+
|
|
284
299
|
type TEventDetails = Record<string, unknown> & {
|
|
285
300
|
type: TFlexHarnessEvent['type'];
|
|
286
301
|
};
|
|
@@ -309,6 +324,19 @@ const maxProjectedErrorMessageBytes = 2048;
|
|
|
309
324
|
const maxProjectedErrorCodeBytes = 128;
|
|
310
325
|
const maxScheduleDebounceMs = 24 * 60 * 60 * 1000;
|
|
311
326
|
const maxBackgroundExecutions = 100;
|
|
327
|
+
const maxSubagentDefinitions = 32;
|
|
328
|
+
const maxSubagentNameBytes = 128;
|
|
329
|
+
const maxSubagentDescriptionBytes = 2048;
|
|
330
|
+
const maxSubagentModelHintBytes = 512;
|
|
331
|
+
const maxSubagentSystemBytes = 64 * 1024;
|
|
332
|
+
const maxSubagentTaskDescriptionBytes = 256;
|
|
333
|
+
const maxSubagentPromptBytes = 64 * 1024;
|
|
334
|
+
const maxSubagentTaskIdBytes = 512;
|
|
335
|
+
const maxSubagentResultTextBytes = 64 * 1024;
|
|
336
|
+
const defaultMaxSubagentDepth = 1;
|
|
337
|
+
const maximumMaxSubagentDepth = 8;
|
|
338
|
+
const defaultMaxSubagentCallsPerRun = 32;
|
|
339
|
+
const maximumMaxSubagentCallsPerRun = 128;
|
|
312
340
|
const repairCancellationMessage = 'The process stopped before this run completed.';
|
|
313
341
|
const scopeRetirementMessage = 'The scope is being retired.';
|
|
314
342
|
const externalErrorFallback: IFlexErrorInfo = Object.freeze({
|
|
@@ -408,6 +436,102 @@ function validateIdentifier(value: string, name: string): void {
|
|
|
408
436
|
}
|
|
409
437
|
}
|
|
410
438
|
|
|
439
|
+
function validateUtf8String(
|
|
440
|
+
value: unknown,
|
|
441
|
+
name: string,
|
|
442
|
+
maxBytes: number,
|
|
443
|
+
nonEmpty = false,
|
|
444
|
+
): asserts value is string {
|
|
445
|
+
if (
|
|
446
|
+
typeof value !== 'string'
|
|
447
|
+
|| (nonEmpty && !value.trim())
|
|
448
|
+
|| Buffer.byteLength(value, 'utf8') > maxBytes
|
|
449
|
+
) {
|
|
450
|
+
throw new FlexHarnessValidationError(
|
|
451
|
+
`${name} must be ${nonEmpty ? 'a non-empty ' : 'a '}string of at most ${maxBytes} UTF-8 bytes.`,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function normalizeSubagents(
|
|
457
|
+
definitions: IFlexSubagentDefinition[] | undefined,
|
|
458
|
+
): ReadonlyMap<string, Readonly<IFlexSubagentDefinition>> {
|
|
459
|
+
if (definitions === undefined) return new Map();
|
|
460
|
+
if (!Array.isArray(definitions) || definitions.length > maxSubagentDefinitions) {
|
|
461
|
+
throw new FlexHarnessValidationError(
|
|
462
|
+
`subagents must be an array with at most ${maxSubagentDefinitions} definitions.`,
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
const normalized = new Map<string, Readonly<IFlexSubagentDefinition>>();
|
|
466
|
+
for (let index = 0; index < definitions.length; index++) {
|
|
467
|
+
const definition = definitions[index];
|
|
468
|
+
if (
|
|
469
|
+
!definition
|
|
470
|
+
|| typeof definition !== 'object'
|
|
471
|
+
|| Array.isArray(definition)
|
|
472
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(definition))
|
|
473
|
+
) {
|
|
474
|
+
throw new FlexHarnessValidationError(`subagents[${index}] must be a plain object.`);
|
|
475
|
+
}
|
|
476
|
+
const unsupported = Object.keys(definition).find(
|
|
477
|
+
(key) => !['name', 'description', 'modelHint', 'system', 'maxSteps'].includes(key),
|
|
478
|
+
);
|
|
479
|
+
if (unsupported) {
|
|
480
|
+
throw new FlexHarnessValidationError(`subagents[${index}] does not support "${unsupported}".`);
|
|
481
|
+
}
|
|
482
|
+
validateUtf8String(definition.name, `subagents[${index}].name`, maxSubagentNameBytes, true);
|
|
483
|
+
validateUtf8String(
|
|
484
|
+
definition.description,
|
|
485
|
+
`subagents[${index}].description`,
|
|
486
|
+
maxSubagentDescriptionBytes,
|
|
487
|
+
);
|
|
488
|
+
if (definition.modelHint !== undefined) {
|
|
489
|
+
validateUtf8String(
|
|
490
|
+
definition.modelHint,
|
|
491
|
+
`subagents[${index}].modelHint`,
|
|
492
|
+
maxSubagentModelHintBytes,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
if (definition.system !== undefined) {
|
|
496
|
+
validateUtf8String(
|
|
497
|
+
definition.system,
|
|
498
|
+
`subagents[${index}].system`,
|
|
499
|
+
maxSubagentSystemBytes,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
if (
|
|
503
|
+
definition.maxSteps !== undefined
|
|
504
|
+
&& (!Number.isSafeInteger(definition.maxSteps) || definition.maxSteps < 1)
|
|
505
|
+
) {
|
|
506
|
+
throw new FlexHarnessValidationError(`subagents[${index}].maxSteps must be a positive integer.`);
|
|
507
|
+
}
|
|
508
|
+
if (normalized.has(definition.name)) {
|
|
509
|
+
throw new FlexHarnessValidationError(`Duplicate subagent definition "${definition.name}".`);
|
|
510
|
+
}
|
|
511
|
+
normalized.set(definition.name, Object.freeze({
|
|
512
|
+
name: definition.name,
|
|
513
|
+
description: definition.description,
|
|
514
|
+
...(definition.modelHint === undefined ? {} : { modelHint: definition.modelHint }),
|
|
515
|
+
...(definition.system === undefined ? {} : { system: definition.system }),
|
|
516
|
+
...(definition.maxSteps === undefined ? {} : { maxSteps: definition.maxSteps }),
|
|
517
|
+
}));
|
|
518
|
+
}
|
|
519
|
+
return Object.freeze(normalized);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function resolveBoundedPositiveInteger(
|
|
523
|
+
value: number | undefined,
|
|
524
|
+
name: string,
|
|
525
|
+
defaultValue: number,
|
|
526
|
+
maximum: number,
|
|
527
|
+
): number {
|
|
528
|
+
const resolved = value ?? defaultValue;
|
|
529
|
+
if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > maximum) {
|
|
530
|
+
throw new FlexHarnessValidationError(`${name} must be an integer from 1 through ${maximum}.`);
|
|
531
|
+
}
|
|
532
|
+
return resolved;
|
|
533
|
+
}
|
|
534
|
+
|
|
411
535
|
function requireTransferIdentifier(value: string, field: string): void {
|
|
412
536
|
if (Buffer.byteLength(value, 'utf8') > maxTransferIdentifierBytes) {
|
|
413
537
|
throw new FlexHarnessValidationError(`${field} exceeds the transfer limit.`);
|
|
@@ -742,6 +866,9 @@ export class FlexHarness<TScope = unknown> {
|
|
|
742
866
|
private readonly callbackLimits: Required<IFlexCallbackLimits>;
|
|
743
867
|
private readonly promptQueueLimits: Required<IFlexPromptQueueLimits>;
|
|
744
868
|
private readonly externalErrorProjector?: TFlexExternalErrorProjector;
|
|
869
|
+
private readonly subagents: ReadonlyMap<string, Readonly<IFlexSubagentDefinition>>;
|
|
870
|
+
private readonly maxSubagentDepth: number;
|
|
871
|
+
private readonly maxSubagentCallsPerRun: number;
|
|
745
872
|
private readonly stateLoads = new Map<string, Promise<IStorageState>>();
|
|
746
873
|
private readonly scopeAdmissions = new Map<string, IScopeAdmissionState>();
|
|
747
874
|
private readonly scopeRetirements = new Map<string, Promise<void>>();
|
|
@@ -786,6 +913,19 @@ export class FlexHarness<TScope = unknown> {
|
|
|
786
913
|
this.callbackLimits = resolveCallbackLimits(options.callbackLimits);
|
|
787
914
|
this.promptQueueLimits = resolvePromptQueueLimits(options.promptQueueLimits);
|
|
788
915
|
this.externalErrorProjector = options.externalErrorProjector;
|
|
916
|
+
this.subagents = normalizeSubagents(options.subagents);
|
|
917
|
+
this.maxSubagentDepth = resolveBoundedPositiveInteger(
|
|
918
|
+
options.maxSubagentDepth,
|
|
919
|
+
'maxSubagentDepth',
|
|
920
|
+
defaultMaxSubagentDepth,
|
|
921
|
+
maximumMaxSubagentDepth,
|
|
922
|
+
);
|
|
923
|
+
this.maxSubagentCallsPerRun = resolveBoundedPositiveInteger(
|
|
924
|
+
options.maxSubagentCallsPerRun,
|
|
925
|
+
'maxSubagentCallsPerRun',
|
|
926
|
+
defaultMaxSubagentCallsPerRun,
|
|
927
|
+
maximumMaxSubagentCallsPerRun,
|
|
928
|
+
);
|
|
789
929
|
}
|
|
790
930
|
|
|
791
931
|
public async listSessions(scopeId: string): Promise<IFlexSession[]> {
|
|
@@ -851,6 +991,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
851
991
|
updatedAt: timestamp,
|
|
852
992
|
status: 'idle',
|
|
853
993
|
activity: { status: 'idle' },
|
|
994
|
+
depth: 0,
|
|
854
995
|
};
|
|
855
996
|
resolved.state.sessions.set(
|
|
856
997
|
sessionId,
|
|
@@ -864,6 +1005,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
864
1005
|
metadata,
|
|
865
1006
|
scopeId,
|
|
866
1007
|
resolved.scope.scope,
|
|
1008
|
+
placeholder.compactorLifecycleController,
|
|
867
1009
|
);
|
|
868
1010
|
const loaded = await initialization;
|
|
869
1011
|
if (resolved.state.sessions.get(sessionId) !== placeholder) {
|
|
@@ -890,6 +1032,8 @@ export class FlexHarness<TScope = unknown> {
|
|
|
890
1032
|
const tombstone: IFlexSessionTombstone = {
|
|
891
1033
|
sessionId,
|
|
892
1034
|
deletedAt: new Date().toISOString(),
|
|
1035
|
+
rootSessionId: sessionId,
|
|
1036
|
+
depth: 0,
|
|
893
1037
|
};
|
|
894
1038
|
let tombstoneCommitted = false;
|
|
895
1039
|
let domainCleanupCompleted = false;
|
|
@@ -899,11 +1043,12 @@ export class FlexHarness<TScope = unknown> {
|
|
|
899
1043
|
resolved.state.tombstones.set(sessionId, tombstone);
|
|
900
1044
|
}, true);
|
|
901
1045
|
tombstoneCommitted = true;
|
|
1046
|
+
completeInitialization();
|
|
902
1047
|
await this.finishTombstoneCleanup(
|
|
903
1048
|
resolved.state,
|
|
904
1049
|
sessionId,
|
|
905
1050
|
scopeId,
|
|
906
|
-
resolved.scope.scope,
|
|
1051
|
+
{ scopeId, scope: resolved.scope.scope },
|
|
907
1052
|
);
|
|
908
1053
|
domainCleanupCompleted = true;
|
|
909
1054
|
} catch (cleanupError) {
|
|
@@ -981,15 +1126,17 @@ export class FlexHarness<TScope = unknown> {
|
|
|
981
1126
|
public async deleteSession(scopeId: string, sessionId: string): Promise<void> {
|
|
982
1127
|
const { scope, state } = await this.resolveState(scopeId);
|
|
983
1128
|
validateIdentifier(sessionId, 'sessionId');
|
|
984
|
-
|
|
1129
|
+
await state.scopeQueue;
|
|
1130
|
+
const deletionKey = state.tombstones.get(sessionId)?.rootSessionId ?? sessionId;
|
|
1131
|
+
const existingDeletion = state.sessionDeletions.get(deletionKey);
|
|
985
1132
|
if (existingDeletion) return existingDeletion;
|
|
986
1133
|
let deletion!: Promise<void>;
|
|
987
1134
|
deletion = this.deleteSessionInternal(state, scopeId, scope.scope, sessionId).finally(() => {
|
|
988
|
-
if (state.sessionDeletions.get(
|
|
989
|
-
state.sessionDeletions.delete(
|
|
1135
|
+
if (state.sessionDeletions.get(deletionKey) === deletion) {
|
|
1136
|
+
state.sessionDeletions.delete(deletionKey);
|
|
990
1137
|
}
|
|
991
1138
|
});
|
|
992
|
-
state.sessionDeletions.set(
|
|
1139
|
+
state.sessionDeletions.set(deletionKey, deletion);
|
|
993
1140
|
return deletion;
|
|
994
1141
|
}
|
|
995
1142
|
|
|
@@ -1001,35 +1148,91 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1001
1148
|
): Promise<void> {
|
|
1002
1149
|
const existingTombstone = state.tombstones.get(sessionId);
|
|
1003
1150
|
if (existingTombstone) {
|
|
1151
|
+
const rootSessionId = existingTombstone.rootSessionId ?? existingTombstone.sessionId;
|
|
1152
|
+
const descendantRoots = this.descendantTombstoneRoots(state, sessionId)
|
|
1153
|
+
.filter((candidate) => candidate !== rootSessionId);
|
|
1004
1154
|
try {
|
|
1005
|
-
await this.
|
|
1155
|
+
const orphanErrors = await this.closeOrphanedResources(state.storageKey);
|
|
1156
|
+
if (orphanErrors.length > 0) throw combineErrors(orphanErrors);
|
|
1157
|
+
for (const descendantRoot of descendantRoots) {
|
|
1158
|
+
await this.finishTombstoneCleanup(
|
|
1159
|
+
state,
|
|
1160
|
+
descendantRoot,
|
|
1161
|
+
scopeId,
|
|
1162
|
+
{ scopeId, scope },
|
|
1163
|
+
);
|
|
1164
|
+
}
|
|
1165
|
+
await this.finishTombstoneCleanup(state, rootSessionId, scopeId, { scopeId, scope });
|
|
1006
1166
|
} catch (error) {
|
|
1007
1167
|
throw this.projectOperationError(error, 'persistence', scopeId, sessionId, 'session-delete');
|
|
1008
1168
|
}
|
|
1009
1169
|
return;
|
|
1010
1170
|
}
|
|
1011
1171
|
const stored = this.requireSession(state, sessionId);
|
|
1012
|
-
|
|
1172
|
+
let deletedSessions: IFlexSession[] = [];
|
|
1173
|
+
let descendantRoots: string[] = [];
|
|
1013
1174
|
await this.mutateScope(state, () => {
|
|
1014
1175
|
if (state.sessions.get(sessionId) !== stored) {
|
|
1015
1176
|
throw new FlexHarnessNotFoundError('Session', sessionId);
|
|
1016
1177
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
sessionId
|
|
1024
|
-
|
|
1025
|
-
|
|
1178
|
+
const subtree = this.collectSessionSubtree(state, sessionId);
|
|
1179
|
+
descendantRoots = this.descendantTombstoneRoots(state, sessionId);
|
|
1180
|
+
const rootDepth = stored.session.depth ?? 0;
|
|
1181
|
+
const deletedAt = new Date().toISOString();
|
|
1182
|
+
deletedSessions = subtree.map((entry) => cloneSerializable(entry.session));
|
|
1183
|
+
for (const entry of subtree) {
|
|
1184
|
+
const entrySessionId = entry.session.sessionId;
|
|
1185
|
+
state.sessions.delete(entrySessionId);
|
|
1186
|
+
state.retainedSessionCleanups.set(entrySessionId, {
|
|
1187
|
+
stored: entry,
|
|
1188
|
+
domainsCompleted: false,
|
|
1189
|
+
});
|
|
1190
|
+
state.tombstones.set(entrySessionId, {
|
|
1191
|
+
sessionId: entrySessionId,
|
|
1192
|
+
deletedAt,
|
|
1193
|
+
rootSessionId: sessionId,
|
|
1194
|
+
depth: (entry.session.depth ?? rootDepth) - rootDepth,
|
|
1195
|
+
...(entry.session.parentSessionId === undefined
|
|
1196
|
+
? {}
|
|
1197
|
+
: { parentSessionId: entry.session.parentSessionId }),
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1026
1200
|
});
|
|
1201
|
+
const reason = this.trustInternalError(new FlexHarnessAbortError('The session subtree was deleted.'));
|
|
1202
|
+
const childFirstDeletedSessions = [...deletedSessions].sort((left, right) =>
|
|
1203
|
+
(right.depth ?? 0) - (left.depth ?? 0)
|
|
1204
|
+
|| left.sessionId.localeCompare(right.sessionId));
|
|
1205
|
+
for (const deleted of childFirstDeletedSessions) {
|
|
1206
|
+
const active = state.activeRuns.get(deleted.sessionId);
|
|
1207
|
+
if (active) {
|
|
1208
|
+
this.cancelRun(
|
|
1209
|
+
active,
|
|
1210
|
+
reason,
|
|
1211
|
+
this.createCompactorContext(scopeId, scope, state.storageKey, deleted.sessionId),
|
|
1212
|
+
);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1027
1215
|
try {
|
|
1028
|
-
await this.
|
|
1216
|
+
const orphanErrors = await this.closeOrphanedResources(state.storageKey);
|
|
1217
|
+
if (orphanErrors.length > 0) throw combineErrors(orphanErrors);
|
|
1218
|
+
for (const descendantRoot of descendantRoots) {
|
|
1219
|
+
await this.finishTombstoneCleanup(
|
|
1220
|
+
state,
|
|
1221
|
+
descendantRoot,
|
|
1222
|
+
scopeId,
|
|
1223
|
+
{ scopeId, scope },
|
|
1224
|
+
);
|
|
1225
|
+
}
|
|
1226
|
+
await this.finishTombstoneCleanup(state, sessionId, scopeId, { scopeId, scope });
|
|
1029
1227
|
} catch (error) {
|
|
1030
1228
|
throw this.projectOperationError(error, 'persistence', scopeId, sessionId, 'session-delete');
|
|
1031
1229
|
}
|
|
1032
|
-
|
|
1230
|
+
for (const deleted of childFirstDeletedSessions) {
|
|
1231
|
+
this.emitEvent(scopeId, deleted.sessionId, {
|
|
1232
|
+
type: 'session.deleted',
|
|
1233
|
+
session: publicSnapshot(deleted),
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1033
1236
|
}
|
|
1034
1237
|
|
|
1035
1238
|
public async getMessages(scopeId: string, sessionId: string): Promise<IFlexMessage[]> {
|
|
@@ -1516,6 +1719,8 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1516
1719
|
options: IFlexPromptOptions,
|
|
1517
1720
|
scheduleKey?: string,
|
|
1518
1721
|
debounceMs?: number,
|
|
1722
|
+
subagentAdmission = false,
|
|
1723
|
+
admissionSignal?: AbortSignal,
|
|
1519
1724
|
): Promise<{ admission: IFlexPromptQueueAdmission; started: Promise<IFlexPromptAdmission> }> {
|
|
1520
1725
|
this.assertOpen();
|
|
1521
1726
|
const normalizedPrompt = normalizeFlexPrompt(prompt);
|
|
@@ -1536,20 +1741,35 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1536
1741
|
resolveSettled: () => resolveSettled(),
|
|
1537
1742
|
};
|
|
1538
1743
|
this.pendingPromptAdmissionOwners.add(pendingOwner);
|
|
1744
|
+
const admissionSignals = [
|
|
1745
|
+
pendingOwner.controller.signal,
|
|
1746
|
+
...(admissionSignal === undefined ? [] : [admissionSignal]),
|
|
1747
|
+
];
|
|
1539
1748
|
let abortAdmission!: () => void;
|
|
1540
1749
|
const abortPromise = new Promise<never>((_resolve, reject) => {
|
|
1541
|
-
abortAdmission = () =>
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1750
|
+
abortAdmission = () => {
|
|
1751
|
+
const aborted = admissionSignals.find((signal) => signal.aborted);
|
|
1752
|
+
reject(aborted?.reason ?? new FlexHarnessAbortError());
|
|
1753
|
+
};
|
|
1754
|
+
for (const signal of admissionSignals) {
|
|
1755
|
+
signal.addEventListener('abort', abortAdmission, { once: true });
|
|
1756
|
+
}
|
|
1757
|
+
if (admissionSignals.some((signal) => signal.aborted)) abortAdmission();
|
|
1546
1758
|
});
|
|
1547
1759
|
try {
|
|
1548
1760
|
const resolved = await Promise.race([this.resolveState(scopeId), abortPromise]);
|
|
1549
1761
|
const state = resolved.state;
|
|
1550
1762
|
await Promise.race([state.scopeQueue, abortPromise]);
|
|
1763
|
+
if (admissionSignal?.aborted) {
|
|
1764
|
+
throw admissionSignal.reason ?? new FlexHarnessAbortError();
|
|
1765
|
+
}
|
|
1551
1766
|
this.assertStateAcceptingWork(state);
|
|
1552
1767
|
const stored = this.requireSession(state, sessionId);
|
|
1768
|
+
if (stored.session.agent !== undefined && !subagentAdmission) {
|
|
1769
|
+
throw new FlexHarnessValidationError(
|
|
1770
|
+
'Subagent sessions can only be prompted through the foreground task tool.',
|
|
1771
|
+
);
|
|
1772
|
+
}
|
|
1553
1773
|
if (stored.outstandingPromptsById.size >= this.promptQueueLimits.maxOutstandingPromptsPerSession) {
|
|
1554
1774
|
throw new FlexHarnessQueueFullError(
|
|
1555
1775
|
`Session "${sessionId}" has reached its outstanding prompt limit.`,
|
|
@@ -1615,7 +1835,9 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1615
1835
|
started,
|
|
1616
1836
|
};
|
|
1617
1837
|
} finally {
|
|
1618
|
-
|
|
1838
|
+
for (const signal of admissionSignals) {
|
|
1839
|
+
signal.removeEventListener('abort', abortAdmission);
|
|
1840
|
+
}
|
|
1619
1841
|
this.pendingPromptAdmissionOwners.delete(pendingOwner);
|
|
1620
1842
|
releasePendingAdmission();
|
|
1621
1843
|
pendingOwner.resolveSettled();
|
|
@@ -1714,6 +1936,8 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1714
1936
|
callbacksClosed: false,
|
|
1715
1937
|
reasoningPartIds: new Map(),
|
|
1716
1938
|
toolPartIds: new Map(),
|
|
1939
|
+
subagentCallCount: 0,
|
|
1940
|
+
subagentSessionIds: new Set(),
|
|
1717
1941
|
pendingPermissionIds: new Set(),
|
|
1718
1942
|
phase: 'admitting',
|
|
1719
1943
|
completion: queued.completion,
|
|
@@ -2109,28 +2333,36 @@ export class FlexHarness<TScope = unknown> {
|
|
|
2109
2333
|
queued.status = 'running';
|
|
2110
2334
|
this.emitPromptQueueEvent(queued, 'prompt.running');
|
|
2111
2335
|
}
|
|
2336
|
+
const resolverRelationship = {
|
|
2337
|
+
...(run.stored.session.parentSessionId === undefined
|
|
2338
|
+
? {}
|
|
2339
|
+
: { parentSessionId: run.stored.session.parentSessionId }),
|
|
2340
|
+
...(run.stored.session.agent === undefined ? {} : { agent: run.stored.session.agent }),
|
|
2341
|
+
};
|
|
2112
2342
|
const modelOutcome = Promise.resolve()
|
|
2113
|
-
.then(() => this.modelResolver.resolveModel({
|
|
2343
|
+
.then(() => this.modelResolver.resolveModel(Object.freeze({
|
|
2114
2344
|
scopeId: run.scopeId,
|
|
2115
2345
|
scope: run.scope as TScope,
|
|
2116
2346
|
sessionId: run.sessionId,
|
|
2117
2347
|
runId: run.runId,
|
|
2118
2348
|
...(options.modelHint ? { modelHint: options.modelHint } : {}),
|
|
2349
|
+
...resolverRelationship,
|
|
2119
2350
|
signal,
|
|
2120
|
-
}))
|
|
2351
|
+
})))
|
|
2121
2352
|
.then(
|
|
2122
2353
|
(value): IResolverOutcome<IFlexResolvedModel> => ({ source: 'model', success: true, value }),
|
|
2123
2354
|
(error): IResolverOutcome<IFlexResolvedModel> => ({ source: 'model', success: false, error }),
|
|
2124
2355
|
);
|
|
2125
2356
|
const toolOutcome = Promise.resolve()
|
|
2126
|
-
.then(() => this.toolProvider?.provideTools({
|
|
2357
|
+
.then(() => this.toolProvider?.provideTools(Object.freeze({
|
|
2127
2358
|
scopeId: run.scopeId,
|
|
2128
2359
|
scope: run.scope as TScope,
|
|
2129
2360
|
sessionId: run.sessionId,
|
|
2130
2361
|
runId: run.runId,
|
|
2362
|
+
...resolverRelationship,
|
|
2131
2363
|
signal,
|
|
2132
2364
|
requestPermission: (request) => this.requestPermission(run.state, run, request),
|
|
2133
|
-
}))
|
|
2365
|
+
})))
|
|
2134
2366
|
.then(
|
|
2135
2367
|
(value): IResolverOutcome<IFlexToolHandle | undefined> => ({ source: 'tools', success: true, value }),
|
|
2136
2368
|
(error): IResolverOutcome<IFlexToolHandle | undefined> => ({ source: 'tools', success: false, error }),
|
|
@@ -2197,9 +2429,21 @@ export class FlexHarness<TScope = unknown> {
|
|
|
2197
2429
|
run.modelResolution = model!;
|
|
2198
2430
|
let tools: TFlexAgentToolSet | undefined;
|
|
2199
2431
|
try {
|
|
2200
|
-
|
|
2432
|
+
const providedTools = toolHandle?.tools;
|
|
2433
|
+
if (
|
|
2434
|
+
this.subagents.size > 0
|
|
2435
|
+
&& providedTools
|
|
2436
|
+
&& Object.prototype.hasOwnProperty.call(providedTools, 'task')
|
|
2437
|
+
) {
|
|
2438
|
+
throw new FlexHarnessValidationError('The application tool provider cannot define reserved tool "task".');
|
|
2439
|
+
}
|
|
2440
|
+
const combinedTools: Record<string, unknown> = { ...(providedTools ?? {}) };
|
|
2441
|
+
if (this.subagents.size > 0 && (run.stored.session.depth ?? 0) < this.maxSubagentDepth) {
|
|
2442
|
+
combinedTools.task = this.createSubagentTool(run);
|
|
2443
|
+
}
|
|
2444
|
+
tools = Object.keys(combinedTools).length > 0
|
|
2201
2445
|
? wrapToolSet(
|
|
2202
|
-
|
|
2446
|
+
combinedTools as TFlexAgentToolSet,
|
|
2203
2447
|
this.toolOutputLimits,
|
|
2204
2448
|
(toolError) => this.projectExternalError(run, toolError, 'toolExecution'),
|
|
2205
2449
|
)
|
|
@@ -2247,6 +2491,426 @@ export class FlexHarness<TScope = unknown> {
|
|
|
2247
2491
|
}
|
|
2248
2492
|
}
|
|
2249
2493
|
|
|
2494
|
+
private createSubagentTool(run: IActiveRun): unknown {
|
|
2495
|
+
const available = [...this.subagents.values()]
|
|
2496
|
+
.map((definition) => `- ${definition.name}: ${definition.description}`)
|
|
2497
|
+
.join('\n');
|
|
2498
|
+
return plugins.tool({
|
|
2499
|
+
description: `Run one configured FlexHarness subagent in the foreground and return its final text.\nAvailable subagents:\n${available}`,
|
|
2500
|
+
inputSchema: plugins.z.object({
|
|
2501
|
+
description: plugins.z.string(),
|
|
2502
|
+
prompt: plugins.z.string(),
|
|
2503
|
+
subagentType: plugins.z.string(),
|
|
2504
|
+
taskId: plugins.z.string().optional(),
|
|
2505
|
+
}).strict(),
|
|
2506
|
+
execute: (input: IFlexSubagentTaskInput, options?: { toolCallId?: string }) =>
|
|
2507
|
+
this.executeSubagentTask(run, input, options?.toolCallId),
|
|
2508
|
+
});
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
private async executeSubagentTask(
|
|
2512
|
+
run: IActiveRun,
|
|
2513
|
+
input: IFlexSubagentTaskInput,
|
|
2514
|
+
toolCallId: string | undefined,
|
|
2515
|
+
): Promise<{ taskId: string; status: 'completed'; text: string; model: IFlexModelIdentity }> {
|
|
2516
|
+
run.subagentCallCount++;
|
|
2517
|
+
if (run.subagentCallCount > this.maxSubagentCallsPerRun) {
|
|
2518
|
+
throw new FlexHarnessValidationError(
|
|
2519
|
+
`Run "${run.runId}" exceeds maxSubagentCallsPerRun (${this.maxSubagentCallsPerRun}).`,
|
|
2520
|
+
);
|
|
2521
|
+
}
|
|
2522
|
+
validateUtf8String(
|
|
2523
|
+
input.description,
|
|
2524
|
+
'task description',
|
|
2525
|
+
maxSubagentTaskDescriptionBytes,
|
|
2526
|
+
true,
|
|
2527
|
+
);
|
|
2528
|
+
validateUtf8String(input.prompt, 'task prompt', maxSubagentPromptBytes, true);
|
|
2529
|
+
validateUtf8String(input.subagentType, 'subagentType', maxSubagentNameBytes, true);
|
|
2530
|
+
if (input.taskId !== undefined) {
|
|
2531
|
+
validateUtf8String(input.taskId, 'taskId', maxSubagentTaskIdBytes, true);
|
|
2532
|
+
}
|
|
2533
|
+
validateUtf8String(toolCallId, 'task toolCallId', maxTransferIdentifierBytes, true);
|
|
2534
|
+
const definition = this.subagents.get(input.subagentType);
|
|
2535
|
+
if (!definition || (run.stored.session.depth ?? 0) >= this.maxSubagentDepth) {
|
|
2536
|
+
throw new FlexHarnessValidationError(`Subagent "${input.subagentType}" is not available.`);
|
|
2537
|
+
}
|
|
2538
|
+
const reservedSessionId = input.taskId ?? this.createSubagentSessionId(
|
|
2539
|
+
run.state.storageKey,
|
|
2540
|
+
run.sessionId,
|
|
2541
|
+
run.runId,
|
|
2542
|
+
toolCallId,
|
|
2543
|
+
);
|
|
2544
|
+
if (run.subagentSessionIds.has(reservedSessionId)) {
|
|
2545
|
+
throw new FlexHarnessValidationError(
|
|
2546
|
+
`Subagent task "${reservedSessionId}" has already been acquired by this parent run.`,
|
|
2547
|
+
);
|
|
2548
|
+
}
|
|
2549
|
+
run.subagentSessionIds.add(reservedSessionId);
|
|
2550
|
+
let child: IStoredSessionState | undefined;
|
|
2551
|
+
let childCreated = false;
|
|
2552
|
+
let queued: Awaited<ReturnType<typeof this.enqueuePromptInternal>> | undefined;
|
|
2553
|
+
let admission: IFlexPromptAdmission | undefined;
|
|
2554
|
+
const abortChild = () => {
|
|
2555
|
+
const reason = run.controller.signal.reason instanceof FlexHarnessAbortError
|
|
2556
|
+
? run.controller.signal.reason
|
|
2557
|
+
: this.trustInternalError(new FlexHarnessAbortError('The parent run was aborted.'));
|
|
2558
|
+
const ownedChild = child ?? run.state.sessions.get(reservedSessionId);
|
|
2559
|
+
if (
|
|
2560
|
+
ownedChild
|
|
2561
|
+
&& ownedChild.session.parentSessionId === run.sessionId
|
|
2562
|
+
&& ownedChild.session.agent === definition.name
|
|
2563
|
+
&& run.state.initializingSessions.has(reservedSessionId)
|
|
2564
|
+
) this.abortCompactorLifecycle(ownedChild, reason);
|
|
2565
|
+
const childQueue = queued === undefined
|
|
2566
|
+
? undefined
|
|
2567
|
+
: ownedChild?.outstandingPromptsById.get(queued.admission.queueId);
|
|
2568
|
+
if (childQueue) this.cancelQueuedPrompt(childQueue, reason);
|
|
2569
|
+
else if (admission) {
|
|
2570
|
+
this.abortExactRun(run.state, reservedSessionId, admission.runId, reason);
|
|
2571
|
+
}
|
|
2572
|
+
};
|
|
2573
|
+
run.controller.signal.addEventListener('abort', abortChild, { once: true });
|
|
2574
|
+
if (run.controller.signal.aborted) abortChild();
|
|
2575
|
+
try {
|
|
2576
|
+
await this.requestPermission(run.state, run, {
|
|
2577
|
+
kind: 'subagent.start',
|
|
2578
|
+
description: `Start foreground subagent "${definition.name}": ${input.description}`,
|
|
2579
|
+
toolCallId,
|
|
2580
|
+
metadata: {
|
|
2581
|
+
agent: definition.name,
|
|
2582
|
+
description: input.description,
|
|
2583
|
+
...(input.taskId === undefined ? {} : { taskId: input.taskId }),
|
|
2584
|
+
},
|
|
2585
|
+
});
|
|
2586
|
+
const acquired = await this.acquireSubagentSession(
|
|
2587
|
+
run,
|
|
2588
|
+
definition,
|
|
2589
|
+
toolCallId,
|
|
2590
|
+
input.taskId,
|
|
2591
|
+
reservedSessionId,
|
|
2592
|
+
);
|
|
2593
|
+
child = acquired.stored;
|
|
2594
|
+
childCreated = acquired.created;
|
|
2595
|
+
if (run.controller.signal.aborted) throw run.controller.signal.reason;
|
|
2596
|
+
this.updateSubagentToolPart(run, toolCallId, child.session.sessionId);
|
|
2597
|
+
const childOptions: IFlexPromptOptions = {
|
|
2598
|
+
...(definition.modelHint === undefined ? {} : { modelHint: definition.modelHint }),
|
|
2599
|
+
...(definition.system === undefined ? {} : { system: definition.system }),
|
|
2600
|
+
...(definition.maxSteps === undefined ? {} : { maxSteps: definition.maxSteps }),
|
|
2601
|
+
};
|
|
2602
|
+
queued = await this.enqueuePromptInternal(
|
|
2603
|
+
run.scopeId,
|
|
2604
|
+
child.session.sessionId,
|
|
2605
|
+
input.prompt,
|
|
2606
|
+
childOptions,
|
|
2607
|
+
undefined,
|
|
2608
|
+
undefined,
|
|
2609
|
+
true,
|
|
2610
|
+
run.controller.signal,
|
|
2611
|
+
);
|
|
2612
|
+
admission = await queued.started;
|
|
2613
|
+
const result = await queued.admission.completion;
|
|
2614
|
+
this.updateSubagentToolPart(run, toolCallId, child.session.sessionId, result.model);
|
|
2615
|
+
return {
|
|
2616
|
+
taskId: child.session.sessionId,
|
|
2617
|
+
status: 'completed',
|
|
2618
|
+
text: truncateUtf8(result.assistantMessage.parts
|
|
2619
|
+
.filter((part) => part.type === 'text')
|
|
2620
|
+
.map((part) => part.text)
|
|
2621
|
+
.join(''), maxSubagentResultTextBytes),
|
|
2622
|
+
model: publicSnapshot(result.model),
|
|
2623
|
+
};
|
|
2624
|
+
} catch (error) {
|
|
2625
|
+
if (
|
|
2626
|
+
childCreated
|
|
2627
|
+
&& child
|
|
2628
|
+
&& queued === undefined
|
|
2629
|
+
&& run.state.sessions.get(child.session.sessionId) === child
|
|
2630
|
+
&& !run.state.tombstones.has(child.session.sessionId)
|
|
2631
|
+
) {
|
|
2632
|
+
try {
|
|
2633
|
+
await this.deleteSessionInternal(
|
|
2634
|
+
run.state,
|
|
2635
|
+
run.scopeId,
|
|
2636
|
+
run.scope as TScope,
|
|
2637
|
+
child.session.sessionId,
|
|
2638
|
+
);
|
|
2639
|
+
} catch (cleanupError) {
|
|
2640
|
+
throw combineErrors([error, cleanupError]);
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
const childModel = child?.messages
|
|
2644
|
+
.filter((message) => message.runId === admission?.runId && message.role === 'assistant')
|
|
2645
|
+
.at(-1)?.model
|
|
2646
|
+
?? child?.stagedTerminals.find((terminal) => terminal.runId === admission?.runId)?.model;
|
|
2647
|
+
const parentPart = run.callbackParts.find((part) =>
|
|
2648
|
+
part.type === 'tool' && part.toolCallId === toolCallId);
|
|
2649
|
+
if (
|
|
2650
|
+
child
|
|
2651
|
+
&& childModel !== undefined
|
|
2652
|
+
&& parentPart?.type === 'tool'
|
|
2653
|
+
&& parentPart.model === undefined
|
|
2654
|
+
) {
|
|
2655
|
+
this.updateSubagentToolPart(run, toolCallId, child.session.sessionId, childModel);
|
|
2656
|
+
}
|
|
2657
|
+
throw error;
|
|
2658
|
+
} finally {
|
|
2659
|
+
run.controller.signal.removeEventListener('abort', abortChild);
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
private async acquireSubagentSession(
|
|
2664
|
+
run: IActiveRun,
|
|
2665
|
+
definition: Readonly<IFlexSubagentDefinition>,
|
|
2666
|
+
toolCallId: string,
|
|
2667
|
+
taskId?: string,
|
|
2668
|
+
reservedSessionId?: string,
|
|
2669
|
+
): Promise<IFlexSubagentAcquisition> {
|
|
2670
|
+
const state = run.state;
|
|
2671
|
+
const sessionId = reservedSessionId ?? taskId ?? this.createSubagentSessionId(
|
|
2672
|
+
state.storageKey,
|
|
2673
|
+
run.sessionId,
|
|
2674
|
+
run.runId,
|
|
2675
|
+
toolCallId,
|
|
2676
|
+
);
|
|
2677
|
+
let metadata: IFlexSession | undefined;
|
|
2678
|
+
let placeholder: IStoredSessionState | undefined;
|
|
2679
|
+
let initializationCompletion: Promise<void> | undefined;
|
|
2680
|
+
let resolveInitialization: (() => void) | undefined;
|
|
2681
|
+
let initializationCompleted = false;
|
|
2682
|
+
const completeInitialization = () => {
|
|
2683
|
+
if (initializationCompleted || initializationCompletion === undefined) return;
|
|
2684
|
+
initializationCompleted = true;
|
|
2685
|
+
state.initializingSessions.delete(sessionId);
|
|
2686
|
+
if (state.sessionInitializations.get(sessionId) === initializationCompletion) {
|
|
2687
|
+
state.sessionInitializations.delete(sessionId);
|
|
2688
|
+
}
|
|
2689
|
+
resolveInitialization?.();
|
|
2690
|
+
};
|
|
2691
|
+
try {
|
|
2692
|
+
await this.mutateScope(state, () => {
|
|
2693
|
+
if (state.sessions.get(run.sessionId) !== run.stored || state.tombstones.has(run.sessionId)) {
|
|
2694
|
+
throw new FlexHarnessAbortError('The parent session no longer owns this subagent request.');
|
|
2695
|
+
}
|
|
2696
|
+
if (state.activeRuns.get(run.sessionId) !== run || run.callbacksClosed) {
|
|
2697
|
+
throw new FlexHarnessAbortError('The parent run no longer owns this subagent request.');
|
|
2698
|
+
}
|
|
2699
|
+
if (run.stored.session.agent !== undefined && !this.subagents.has(run.stored.session.agent)) {
|
|
2700
|
+
throw new FlexHarnessValidationError(`Parent subagent "${run.stored.session.agent}" is disabled.`);
|
|
2701
|
+
}
|
|
2702
|
+
if ((run.stored.session.depth ?? 0) >= this.maxSubagentDepth) {
|
|
2703
|
+
throw new FlexHarnessValidationError('The maximum subagent depth has been reached.');
|
|
2704
|
+
}
|
|
2705
|
+
let ancestor: IFlexSession | undefined = run.stored.session;
|
|
2706
|
+
const visited = new Set<string>();
|
|
2707
|
+
while (ancestor) {
|
|
2708
|
+
if (visited.has(ancestor.sessionId) || state.tombstones.has(ancestor.sessionId)) {
|
|
2709
|
+
throw new FlexHarnessValidationError('The subagent ancestor chain is invalid or deleted.');
|
|
2710
|
+
}
|
|
2711
|
+
visited.add(ancestor.sessionId);
|
|
2712
|
+
ancestor = ancestor.parentSessionId
|
|
2713
|
+
? state.sessions.get(ancestor.parentSessionId)?.session
|
|
2714
|
+
: undefined;
|
|
2715
|
+
}
|
|
2716
|
+
const existing = state.sessions.get(sessionId);
|
|
2717
|
+
if (existing) {
|
|
2718
|
+
if (
|
|
2719
|
+
existing.session.parentSessionId !== run.sessionId
|
|
2720
|
+
|| existing.session.agent !== definition.name
|
|
2721
|
+
) {
|
|
2722
|
+
throw new FlexHarnessValidationError(`Task session "${sessionId}" is not owned by this parent and agent.`);
|
|
2723
|
+
}
|
|
2724
|
+
if (
|
|
2725
|
+
taskId === undefined
|
|
2726
|
+
&& (existing.session.parentRunId !== run.runId
|
|
2727
|
+
|| existing.session.parentToolCallId !== toolCallId)
|
|
2728
|
+
) {
|
|
2729
|
+
throw new FlexHarnessValidationError(
|
|
2730
|
+
`Subagent task "${sessionId}" does not match its deterministic invocation origin.`,
|
|
2731
|
+
);
|
|
2732
|
+
}
|
|
2733
|
+
if (state.initializingSessions.has(sessionId)) {
|
|
2734
|
+
throw new FlexHarnessSessionBusyError(sessionId, 'is still being initialized');
|
|
2735
|
+
}
|
|
2736
|
+
if (taskId === undefined && existing.messages.length > 0) {
|
|
2737
|
+
throw new FlexHarnessValidationError(
|
|
2738
|
+
`Subagent task "${sessionId}" has an uncertain prior execution and cannot be replayed automatically.`,
|
|
2739
|
+
);
|
|
2740
|
+
}
|
|
2741
|
+
if (state.activeRuns.has(sessionId) || existing.session.status !== 'idle') {
|
|
2742
|
+
throw new FlexHarnessSessionBusyError(sessionId, 'cannot be resumed while it is not idle');
|
|
2743
|
+
}
|
|
2744
|
+
if (taskId !== undefined && existing.session.parentRunId === run.runId) {
|
|
2745
|
+
throw new FlexHarnessValidationError('taskId can only resume a child from a later parent run.');
|
|
2746
|
+
}
|
|
2747
|
+
placeholder = existing;
|
|
2748
|
+
return;
|
|
2749
|
+
}
|
|
2750
|
+
if (taskId !== undefined || state.tombstones.has(sessionId)) {
|
|
2751
|
+
throw new FlexHarnessNotFoundError('Subagent task', sessionId);
|
|
2752
|
+
}
|
|
2753
|
+
const timestamp = new Date().toISOString();
|
|
2754
|
+
metadata = {
|
|
2755
|
+
scopeId: run.scopeId,
|
|
2756
|
+
sessionId,
|
|
2757
|
+
title: `Subagent: ${definition.name}`,
|
|
2758
|
+
createdAt: timestamp,
|
|
2759
|
+
updatedAt: timestamp,
|
|
2760
|
+
status: 'idle',
|
|
2761
|
+
activity: { status: 'idle' },
|
|
2762
|
+
parentSessionId: run.sessionId,
|
|
2763
|
+
parentRunId: run.runId,
|
|
2764
|
+
parentToolCallId: toolCallId,
|
|
2765
|
+
agent: definition.name,
|
|
2766
|
+
depth: (run.stored.session.depth ?? 0) + 1,
|
|
2767
|
+
};
|
|
2768
|
+
placeholder = this.createUninitializedStoredSession(metadata, state.storageKey);
|
|
2769
|
+
state.sessions.set(sessionId, placeholder);
|
|
2770
|
+
state.initializingSessions.add(sessionId);
|
|
2771
|
+
initializationCompletion = new Promise<void>((resolve) => {
|
|
2772
|
+
resolveInitialization = resolve;
|
|
2773
|
+
});
|
|
2774
|
+
state.sessionInitializations.set(sessionId, initializationCompletion);
|
|
2775
|
+
});
|
|
2776
|
+
if (!metadata) return { stored: placeholder!, created: false };
|
|
2777
|
+
const loaded = await this.loadSessionRuntime(
|
|
2778
|
+
state,
|
|
2779
|
+
metadata,
|
|
2780
|
+
run.scopeId,
|
|
2781
|
+
run.scope as TScope,
|
|
2782
|
+
placeholder!.compactorLifecycleController,
|
|
2783
|
+
);
|
|
2784
|
+
const parentLostOwnership = run.controller.signal.aborted
|
|
2785
|
+
|| state.sessions.get(run.sessionId) !== run.stored
|
|
2786
|
+
|| state.activeRuns.get(run.sessionId) !== run
|
|
2787
|
+
|| run.callbacksClosed;
|
|
2788
|
+
if (
|
|
2789
|
+
parentLostOwnership
|
|
2790
|
+
|| state.sessions.get(sessionId) !== placeholder
|
|
2791
|
+
|| state.tombstones.has(sessionId)
|
|
2792
|
+
) {
|
|
2793
|
+
const aborted = run.controller.signal.aborted
|
|
2794
|
+
? run.controller.signal.reason
|
|
2795
|
+
: new FlexHarnessAbortError('The subagent session lost parent ownership during initialization.');
|
|
2796
|
+
try {
|
|
2797
|
+
await this.closeStoredSession(loaded);
|
|
2798
|
+
} catch (error) {
|
|
2799
|
+
this.orphanedStoredSessions.add(loaded);
|
|
2800
|
+
throw combineErrors([aborted, error]);
|
|
2801
|
+
}
|
|
2802
|
+
throw aborted;
|
|
2803
|
+
}
|
|
2804
|
+
state.sessions.set(sessionId, loaded);
|
|
2805
|
+
this.emitEvent(run.scopeId, sessionId, {
|
|
2806
|
+
type: 'session.created',
|
|
2807
|
+
session: publicSnapshot(metadata),
|
|
2808
|
+
});
|
|
2809
|
+
return { stored: loaded, created: true };
|
|
2810
|
+
} catch (error) {
|
|
2811
|
+
if (metadata === undefined) throw error;
|
|
2812
|
+
if (
|
|
2813
|
+
state.tombstones.has(sessionId)
|
|
2814
|
+
&& placeholder?.compactorLifecycleController.signal.aborted
|
|
2815
|
+
) {
|
|
2816
|
+
throw placeholder.compactorLifecycleController.signal.reason;
|
|
2817
|
+
}
|
|
2818
|
+
const projected = this.projectExternalError(run, error, 'agentSession');
|
|
2819
|
+
if (
|
|
2820
|
+
placeholder === undefined
|
|
2821
|
+
|| (state.sessions.get(sessionId) !== placeholder && !state.tombstones.has(sessionId))
|
|
2822
|
+
) throw projected;
|
|
2823
|
+
if (state.tombstones.has(sessionId)) throw projected;
|
|
2824
|
+
try {
|
|
2825
|
+
await this.mutateScope(state, () => {
|
|
2826
|
+
if (state.sessions.get(sessionId) !== placeholder) return;
|
|
2827
|
+
state.sessions.delete(sessionId);
|
|
2828
|
+
state.tombstones.set(sessionId, {
|
|
2829
|
+
sessionId,
|
|
2830
|
+
deletedAt: new Date().toISOString(),
|
|
2831
|
+
rootSessionId: sessionId,
|
|
2832
|
+
depth: 0,
|
|
2833
|
+
parentSessionId: run.sessionId,
|
|
2834
|
+
});
|
|
2835
|
+
}, true);
|
|
2836
|
+
completeInitialization();
|
|
2837
|
+
await this.finishTombstoneCleanup(
|
|
2838
|
+
state,
|
|
2839
|
+
sessionId,
|
|
2840
|
+
run.scopeId,
|
|
2841
|
+
{ scopeId: run.scopeId, scope: run.scope as TScope },
|
|
2842
|
+
);
|
|
2843
|
+
} catch (cleanupError) {
|
|
2844
|
+
const combined = combineErrors([
|
|
2845
|
+
projected,
|
|
2846
|
+
this.projectExternalError(run, cleanupError, 'persistence'),
|
|
2847
|
+
]);
|
|
2848
|
+
this.deferNamespaceDrain(
|
|
2849
|
+
state,
|
|
2850
|
+
combined,
|
|
2851
|
+
{ scopeId: run.scopeId, scope: run.scope as TScope },
|
|
2852
|
+
);
|
|
2853
|
+
throw combined;
|
|
2854
|
+
}
|
|
2855
|
+
throw projected;
|
|
2856
|
+
} finally {
|
|
2857
|
+
completeInitialization();
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2860
|
+
|
|
2861
|
+
private createSubagentSessionId(
|
|
2862
|
+
storageKey: string,
|
|
2863
|
+
parentSessionId: string,
|
|
2864
|
+
parentRunId: string,
|
|
2865
|
+
parentToolCallId: string,
|
|
2866
|
+
): string {
|
|
2867
|
+
return `subagent_${plugins.crypto.createHash('sha256').update(JSON.stringify([
|
|
2868
|
+
'flexharness-subagent-v1',
|
|
2869
|
+
storageKey,
|
|
2870
|
+
parentSessionId,
|
|
2871
|
+
parentRunId,
|
|
2872
|
+
parentToolCallId,
|
|
2873
|
+
])).digest('hex')}`;
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
private updateSubagentToolPart(
|
|
2877
|
+
run: IActiveRun,
|
|
2878
|
+
toolCallId: string,
|
|
2879
|
+
childSessionId: string,
|
|
2880
|
+
model?: IFlexModelIdentity,
|
|
2881
|
+
): void {
|
|
2882
|
+
const partId = run.toolPartIds.get(toolCallId);
|
|
2883
|
+
const part = run.callbackParts.find((entry) => entry.partId === partId && entry.type === 'tool');
|
|
2884
|
+
if (!part || part.type !== 'tool' || part.status !== 'running') {
|
|
2885
|
+
throw new FlexHarnessValidationError(`Running task part "${toolCallId}" is unavailable.`);
|
|
2886
|
+
}
|
|
2887
|
+
const bytes = Buffer.byteLength(childSessionId, 'utf8')
|
|
2888
|
+
+ (model === undefined ? 0 : jsonBytes(model));
|
|
2889
|
+
if (!this.reserveCallbackCapacity(run, 1, bytes, 0)) {
|
|
2890
|
+
throw run.callbackError ?? new FlexHarnessCallbackOverflowError('Task metadata exceeded callback limits.');
|
|
2891
|
+
}
|
|
2892
|
+
part.childSessionId = childSessionId;
|
|
2893
|
+
if (model !== undefined) part.model = cloneSerializable(model);
|
|
2894
|
+
this.emitPartEvent(run, 'part.updated', part);
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2897
|
+
private abortExactRun(
|
|
2898
|
+
state: IStorageState,
|
|
2899
|
+
sessionId: string,
|
|
2900
|
+
runId: string,
|
|
2901
|
+
reason: unknown,
|
|
2902
|
+
): boolean {
|
|
2903
|
+
const active = state.activeRuns.get(sessionId);
|
|
2904
|
+
if (!active || active.runId !== runId || active.phase === 'finalizing' || active.phase === 'promoting') {
|
|
2905
|
+
return false;
|
|
2906
|
+
}
|
|
2907
|
+
const cancellation = reason instanceof FlexHarnessAbortError
|
|
2908
|
+
? reason
|
|
2909
|
+
: this.trustInternalError(new FlexHarnessAbortError('The parent run was aborted.'));
|
|
2910
|
+
this.cancelRun(active, cancellation);
|
|
2911
|
+
return true;
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2250
2914
|
private createReservation(
|
|
2251
2915
|
run: IActiveRun,
|
|
2252
2916
|
prompt: INormalizedFlexPrompt,
|
|
@@ -3224,10 +3888,37 @@ export class FlexHarness<TScope = unknown> {
|
|
|
3224
3888
|
scopeChanged = (await this.repairLoadedSession(state.storageKey, stored)) || scopeChanged;
|
|
3225
3889
|
if (stored.projectionRevision < 0) throw new Error('Invalid projection revision.');
|
|
3226
3890
|
}
|
|
3227
|
-
for (const
|
|
3891
|
+
for (const stored of state.sessions.values()) {
|
|
3892
|
+
let ancestorId = stored.session.parentSessionId;
|
|
3893
|
+
const visited = new Set<string>([stored.session.sessionId]);
|
|
3894
|
+
while (ancestorId) {
|
|
3895
|
+
if (visited.has(ancestorId)) {
|
|
3896
|
+
throw new FlexHarnessValidationError(
|
|
3897
|
+
`Live session "${stored.session.sessionId}" has a cyclic ancestor chain.`,
|
|
3898
|
+
);
|
|
3899
|
+
}
|
|
3900
|
+
visited.add(ancestorId);
|
|
3901
|
+
if (state.tombstones.has(ancestorId)) {
|
|
3902
|
+
throw new FlexHarnessValidationError(
|
|
3903
|
+
`Live session "${stored.session.sessionId}" descends from tombstoned ancestor "${ancestorId}".`,
|
|
3904
|
+
);
|
|
3905
|
+
}
|
|
3906
|
+
ancestorId = state.sessions.get(ancestorId)?.session.parentSessionId;
|
|
3907
|
+
}
|
|
3908
|
+
}
|
|
3909
|
+
const tombstoneRoots = this.orderTombstoneRootsChildFirst(
|
|
3910
|
+
state,
|
|
3911
|
+
[...new Set([...state.tombstones.values()].map((tombstone) =>
|
|
3912
|
+
tombstone.rootSessionId ?? tombstone.sessionId))],
|
|
3913
|
+
);
|
|
3914
|
+
for (const rootSessionId of tombstoneRoots) {
|
|
3915
|
+
if (this.descendantTombstoneRoots(state, rootSessionId).length > 0) continue;
|
|
3228
3916
|
try {
|
|
3229
|
-
|
|
3230
|
-
|
|
3917
|
+
const group = this.tombstoneGroup(state, rootSessionId);
|
|
3918
|
+
for (const tombstone of group) {
|
|
3919
|
+
await this.cleanupSessionDomains(storageKey, tombstone.sessionId);
|
|
3920
|
+
}
|
|
3921
|
+
for (const tombstone of group) state.tombstones.delete(tombstone.sessionId);
|
|
3231
3922
|
scopeChanged = true;
|
|
3232
3923
|
} catch {
|
|
3233
3924
|
// A retained tombstone is retried by the next load or explicit delete call.
|
|
@@ -3260,6 +3951,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
3260
3951
|
private createUninitializedStoredSession(
|
|
3261
3952
|
session: IFlexSession,
|
|
3262
3953
|
storageKey: string,
|
|
3954
|
+
compactorLifecycleController = new AbortController(),
|
|
3263
3955
|
): IStoredSessionState {
|
|
3264
3956
|
const unavailable = new Proxy({} as plugins.IAgentSession, {
|
|
3265
3957
|
get() {
|
|
@@ -3283,7 +3975,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
3283
3975
|
agentEventStoreReleased: true,
|
|
3284
3976
|
executionContextCloseCompleted: true,
|
|
3285
3977
|
jobStoreReleased: true,
|
|
3286
|
-
compactorLifecycleController
|
|
3978
|
+
compactorLifecycleController,
|
|
3287
3979
|
promptQueue: [],
|
|
3288
3980
|
outstandingPromptsById: new Map(),
|
|
3289
3981
|
terminalPromptQueueEntries: new Map(),
|
|
@@ -3296,6 +3988,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
3296
3988
|
metadata: IFlexSession,
|
|
3297
3989
|
scopeId: string,
|
|
3298
3990
|
scope: TScope,
|
|
3991
|
+
compactorLifecycleController = new AbortController(),
|
|
3299
3992
|
): Promise<IStoredSessionState> {
|
|
3300
3993
|
const sessionId = metadata.sessionId;
|
|
3301
3994
|
const compactorContext = this.createCompactorContext(
|
|
@@ -3380,7 +4073,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
3380
4073
|
executionContextCloseCompleted: executionContextHandle?.close === undefined,
|
|
3381
4074
|
jobStoreReleased: this.stores.jobs.releaseSession === undefined,
|
|
3382
4075
|
jobs: executionContextHandle?.context.jobs,
|
|
3383
|
-
compactorLifecycleController
|
|
4076
|
+
compactorLifecycleController,
|
|
3384
4077
|
promptQueue: [],
|
|
3385
4078
|
outstandingPromptsById: new Map(),
|
|
3386
4079
|
terminalPromptQueueEntries: new Map(),
|
|
@@ -3927,50 +4620,136 @@ export class FlexHarness<TScope = unknown> {
|
|
|
3927
4620
|
if (errors.length > 0) throw combineErrors(errors);
|
|
3928
4621
|
}
|
|
3929
4622
|
|
|
4623
|
+
private collectSessionSubtree(
|
|
4624
|
+
state: IStorageState,
|
|
4625
|
+
rootSessionId: string,
|
|
4626
|
+
): IStoredSessionState[] {
|
|
4627
|
+
const subtree: IStoredSessionState[] = [];
|
|
4628
|
+
const pending = [rootSessionId];
|
|
4629
|
+
const seen = new Set<string>();
|
|
4630
|
+
while (pending.length > 0) {
|
|
4631
|
+
const sessionId = pending.pop()!;
|
|
4632
|
+
if (seen.has(sessionId)) {
|
|
4633
|
+
throw new FlexHarnessValidationError('Session relationships contain a cycle.');
|
|
4634
|
+
}
|
|
4635
|
+
seen.add(sessionId);
|
|
4636
|
+
const stored = state.sessions.get(sessionId);
|
|
4637
|
+
if (!stored) continue;
|
|
4638
|
+
subtree.push(stored);
|
|
4639
|
+
const children = [...state.sessions.values()]
|
|
4640
|
+
.filter((candidate) => candidate.session.parentSessionId === sessionId)
|
|
4641
|
+
.map((candidate) => candidate.session.sessionId)
|
|
4642
|
+
.sort()
|
|
4643
|
+
.reverse();
|
|
4644
|
+
pending.push(...children);
|
|
4645
|
+
}
|
|
4646
|
+
return subtree;
|
|
4647
|
+
}
|
|
4648
|
+
|
|
4649
|
+
private tombstoneGroup(
|
|
4650
|
+
state: IStorageState,
|
|
4651
|
+
rootSessionId: string,
|
|
4652
|
+
): IFlexSessionTombstone[] {
|
|
4653
|
+
return [...state.tombstones.values()]
|
|
4654
|
+
.filter((tombstone) =>
|
|
4655
|
+
(tombstone.rootSessionId ?? tombstone.sessionId) === rootSessionId)
|
|
4656
|
+
.sort((left, right) =>
|
|
4657
|
+
(right.depth ?? 0) - (left.depth ?? 0)
|
|
4658
|
+
|| left.sessionId.localeCompare(right.sessionId));
|
|
4659
|
+
}
|
|
4660
|
+
|
|
4661
|
+
private descendantTombstoneRoots(
|
|
4662
|
+
state: IStorageState,
|
|
4663
|
+
ancestorSessionId: string,
|
|
4664
|
+
): string[] {
|
|
4665
|
+
return [...new Set([...state.tombstones.values()]
|
|
4666
|
+
.map((tombstone) => tombstone.rootSessionId ?? tombstone.sessionId)
|
|
4667
|
+
.filter((rootSessionId) =>
|
|
4668
|
+
rootSessionId !== ancestorSessionId
|
|
4669
|
+
&& this.isSessionAncestor(state, ancestorSessionId, rootSessionId)))]
|
|
4670
|
+
.sort((left, right) =>
|
|
4671
|
+
this.sessionAncestryDepth(state, right) - this.sessionAncestryDepth(state, left)
|
|
4672
|
+
|| left.localeCompare(right));
|
|
4673
|
+
}
|
|
4674
|
+
|
|
4675
|
+
private orderTombstoneRootsChildFirst(
|
|
4676
|
+
state: IStorageState,
|
|
4677
|
+
rootSessionIds: readonly string[],
|
|
4678
|
+
): string[] {
|
|
4679
|
+
return [...rootSessionIds].sort((left, right) =>
|
|
4680
|
+
this.sessionAncestryDepth(state, right) - this.sessionAncestryDepth(state, left)
|
|
4681
|
+
|| left.localeCompare(right));
|
|
4682
|
+
}
|
|
4683
|
+
|
|
3930
4684
|
private finishTombstoneCleanup(
|
|
3931
4685
|
state: IStorageState,
|
|
3932
|
-
|
|
4686
|
+
rootSessionId: string,
|
|
3933
4687
|
scopeId: string,
|
|
3934
|
-
|
|
4688
|
+
invocation?: { scopeId: string; scope: TScope },
|
|
3935
4689
|
): Promise<void> {
|
|
3936
|
-
const existing = state.tombstoneCleanups.get(
|
|
4690
|
+
const existing = state.tombstoneCleanups.get(rootSessionId);
|
|
3937
4691
|
if (existing) return existing;
|
|
3938
4692
|
let cleanup!: Promise<void>;
|
|
3939
|
-
cleanup = this.finishTombstoneCleanupInternal(
|
|
3940
|
-
|
|
3941
|
-
|
|
4693
|
+
cleanup = this.finishTombstoneCleanupInternal(
|
|
4694
|
+
state,
|
|
4695
|
+
rootSessionId,
|
|
4696
|
+
scopeId,
|
|
4697
|
+
invocation,
|
|
4698
|
+
).finally(() => {
|
|
4699
|
+
if (state.tombstoneCleanups.get(rootSessionId) === cleanup) {
|
|
4700
|
+
state.tombstoneCleanups.delete(rootSessionId);
|
|
3942
4701
|
}
|
|
3943
4702
|
});
|
|
3944
|
-
state.tombstoneCleanups.set(
|
|
4703
|
+
state.tombstoneCleanups.set(rootSessionId, cleanup);
|
|
3945
4704
|
return cleanup;
|
|
3946
4705
|
}
|
|
3947
4706
|
|
|
3948
4707
|
private async finishTombstoneCleanupInternal(
|
|
3949
4708
|
state: IStorageState,
|
|
3950
|
-
|
|
4709
|
+
rootSessionId: string,
|
|
3951
4710
|
scopeId: string,
|
|
3952
|
-
|
|
4711
|
+
invocation?: { scopeId: string; scope: TScope },
|
|
3953
4712
|
): Promise<void> {
|
|
3954
|
-
const
|
|
3955
|
-
|
|
4713
|
+
for (const descendantRoot of this.descendantTombstoneRoots(state, rootSessionId)) {
|
|
4714
|
+
await this.finishTombstoneCleanup(state, descendantRoot, scopeId, invocation);
|
|
4715
|
+
}
|
|
4716
|
+
const group = this.tombstoneGroup(state, rootSessionId);
|
|
4717
|
+
if (group.length === 0) return;
|
|
4718
|
+
const groupIds = new Set(group.map((tombstone) => tombstone.sessionId));
|
|
4719
|
+
const reason = this.trustInternalError(new FlexHarnessAbortError('The session subtree was deleted.'));
|
|
4720
|
+
const contextFor = (
|
|
4721
|
+
sessionId: string,
|
|
4722
|
+
retained?: IRetainedSessionCleanup,
|
|
4723
|
+
): IFlexAgentContextInvocation<TScope> | undefined => invocation
|
|
4724
|
+
? this.createCompactorContext(
|
|
4725
|
+
invocation.scopeId,
|
|
4726
|
+
invocation.scope,
|
|
4727
|
+
state.storageKey,
|
|
4728
|
+
sessionId,
|
|
4729
|
+
)
|
|
4730
|
+
: retained?.stored.compactorContext as IFlexAgentContextInvocation<TScope> | undefined;
|
|
4731
|
+
for (const sessionId of groupIds) {
|
|
4732
|
+
const retained = state.retainedSessionCleanups.get(sessionId);
|
|
4733
|
+
const context = contextFor(sessionId, retained);
|
|
4734
|
+
if (retained) {
|
|
4735
|
+
this.abortCompactorLifecycle(retained.stored, reason);
|
|
4736
|
+
this.cancelStoredPromptQueue(retained.stored, reason, undefined, context);
|
|
4737
|
+
}
|
|
4738
|
+
const run = state.activeRuns.get(sessionId);
|
|
4739
|
+
if (run) this.cancelRun(run, reason, context);
|
|
4740
|
+
}
|
|
4741
|
+
await Promise.allSettled([...groupIds]
|
|
4742
|
+
.map((sessionId) => state.sessionInitializations.get(sessionId))
|
|
4743
|
+
.filter((completion): completion is Promise<void> => completion !== undefined));
|
|
4744
|
+
for (const tombstone of group) {
|
|
4745
|
+
const sessionId = tombstone.sessionId;
|
|
4746
|
+
const retained = state.retainedSessionCleanups.get(sessionId);
|
|
3956
4747
|
const errors: unknown[] = [];
|
|
3957
|
-
const reason = this.trustInternalError(new FlexHarnessAbortError('The session was deleted.'));
|
|
3958
|
-
const invocation = scope === undefined
|
|
3959
|
-
? retained.stored.compactorContext
|
|
3960
|
-
: this.createCompactorContext(scopeId, scope, state.storageKey, sessionId);
|
|
3961
|
-
this.abortCompactorLifecycle(retained.stored, reason);
|
|
3962
|
-
this.cancelStoredPromptQueue(retained.stored, reason, undefined, invocation);
|
|
3963
4748
|
const run = state.activeRuns.get(sessionId);
|
|
3964
|
-
|
|
3965
|
-
if (
|
|
3966
|
-
const settled = await Promise.allSettled([run.completion]);
|
|
3967
|
-
if (settled[0].status === 'rejected') {
|
|
3968
|
-
this.appendUnexpectedErrors(errors, settled[0].reason);
|
|
3969
|
-
}
|
|
3970
|
-
}
|
|
3971
|
-
if (!retained.stored.agentSessionAbortCompleted) {
|
|
4749
|
+
const context = contextFor(sessionId, retained);
|
|
4750
|
+
if (retained && !retained.stored.agentSessionAbortCompleted) {
|
|
3972
4751
|
try {
|
|
3973
|
-
await this.abortStoredSession(retained.stored, reason,
|
|
4752
|
+
await this.abortStoredSession(retained.stored, reason, context);
|
|
3974
4753
|
} catch (error) {
|
|
3975
4754
|
errors.push(this.projectOperationError(
|
|
3976
4755
|
error,
|
|
@@ -3981,53 +4760,66 @@ export class FlexHarness<TScope = unknown> {
|
|
|
3981
4760
|
));
|
|
3982
4761
|
}
|
|
3983
4762
|
}
|
|
3984
|
-
if (
|
|
3985
|
-
const settled = await Promise.allSettled([
|
|
4763
|
+
if (run) {
|
|
4764
|
+
const settled = await Promise.allSettled([run.completion]);
|
|
3986
4765
|
if (settled[0].status === 'rejected') {
|
|
3987
4766
|
this.appendUnexpectedErrors(errors, settled[0].reason);
|
|
3988
4767
|
}
|
|
3989
4768
|
}
|
|
3990
|
-
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
|
|
3996
|
-
|
|
3997
|
-
|
|
3998
|
-
|
|
3999
|
-
|
|
4769
|
+
if (retained) {
|
|
4770
|
+
if (retained.stored.promptQueueDrain) {
|
|
4771
|
+
const settled = await Promise.allSettled([retained.stored.promptQueueDrain]);
|
|
4772
|
+
if (settled[0].status === 'rejected') errors.push(settled[0].reason);
|
|
4773
|
+
}
|
|
4774
|
+
try {
|
|
4775
|
+
await this.closeStoredSession(retained.stored, context);
|
|
4776
|
+
} catch (error) {
|
|
4777
|
+
this.appendUnexpectedErrors(errors, this.projectOperationError(
|
|
4778
|
+
error,
|
|
4779
|
+
'toolCleanup',
|
|
4780
|
+
scopeId,
|
|
4781
|
+
sessionId,
|
|
4782
|
+
'session-delete',
|
|
4783
|
+
));
|
|
4784
|
+
}
|
|
4000
4785
|
}
|
|
4001
4786
|
if (errors.length > 0) {
|
|
4002
|
-
if (
|
|
4003
|
-
|
|
4004
|
-
|
|
4787
|
+
if (
|
|
4788
|
+
retained
|
|
4789
|
+
&& state.lifecycle === 'retired'
|
|
4790
|
+
&& !this.storedSessionCleanupCompleted(retained.stored)
|
|
4791
|
+
) this.orphanedStoredSessions.add(retained.stored);
|
|
4005
4792
|
throw combineErrors(errors);
|
|
4006
4793
|
}
|
|
4007
|
-
this.purgeStoredPromptQueue(retained.stored);
|
|
4008
|
-
|
|
4009
|
-
|
|
4010
|
-
|
|
4011
|
-
|
|
4794
|
+
if (retained) this.purgeStoredPromptQueue(retained.stored);
|
|
4795
|
+
if (!retained?.domainsCompleted) {
|
|
4796
|
+
if (this.hasOrphanedSessionResources(state.storageKey, sessionId)) {
|
|
4797
|
+
throw new Error(`Session "${sessionId}" still has runtime cleanup ownership.`);
|
|
4798
|
+
}
|
|
4799
|
+
await this.cleanupSessionDomains(state.storageKey, sessionId);
|
|
4800
|
+
if (retained) retained.domainsCompleted = true;
|
|
4012
4801
|
}
|
|
4013
|
-
await this.cleanupSessionDomains(state.storageKey, sessionId);
|
|
4014
|
-
if (retained) retained.domainsCompleted = true;
|
|
4015
4802
|
}
|
|
4016
4803
|
await this.mutateScope(state, () => {
|
|
4017
|
-
|
|
4018
|
-
|
|
4804
|
+
for (const tombstone of group) {
|
|
4805
|
+
state.tombstones.delete(tombstone.sessionId);
|
|
4806
|
+
state.retainedSessionCleanups.delete(tombstone.sessionId);
|
|
4807
|
+
}
|
|
4019
4808
|
}, true);
|
|
4020
4809
|
}
|
|
4021
4810
|
|
|
4022
4811
|
private async fenceNamespace(state: IStorageState, currentRun: IActiveRun, cause: unknown): Promise<void> {
|
|
4023
4812
|
if (state.lifecycle === 'retired') return;
|
|
4813
|
+
if (this.storageDrains.has(state.storageKey)) {
|
|
4814
|
+
state.fenceAdditionalErrors.push(cause);
|
|
4815
|
+
return;
|
|
4816
|
+
}
|
|
4024
4817
|
if (state.fenceInProgress) {
|
|
4025
4818
|
state.fenceAdditionalErrors.push(cause);
|
|
4026
4819
|
return;
|
|
4027
4820
|
}
|
|
4028
4821
|
state.fenceInProgress = true;
|
|
4029
4822
|
state.lifecycle = 'fenced';
|
|
4030
|
-
const detachedCleanupAttempts = this.beginDetachedCleanupSettlement(state);
|
|
4031
4823
|
const reason = this.trustInternalError(new FlexHarnessAbortError('The session namespace was fenced.'));
|
|
4032
4824
|
if (!state.compactorLifecycleController.signal.aborted) {
|
|
4033
4825
|
state.compactorLifecycleController.abort(reason);
|
|
@@ -4056,23 +4848,49 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4056
4848
|
}
|
|
4057
4849
|
const otherRuns = [...state.activeRuns.values()].filter((run) => run !== currentRun);
|
|
4058
4850
|
for (const run of otherRuns) this.cancelRun(run, reason, contextFor(run.sessionId));
|
|
4059
|
-
const
|
|
4851
|
+
const dependentRuns = otherRuns.filter((run) =>
|
|
4852
|
+
this.sessionsAreDependencyRelated(state, currentRun.sessionId, run.sessionId));
|
|
4853
|
+
const independentRuns = otherRuns.filter((run) => !dependentRuns.includes(run));
|
|
4854
|
+
const runResults = await Promise.allSettled(independentRuns.map((run) => run.completion));
|
|
4060
4855
|
for (const result of runResults) {
|
|
4061
4856
|
if (result.status === 'rejected') this.appendUnexpectedErrors(cleanupErrors, result.reason);
|
|
4062
4857
|
}
|
|
4858
|
+
if (dependentRuns.length > 0) {
|
|
4859
|
+
state.fenceAdditionalErrors.push(cause, ...cleanupErrors);
|
|
4860
|
+
const stateLoad = this.stateLoads.get(state.storageKey);
|
|
4861
|
+
if (!stateLoad) {
|
|
4862
|
+
throw combineErrors([cause, ...cleanupErrors, new Error(
|
|
4863
|
+
'The fenced namespace no longer has durable cleanup ownership.',
|
|
4864
|
+
)]);
|
|
4865
|
+
}
|
|
4866
|
+
const deferredDrain = this.drainStorage(
|
|
4867
|
+
state.storageKey,
|
|
4868
|
+
stateLoad,
|
|
4869
|
+
reason,
|
|
4870
|
+
{ scopeId: invocation.scopeId, scope: invocation.scope },
|
|
4871
|
+
);
|
|
4872
|
+
void deferredDrain.catch(() => undefined);
|
|
4873
|
+
return;
|
|
4874
|
+
}
|
|
4875
|
+
const detachedCleanupAttempts = this.beginDetachedCleanupSettlement(state);
|
|
4063
4876
|
await Promise.allSettled([...state.sessionInitializations.values()]);
|
|
4064
4877
|
cleanupErrors.push(...await this.closeOrphanedResources(state.storageKey));
|
|
4065
4878
|
await state.scopeQueue;
|
|
4066
|
-
const
|
|
4879
|
+
const currentTombstoneRoot = state.tombstones.get(currentRun.sessionId)?.rootSessionId
|
|
4880
|
+
?? currentRun.sessionId;
|
|
4881
|
+
const currentTombstoneCleanup = state.tombstoneCleanups.get(currentTombstoneRoot);
|
|
4882
|
+
const currentTombstoneSessions = new Set(
|
|
4883
|
+
this.tombstoneGroup(state, currentTombstoneRoot).map((tombstone) => tombstone.sessionId),
|
|
4884
|
+
);
|
|
4067
4885
|
if (currentTombstoneCleanup) {
|
|
4068
4886
|
this.retainOrphanedTombstoneCleanup(
|
|
4069
4887
|
state.storageKey,
|
|
4070
|
-
|
|
4888
|
+
currentTombstoneRoot,
|
|
4071
4889
|
currentTombstoneCleanup,
|
|
4072
4890
|
);
|
|
4073
4891
|
}
|
|
4074
4892
|
const tombstoneAttempts = [...state.tombstoneCleanups.entries()]
|
|
4075
|
-
.filter(([
|
|
4893
|
+
.filter(([rootSessionId]) => rootSessionId !== currentTombstoneRoot);
|
|
4076
4894
|
const tombstoneResults = await Promise.allSettled(
|
|
4077
4895
|
tombstoneAttempts.map(([, cleanup]) => cleanup),
|
|
4078
4896
|
);
|
|
@@ -4080,11 +4898,14 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4080
4898
|
if (result.status === 'rejected') this.appendUnexpectedErrors(cleanupErrors, result.reason);
|
|
4081
4899
|
}
|
|
4082
4900
|
const attemptedTombstones = new Set(tombstoneAttempts.map(([sessionId]) => sessionId));
|
|
4901
|
+
const attemptedTombstoneSessions = new Set([...attemptedTombstones].flatMap((rootSessionId) =>
|
|
4902
|
+
this.tombstoneGroup(state, rootSessionId).map((tombstone) => tombstone.sessionId)));
|
|
4083
4903
|
const storedSessions = new Set([
|
|
4084
4904
|
...state.sessions.values(),
|
|
4085
4905
|
...[...state.retainedSessionCleanups]
|
|
4086
4906
|
.filter(([sessionId]) =>
|
|
4087
|
-
|
|
4907
|
+
!currentTombstoneSessions.has(sessionId)
|
|
4908
|
+
&& !attemptedTombstoneSessions.has(sessionId))
|
|
4088
4909
|
.map(([, retained]) => retained.stored),
|
|
4089
4910
|
]);
|
|
4090
4911
|
for (const stored of storedSessions) {
|
|
@@ -4119,15 +4940,16 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4119
4940
|
if (cleanupErrors.length > 0) throw combineErrors([cause, ...cleanupErrors]);
|
|
4120
4941
|
state.lifecycle = 'retired';
|
|
4121
4942
|
state.sessions.clear();
|
|
4122
|
-
const
|
|
4943
|
+
const currentRetainedCleanups = [...state.retainedSessionCleanups]
|
|
4944
|
+
.filter(([sessionId]) => currentTombstoneSessions.has(sessionId));
|
|
4123
4945
|
state.retainedSessionCleanups.clear();
|
|
4124
|
-
|
|
4125
|
-
state.retainedSessionCleanups.set(
|
|
4946
|
+
for (const [sessionId, retained] of currentRetainedCleanups) {
|
|
4947
|
+
state.retainedSessionCleanups.set(sessionId, retained);
|
|
4126
4948
|
}
|
|
4127
4949
|
state.sessionDeletions.clear();
|
|
4128
4950
|
state.tombstoneCleanups.clear();
|
|
4129
4951
|
if (currentTombstoneCleanup) {
|
|
4130
|
-
state.tombstoneCleanups.set(
|
|
4952
|
+
state.tombstoneCleanups.set(currentTombstoneRoot, currentTombstoneCleanup);
|
|
4131
4953
|
}
|
|
4132
4954
|
state.activeRuns.clear();
|
|
4133
4955
|
state.pendingPermissions.clear();
|
|
@@ -4143,6 +4965,75 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4143
4965
|
}
|
|
4144
4966
|
}
|
|
4145
4967
|
|
|
4968
|
+
private deferNamespaceDrain(
|
|
4969
|
+
state: IStorageState,
|
|
4970
|
+
cause: unknown,
|
|
4971
|
+
invocation?: { scopeId: string; scope: TScope },
|
|
4972
|
+
): void {
|
|
4973
|
+
state.lifecycle = 'fenced';
|
|
4974
|
+
state.fenceAdditionalErrors.push(cause);
|
|
4975
|
+
const stateLoad = this.stateLoads.get(state.storageKey);
|
|
4976
|
+
if (!stateLoad) return;
|
|
4977
|
+
const drain = this.drainStorage(
|
|
4978
|
+
state.storageKey,
|
|
4979
|
+
stateLoad,
|
|
4980
|
+
this.trustInternalError(new FlexHarnessAbortError('The session namespace was fenced.')),
|
|
4981
|
+
invocation,
|
|
4982
|
+
);
|
|
4983
|
+
void drain.catch(() => undefined);
|
|
4984
|
+
}
|
|
4985
|
+
|
|
4986
|
+
private sessionsAreDependencyRelated(
|
|
4987
|
+
state: IStorageState,
|
|
4988
|
+
leftSessionId: string,
|
|
4989
|
+
rightSessionId: string,
|
|
4990
|
+
): boolean {
|
|
4991
|
+
return this.isSessionAncestor(state, leftSessionId, rightSessionId)
|
|
4992
|
+
|| this.isSessionAncestor(state, rightSessionId, leftSessionId);
|
|
4993
|
+
}
|
|
4994
|
+
|
|
4995
|
+
private isSessionAncestor(
|
|
4996
|
+
state: IStorageState,
|
|
4997
|
+
ancestorSessionId: string,
|
|
4998
|
+
descendantSessionId: string,
|
|
4999
|
+
): boolean {
|
|
5000
|
+
const visited = new Set<string>();
|
|
5001
|
+
let currentId: string | undefined = descendantSessionId;
|
|
5002
|
+
while (currentId) {
|
|
5003
|
+
if (visited.has(currentId)) return false;
|
|
5004
|
+
visited.add(currentId);
|
|
5005
|
+
const parentSessionId = this.sessionParentSessionId(state, currentId);
|
|
5006
|
+
if (parentSessionId === ancestorSessionId) return true;
|
|
5007
|
+
currentId = parentSessionId;
|
|
5008
|
+
}
|
|
5009
|
+
return false;
|
|
5010
|
+
}
|
|
5011
|
+
|
|
5012
|
+
private sessionAncestryDepth(state: IStorageState, sessionId: string): number {
|
|
5013
|
+
const visited = new Set<string>();
|
|
5014
|
+
let currentId: string | undefined = sessionId;
|
|
5015
|
+
let depth = 0;
|
|
5016
|
+
while (currentId) {
|
|
5017
|
+
if (visited.has(currentId)) return depth;
|
|
5018
|
+
visited.add(currentId);
|
|
5019
|
+
const parentSessionId = this.sessionParentSessionId(state, currentId);
|
|
5020
|
+
if (!parentSessionId) return depth;
|
|
5021
|
+
depth++;
|
|
5022
|
+
currentId = parentSessionId;
|
|
5023
|
+
}
|
|
5024
|
+
return depth;
|
|
5025
|
+
}
|
|
5026
|
+
|
|
5027
|
+
private sessionParentSessionId(state: IStorageState, sessionId: string): string | undefined {
|
|
5028
|
+
return this.sessionMetadata(state, sessionId)?.parentSessionId
|
|
5029
|
+
?? state.tombstones.get(sessionId)?.parentSessionId;
|
|
5030
|
+
}
|
|
5031
|
+
|
|
5032
|
+
private sessionMetadata(state: IStorageState, sessionId: string): IFlexSession | undefined {
|
|
5033
|
+
return state.sessions.get(sessionId)?.session
|
|
5034
|
+
?? state.retainedSessionCleanups.get(sessionId)?.stored.session;
|
|
5035
|
+
}
|
|
5036
|
+
|
|
4146
5037
|
private async closeStoredSession(
|
|
4147
5038
|
stored: IStoredSessionState,
|
|
4148
5039
|
context: IFlexAgentContextInvocation<unknown> = stored.compactorContext!,
|
|
@@ -4497,20 +5388,27 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4497
5388
|
}
|
|
4498
5389
|
this.purgeStoredPromptQueue(stored);
|
|
4499
5390
|
}
|
|
4500
|
-
|
|
4501
|
-
|
|
4502
|
-
|
|
5391
|
+
const roots = this.orderTombstoneRootsChildFirst(
|
|
5392
|
+
state,
|
|
5393
|
+
[...new Set([...state.tombstones.values()].map((tombstone) =>
|
|
5394
|
+
tombstone.rootSessionId ?? tombstone.sessionId))],
|
|
5395
|
+
);
|
|
5396
|
+
for (const rootSessionId of roots) {
|
|
5397
|
+
if (attemptedTombstones.has(rootSessionId)) continue;
|
|
5398
|
+
if (this.descendantTombstoneRoots(state, rootSessionId).length > 0) continue;
|
|
5399
|
+
const retained = state.retainedSessionCleanups.get(rootSessionId);
|
|
4503
5400
|
try {
|
|
4504
5401
|
await this.finishTombstoneCleanup(
|
|
4505
5402
|
state,
|
|
4506
|
-
|
|
5403
|
+
rootSessionId,
|
|
4507
5404
|
invocation?.scopeId ?? retained?.stored.session.scopeId ?? state.scopeIdHint,
|
|
4508
|
-
invocation
|
|
5405
|
+
invocation,
|
|
4509
5406
|
);
|
|
4510
5407
|
} catch (error) {
|
|
4511
5408
|
this.appendUnexpectedErrors(errors, error);
|
|
4512
5409
|
}
|
|
4513
5410
|
}
|
|
5411
|
+
errors.push(...state.fenceAdditionalErrors.splice(0));
|
|
4514
5412
|
if (errors.length > 0) throw combineErrors(errors);
|
|
4515
5413
|
state.lifecycle = 'retired';
|
|
4516
5414
|
state.sessions.clear();
|