@modelprofile.com/flexharness 3.4.0 → 3.6.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.
@@ -44,15 +44,18 @@ import type {
44
44
  IFlexProjectionSnapshot,
45
45
  IFlexResolvedModel,
46
46
  IFlexResolvedScope,
47
+ IFlexResourceToolProviderDescriptor,
47
48
  IFlexRunFinishedEvent,
48
49
  IFlexScheduledPromptAdmission,
49
50
  IFlexSchedulePromptOptions,
50
51
  IFlexScopeSnapshot,
51
52
  IFlexSession,
52
53
  IFlexSessionTombstone,
54
+ IFlexSubagentDefinition,
53
55
  IFlexTerminalProjection,
54
56
  IFlexToolHandle,
55
57
  IFlexToolMessagePart,
58
+ IFlexToolProviderContext,
56
59
  IFlexUncertainToolExecution,
57
60
  IFlexUpdateSessionOptions,
58
61
  IFlexUsage,
@@ -199,6 +202,8 @@ interface IActiveRun {
199
202
  callbacksClosed: boolean;
200
203
  reasoningPartIds: Map<string, string>;
201
204
  toolPartIds: Map<string, string>;
205
+ subagentCallCount: number;
206
+ subagentSessionIds: Set<string>;
202
207
  pendingPermissionIds: Set<string>;
203
208
  phase: TRunPhase;
204
209
  reservedUserMessage?: IFlexMessage;
@@ -281,6 +286,18 @@ interface IExecutableToolRecord extends Record<string, unknown> {
281
286
  execute?: (input: unknown, options: unknown) => unknown;
282
287
  }
283
288
 
289
+ interface IFlexSubagentTaskInput {
290
+ description: string;
291
+ prompt: string;
292
+ subagentType: string;
293
+ taskId?: string;
294
+ }
295
+
296
+ interface IFlexSubagentAcquisition {
297
+ stored: IStoredSessionState;
298
+ created: boolean;
299
+ }
300
+
284
301
  type TEventDetails = Record<string, unknown> & {
285
302
  type: TFlexHarnessEvent['type'];
286
303
  };
@@ -309,6 +326,23 @@ const maxProjectedErrorMessageBytes = 2048;
309
326
  const maxProjectedErrorCodeBytes = 128;
310
327
  const maxScheduleDebounceMs = 24 * 60 * 60 * 1000;
311
328
  const maxBackgroundExecutions = 100;
329
+ const maxSubagentDefinitions = 32;
330
+ const maxSubagentNameBytes = 128;
331
+ const maxSubagentDescriptionBytes = 2048;
332
+ const maxSubagentModelHintBytes = 512;
333
+ const maxSubagentSystemBytes = 64 * 1024;
334
+ const maxSubagentTaskDescriptionBytes = 256;
335
+ const maxSubagentPromptBytes = 64 * 1024;
336
+ const maxSubagentTaskIdBytes = 512;
337
+ const maxSubagentResultTextBytes = 64 * 1024;
338
+ const defaultMaxSubagentDepth = 1;
339
+ const maximumMaxSubagentDepth = 8;
340
+ const defaultMaxSubagentCallsPerRun = 32;
341
+ const maximumMaxSubagentCallsPerRun = 128;
342
+ const maxResourceToolProviders = 128;
343
+ const maxResourceIdBytes = 512;
344
+ const maxResourceToolNameBytes = 512;
345
+ const resourceToolStemLength = 16;
312
346
  const repairCancellationMessage = 'The process stopped before this run completed.';
313
347
  const scopeRetirementMessage = 'The scope is being retired.';
314
348
  const externalErrorFallback: IFlexErrorInfo = Object.freeze({
@@ -408,6 +442,136 @@ function validateIdentifier(value: string, name: string): void {
408
442
  }
409
443
  }
410
444
 
445
+ function sha256Hex(value: string): string {
446
+ return plugins.crypto.createHash('sha256').update(value, 'utf8').digest('hex');
447
+ }
448
+
449
+ function createFlexResourceIdentityDigest(
450
+ resourceId: string,
451
+ attachmentRevision: number,
452
+ ): string {
453
+ validateUtf8String(resourceId, 'resourceId', maxResourceIdBytes, true);
454
+ if (!Number.isSafeInteger(attachmentRevision) || attachmentRevision < 0) {
455
+ throw new FlexHarnessValidationError('attachmentRevision must be a non-negative safe integer.');
456
+ }
457
+ return sha256Hex(JSON.stringify([resourceId, attachmentRevision]));
458
+ }
459
+
460
+ export function createFlexResourceToolNamespace(
461
+ resourceId: string,
462
+ attachmentRevision: number,
463
+ ): string {
464
+ const digest = createFlexResourceIdentityDigest(resourceId, attachmentRevision);
465
+ return `resource_${digest.slice(0, 16)}`;
466
+ }
467
+
468
+ export function createFlexResourceToolName(namespace: string, toolName: string): string {
469
+ if (!/^resource_[a-f0-9]{16}$/u.test(namespace)) {
470
+ throw new FlexHarnessValidationError('Resource tool namespace is invalid.');
471
+ }
472
+ validateUtf8String(toolName, 'resource tool name', maxResourceToolNameBytes, true);
473
+ const stem = toolName
474
+ .replace(/[^A-Za-z0-9_-]/gu, '_')
475
+ .slice(0, resourceToolStemLength) || 'tool';
476
+ return `${namespace}__${stem}__${sha256Hex(toolName).slice(0, 12)}`;
477
+ }
478
+
479
+ function validateUtf8String(
480
+ value: unknown,
481
+ name: string,
482
+ maxBytes: number,
483
+ nonEmpty = false,
484
+ ): asserts value is string {
485
+ if (
486
+ typeof value !== 'string'
487
+ || (nonEmpty && !value.trim())
488
+ || Buffer.byteLength(value, 'utf8') > maxBytes
489
+ ) {
490
+ throw new FlexHarnessValidationError(
491
+ `${name} must be ${nonEmpty ? 'a non-empty ' : 'a '}string of at most ${maxBytes} UTF-8 bytes.`,
492
+ );
493
+ }
494
+ }
495
+
496
+ function normalizeSubagents(
497
+ definitions: IFlexSubagentDefinition[] | undefined,
498
+ ): ReadonlyMap<string, Readonly<IFlexSubagentDefinition>> {
499
+ if (definitions === undefined) return new Map();
500
+ if (!Array.isArray(definitions) || definitions.length > maxSubagentDefinitions) {
501
+ throw new FlexHarnessValidationError(
502
+ `subagents must be an array with at most ${maxSubagentDefinitions} definitions.`,
503
+ );
504
+ }
505
+ const normalized = new Map<string, Readonly<IFlexSubagentDefinition>>();
506
+ for (let index = 0; index < definitions.length; index++) {
507
+ const definition = definitions[index];
508
+ if (
509
+ !definition
510
+ || typeof definition !== 'object'
511
+ || Array.isArray(definition)
512
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(definition))
513
+ ) {
514
+ throw new FlexHarnessValidationError(`subagents[${index}] must be a plain object.`);
515
+ }
516
+ const unsupported = Object.keys(definition).find(
517
+ (key) => !['name', 'description', 'modelHint', 'system', 'maxSteps'].includes(key),
518
+ );
519
+ if (unsupported) {
520
+ throw new FlexHarnessValidationError(`subagents[${index}] does not support "${unsupported}".`);
521
+ }
522
+ validateUtf8String(definition.name, `subagents[${index}].name`, maxSubagentNameBytes, true);
523
+ validateUtf8String(
524
+ definition.description,
525
+ `subagents[${index}].description`,
526
+ maxSubagentDescriptionBytes,
527
+ );
528
+ if (definition.modelHint !== undefined) {
529
+ validateUtf8String(
530
+ definition.modelHint,
531
+ `subagents[${index}].modelHint`,
532
+ maxSubagentModelHintBytes,
533
+ );
534
+ }
535
+ if (definition.system !== undefined) {
536
+ validateUtf8String(
537
+ definition.system,
538
+ `subagents[${index}].system`,
539
+ maxSubagentSystemBytes,
540
+ );
541
+ }
542
+ if (
543
+ definition.maxSteps !== undefined
544
+ && (!Number.isSafeInteger(definition.maxSteps) || definition.maxSteps < 1)
545
+ ) {
546
+ throw new FlexHarnessValidationError(`subagents[${index}].maxSteps must be a positive integer.`);
547
+ }
548
+ if (normalized.has(definition.name)) {
549
+ throw new FlexHarnessValidationError(`Duplicate subagent definition "${definition.name}".`);
550
+ }
551
+ normalized.set(definition.name, Object.freeze({
552
+ name: definition.name,
553
+ description: definition.description,
554
+ ...(definition.modelHint === undefined ? {} : { modelHint: definition.modelHint }),
555
+ ...(definition.system === undefined ? {} : { system: definition.system }),
556
+ ...(definition.maxSteps === undefined ? {} : { maxSteps: definition.maxSteps }),
557
+ }));
558
+ }
559
+ return Object.freeze(normalized);
560
+ }
561
+
562
+ function resolveBoundedPositiveInteger(
563
+ value: number | undefined,
564
+ name: string,
565
+ defaultValue: number,
566
+ maximum: number,
567
+ ): number {
568
+ const resolved = value ?? defaultValue;
569
+ if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > maximum) {
570
+ throw new FlexHarnessValidationError(`${name} must be an integer from 1 through ${maximum}.`);
571
+ }
572
+ return resolved;
573
+ }
574
+
411
575
  function requireTransferIdentifier(value: string, field: string): void {
412
576
  if (Buffer.byteLength(value, 'utf8') > maxTransferIdentifierBytes) {
413
577
  throw new FlexHarnessValidationError(`${field} exceeds the transfer limit.`);
@@ -735,6 +899,7 @@ export class FlexHarness<TScope = unknown> {
735
899
  private readonly scopeResolver: IFlexHarnessOptions<TScope>['scopeResolver'];
736
900
  private readonly modelResolver: IFlexHarnessOptions<TScope>['modelResolver'];
737
901
  private readonly toolProvider: IFlexHarnessOptions<TScope>['toolProvider'];
902
+ private readonly resourceToolProviderResolver: IFlexHarnessOptions<TScope>['resourceToolProviderResolver'];
738
903
  private readonly executionContextProvider: IFlexHarnessOptions<TScope>['executionContextProvider'];
739
904
  private readonly stores: IFlexHarnessStores;
740
905
  private readonly agentSessionPolicy: IFlexAgentSessionPolicy<TScope>;
@@ -742,6 +907,9 @@ export class FlexHarness<TScope = unknown> {
742
907
  private readonly callbackLimits: Required<IFlexCallbackLimits>;
743
908
  private readonly promptQueueLimits: Required<IFlexPromptQueueLimits>;
744
909
  private readonly externalErrorProjector?: TFlexExternalErrorProjector;
910
+ private readonly subagents: ReadonlyMap<string, Readonly<IFlexSubagentDefinition>>;
911
+ private readonly maxSubagentDepth: number;
912
+ private readonly maxSubagentCallsPerRun: number;
745
913
  private readonly stateLoads = new Map<string, Promise<IStorageState>>();
746
914
  private readonly scopeAdmissions = new Map<string, IScopeAdmissionState>();
747
915
  private readonly scopeRetirements = new Map<string, Promise<void>>();
@@ -779,6 +947,7 @@ export class FlexHarness<TScope = unknown> {
779
947
  this.scopeResolver = options.scopeResolver;
780
948
  this.modelResolver = options.modelResolver;
781
949
  this.toolProvider = options.toolProvider;
950
+ this.resourceToolProviderResolver = options.resourceToolProviderResolver;
782
951
  this.executionContextProvider = options.executionContextProvider;
783
952
  this.stores = options.stores ?? new InMemoryFlexHarnessStores();
784
953
  this.agentSessionPolicy = normalizeAgentSessionPolicy(options.agentSessionPolicy);
@@ -786,6 +955,19 @@ export class FlexHarness<TScope = unknown> {
786
955
  this.callbackLimits = resolveCallbackLimits(options.callbackLimits);
787
956
  this.promptQueueLimits = resolvePromptQueueLimits(options.promptQueueLimits);
788
957
  this.externalErrorProjector = options.externalErrorProjector;
958
+ this.subagents = normalizeSubagents(options.subagents);
959
+ this.maxSubagentDepth = resolveBoundedPositiveInteger(
960
+ options.maxSubagentDepth,
961
+ 'maxSubagentDepth',
962
+ defaultMaxSubagentDepth,
963
+ maximumMaxSubagentDepth,
964
+ );
965
+ this.maxSubagentCallsPerRun = resolveBoundedPositiveInteger(
966
+ options.maxSubagentCallsPerRun,
967
+ 'maxSubagentCallsPerRun',
968
+ defaultMaxSubagentCallsPerRun,
969
+ maximumMaxSubagentCallsPerRun,
970
+ );
789
971
  }
790
972
 
791
973
  public async listSessions(scopeId: string): Promise<IFlexSession[]> {
@@ -851,6 +1033,7 @@ export class FlexHarness<TScope = unknown> {
851
1033
  updatedAt: timestamp,
852
1034
  status: 'idle',
853
1035
  activity: { status: 'idle' },
1036
+ depth: 0,
854
1037
  };
855
1038
  resolved.state.sessions.set(
856
1039
  sessionId,
@@ -864,6 +1047,7 @@ export class FlexHarness<TScope = unknown> {
864
1047
  metadata,
865
1048
  scopeId,
866
1049
  resolved.scope.scope,
1050
+ placeholder.compactorLifecycleController,
867
1051
  );
868
1052
  const loaded = await initialization;
869
1053
  if (resolved.state.sessions.get(sessionId) !== placeholder) {
@@ -890,6 +1074,8 @@ export class FlexHarness<TScope = unknown> {
890
1074
  const tombstone: IFlexSessionTombstone = {
891
1075
  sessionId,
892
1076
  deletedAt: new Date().toISOString(),
1077
+ rootSessionId: sessionId,
1078
+ depth: 0,
893
1079
  };
894
1080
  let tombstoneCommitted = false;
895
1081
  let domainCleanupCompleted = false;
@@ -899,11 +1085,12 @@ export class FlexHarness<TScope = unknown> {
899
1085
  resolved.state.tombstones.set(sessionId, tombstone);
900
1086
  }, true);
901
1087
  tombstoneCommitted = true;
1088
+ completeInitialization();
902
1089
  await this.finishTombstoneCleanup(
903
1090
  resolved.state,
904
1091
  sessionId,
905
1092
  scopeId,
906
- resolved.scope.scope,
1093
+ { scopeId, scope: resolved.scope.scope },
907
1094
  );
908
1095
  domainCleanupCompleted = true;
909
1096
  } catch (cleanupError) {
@@ -981,15 +1168,17 @@ export class FlexHarness<TScope = unknown> {
981
1168
  public async deleteSession(scopeId: string, sessionId: string): Promise<void> {
982
1169
  const { scope, state } = await this.resolveState(scopeId);
983
1170
  validateIdentifier(sessionId, 'sessionId');
984
- const existingDeletion = state.sessionDeletions.get(sessionId);
1171
+ await state.scopeQueue;
1172
+ const deletionKey = state.tombstones.get(sessionId)?.rootSessionId ?? sessionId;
1173
+ const existingDeletion = state.sessionDeletions.get(deletionKey);
985
1174
  if (existingDeletion) return existingDeletion;
986
1175
  let deletion!: Promise<void>;
987
1176
  deletion = this.deleteSessionInternal(state, scopeId, scope.scope, sessionId).finally(() => {
988
- if (state.sessionDeletions.get(sessionId) === deletion) {
989
- state.sessionDeletions.delete(sessionId);
1177
+ if (state.sessionDeletions.get(deletionKey) === deletion) {
1178
+ state.sessionDeletions.delete(deletionKey);
990
1179
  }
991
1180
  });
992
- state.sessionDeletions.set(sessionId, deletion);
1181
+ state.sessionDeletions.set(deletionKey, deletion);
993
1182
  return deletion;
994
1183
  }
995
1184
 
@@ -1001,35 +1190,91 @@ export class FlexHarness<TScope = unknown> {
1001
1190
  ): Promise<void> {
1002
1191
  const existingTombstone = state.tombstones.get(sessionId);
1003
1192
  if (existingTombstone) {
1193
+ const rootSessionId = existingTombstone.rootSessionId ?? existingTombstone.sessionId;
1194
+ const descendantRoots = this.descendantTombstoneRoots(state, sessionId)
1195
+ .filter((candidate) => candidate !== rootSessionId);
1004
1196
  try {
1005
- await this.finishTombstoneCleanup(state, sessionId, scopeId, scope);
1197
+ const orphanErrors = await this.closeOrphanedResources(state.storageKey);
1198
+ if (orphanErrors.length > 0) throw combineErrors(orphanErrors);
1199
+ for (const descendantRoot of descendantRoots) {
1200
+ await this.finishTombstoneCleanup(
1201
+ state,
1202
+ descendantRoot,
1203
+ scopeId,
1204
+ { scopeId, scope },
1205
+ );
1206
+ }
1207
+ await this.finishTombstoneCleanup(state, rootSessionId, scopeId, { scopeId, scope });
1006
1208
  } catch (error) {
1007
1209
  throw this.projectOperationError(error, 'persistence', scopeId, sessionId, 'session-delete');
1008
1210
  }
1009
1211
  return;
1010
1212
  }
1011
1213
  const stored = this.requireSession(state, sessionId);
1012
- const deletedSession = publicSnapshot(stored.session);
1214
+ let deletedSessions: IFlexSession[] = [];
1215
+ let descendantRoots: string[] = [];
1013
1216
  await this.mutateScope(state, () => {
1014
1217
  if (state.sessions.get(sessionId) !== stored) {
1015
1218
  throw new FlexHarnessNotFoundError('Session', sessionId);
1016
1219
  }
1017
- state.sessions.delete(sessionId);
1018
- state.retainedSessionCleanups.set(sessionId, {
1019
- stored,
1020
- domainsCompleted: false,
1021
- });
1022
- state.tombstones.set(sessionId, {
1023
- sessionId,
1024
- deletedAt: new Date().toISOString(),
1025
- });
1220
+ const subtree = this.collectSessionSubtree(state, sessionId);
1221
+ descendantRoots = this.descendantTombstoneRoots(state, sessionId);
1222
+ const rootDepth = stored.session.depth ?? 0;
1223
+ const deletedAt = new Date().toISOString();
1224
+ deletedSessions = subtree.map((entry) => cloneSerializable(entry.session));
1225
+ for (const entry of subtree) {
1226
+ const entrySessionId = entry.session.sessionId;
1227
+ state.sessions.delete(entrySessionId);
1228
+ state.retainedSessionCleanups.set(entrySessionId, {
1229
+ stored: entry,
1230
+ domainsCompleted: false,
1231
+ });
1232
+ state.tombstones.set(entrySessionId, {
1233
+ sessionId: entrySessionId,
1234
+ deletedAt,
1235
+ rootSessionId: sessionId,
1236
+ depth: (entry.session.depth ?? rootDepth) - rootDepth,
1237
+ ...(entry.session.parentSessionId === undefined
1238
+ ? {}
1239
+ : { parentSessionId: entry.session.parentSessionId }),
1240
+ });
1241
+ }
1026
1242
  });
1243
+ const reason = this.trustInternalError(new FlexHarnessAbortError('The session subtree was deleted.'));
1244
+ const childFirstDeletedSessions = [...deletedSessions].sort((left, right) =>
1245
+ (right.depth ?? 0) - (left.depth ?? 0)
1246
+ || left.sessionId.localeCompare(right.sessionId));
1247
+ for (const deleted of childFirstDeletedSessions) {
1248
+ const active = state.activeRuns.get(deleted.sessionId);
1249
+ if (active) {
1250
+ this.cancelRun(
1251
+ active,
1252
+ reason,
1253
+ this.createCompactorContext(scopeId, scope, state.storageKey, deleted.sessionId),
1254
+ );
1255
+ }
1256
+ }
1027
1257
  try {
1028
- await this.finishTombstoneCleanup(state, sessionId, scopeId, scope);
1258
+ const orphanErrors = await this.closeOrphanedResources(state.storageKey);
1259
+ if (orphanErrors.length > 0) throw combineErrors(orphanErrors);
1260
+ for (const descendantRoot of descendantRoots) {
1261
+ await this.finishTombstoneCleanup(
1262
+ state,
1263
+ descendantRoot,
1264
+ scopeId,
1265
+ { scopeId, scope },
1266
+ );
1267
+ }
1268
+ await this.finishTombstoneCleanup(state, sessionId, scopeId, { scopeId, scope });
1029
1269
  } catch (error) {
1030
1270
  throw this.projectOperationError(error, 'persistence', scopeId, sessionId, 'session-delete');
1031
1271
  }
1032
- this.emitEvent(scopeId, sessionId, { type: 'session.deleted', session: deletedSession });
1272
+ for (const deleted of childFirstDeletedSessions) {
1273
+ this.emitEvent(scopeId, deleted.sessionId, {
1274
+ type: 'session.deleted',
1275
+ session: publicSnapshot(deleted),
1276
+ });
1277
+ }
1033
1278
  }
1034
1279
 
1035
1280
  public async getMessages(scopeId: string, sessionId: string): Promise<IFlexMessage[]> {
@@ -1516,6 +1761,8 @@ export class FlexHarness<TScope = unknown> {
1516
1761
  options: IFlexPromptOptions,
1517
1762
  scheduleKey?: string,
1518
1763
  debounceMs?: number,
1764
+ subagentAdmission = false,
1765
+ admissionSignal?: AbortSignal,
1519
1766
  ): Promise<{ admission: IFlexPromptQueueAdmission; started: Promise<IFlexPromptAdmission> }> {
1520
1767
  this.assertOpen();
1521
1768
  const normalizedPrompt = normalizeFlexPrompt(prompt);
@@ -1536,20 +1783,35 @@ export class FlexHarness<TScope = unknown> {
1536
1783
  resolveSettled: () => resolveSettled(),
1537
1784
  };
1538
1785
  this.pendingPromptAdmissionOwners.add(pendingOwner);
1786
+ const admissionSignals = [
1787
+ pendingOwner.controller.signal,
1788
+ ...(admissionSignal === undefined ? [] : [admissionSignal]),
1789
+ ];
1539
1790
  let abortAdmission!: () => void;
1540
1791
  const abortPromise = new Promise<never>((_resolve, reject) => {
1541
- abortAdmission = () => reject(
1542
- pendingOwner.controller.signal.reason ?? new FlexHarnessAbortError(),
1543
- );
1544
- pendingOwner.controller.signal.addEventListener('abort', abortAdmission, { once: true });
1545
- if (pendingOwner.controller.signal.aborted) abortAdmission();
1792
+ abortAdmission = () => {
1793
+ const aborted = admissionSignals.find((signal) => signal.aborted);
1794
+ reject(aborted?.reason ?? new FlexHarnessAbortError());
1795
+ };
1796
+ for (const signal of admissionSignals) {
1797
+ signal.addEventListener('abort', abortAdmission, { once: true });
1798
+ }
1799
+ if (admissionSignals.some((signal) => signal.aborted)) abortAdmission();
1546
1800
  });
1547
1801
  try {
1548
1802
  const resolved = await Promise.race([this.resolveState(scopeId), abortPromise]);
1549
1803
  const state = resolved.state;
1550
1804
  await Promise.race([state.scopeQueue, abortPromise]);
1805
+ if (admissionSignal?.aborted) {
1806
+ throw admissionSignal.reason ?? new FlexHarnessAbortError();
1807
+ }
1551
1808
  this.assertStateAcceptingWork(state);
1552
1809
  const stored = this.requireSession(state, sessionId);
1810
+ if (stored.session.agent !== undefined && !subagentAdmission) {
1811
+ throw new FlexHarnessValidationError(
1812
+ 'Subagent sessions can only be prompted through the foreground task tool.',
1813
+ );
1814
+ }
1553
1815
  if (stored.outstandingPromptsById.size >= this.promptQueueLimits.maxOutstandingPromptsPerSession) {
1554
1816
  throw new FlexHarnessQueueFullError(
1555
1817
  `Session "${sessionId}" has reached its outstanding prompt limit.`,
@@ -1615,7 +1877,9 @@ export class FlexHarness<TScope = unknown> {
1615
1877
  started,
1616
1878
  };
1617
1879
  } finally {
1618
- pendingOwner.controller.signal.removeEventListener('abort', abortAdmission);
1880
+ for (const signal of admissionSignals) {
1881
+ signal.removeEventListener('abort', abortAdmission);
1882
+ }
1619
1883
  this.pendingPromptAdmissionOwners.delete(pendingOwner);
1620
1884
  releasePendingAdmission();
1621
1885
  pendingOwner.resolveSettled();
@@ -1714,6 +1978,8 @@ export class FlexHarness<TScope = unknown> {
1714
1978
  callbacksClosed: false,
1715
1979
  reasoningPartIds: new Map(),
1716
1980
  toolPartIds: new Map(),
1981
+ subagentCallCount: 0,
1982
+ subagentSessionIds: new Set(),
1717
1983
  pendingPermissionIds: new Set(),
1718
1984
  phase: 'admitting',
1719
1985
  completion: queued.completion,
@@ -2109,28 +2375,38 @@ export class FlexHarness<TScope = unknown> {
2109
2375
  queued.status = 'running';
2110
2376
  this.emitPromptQueueEvent(queued, 'prompt.running');
2111
2377
  }
2378
+ const resolverRelationship = {
2379
+ ...(run.stored.session.parentSessionId === undefined
2380
+ ? {}
2381
+ : { parentSessionId: run.stored.session.parentSessionId }),
2382
+ ...(run.stored.session.agent === undefined ? {} : { agent: run.stored.session.agent }),
2383
+ };
2112
2384
  const modelOutcome = Promise.resolve()
2113
- .then(() => this.modelResolver.resolveModel({
2385
+ .then(() => this.modelResolver.resolveModel(Object.freeze({
2114
2386
  scopeId: run.scopeId,
2115
2387
  scope: run.scope as TScope,
2116
2388
  sessionId: run.sessionId,
2117
2389
  runId: run.runId,
2118
2390
  ...(options.modelHint ? { modelHint: options.modelHint } : {}),
2391
+ ...resolverRelationship,
2119
2392
  signal,
2120
- }))
2393
+ })))
2121
2394
  .then(
2122
2395
  (value): IResolverOutcome<IFlexResolvedModel> => ({ source: 'model', success: true, value }),
2123
2396
  (error): IResolverOutcome<IFlexResolvedModel> => ({ source: 'model', success: false, error }),
2124
2397
  );
2398
+ const toolProviderContext: Readonly<IFlexToolProviderContext<TScope>> = Object.freeze({
2399
+ scopeId: run.scopeId,
2400
+ scope: run.scope as TScope,
2401
+ sessionId: run.sessionId,
2402
+ runId: run.runId,
2403
+ ...resolverRelationship,
2404
+ signal,
2405
+ requestPermission: (request: IFlexPermissionRequestInput) =>
2406
+ this.requestPermission(run.state, run, request),
2407
+ });
2125
2408
  const toolOutcome = Promise.resolve()
2126
- .then(() => this.toolProvider?.provideTools({
2127
- scopeId: run.scopeId,
2128
- scope: run.scope as TScope,
2129
- sessionId: run.sessionId,
2130
- runId: run.runId,
2131
- signal,
2132
- requestPermission: (request) => this.requestPermission(run.state, run, request),
2133
- }))
2409
+ .then(() => this.provideRunToolHandle(run, toolProviderContext))
2134
2410
  .then(
2135
2411
  (value): IResolverOutcome<IFlexToolHandle | undefined> => ({ source: 'tools', success: true, value }),
2136
2412
  (error): IResolverOutcome<IFlexToolHandle | undefined> => ({ source: 'tools', success: false, error }),
@@ -2197,9 +2473,21 @@ export class FlexHarness<TScope = unknown> {
2197
2473
  run.modelResolution = model!;
2198
2474
  let tools: TFlexAgentToolSet | undefined;
2199
2475
  try {
2200
- tools = toolHandle
2476
+ const providedTools = toolHandle?.tools;
2477
+ if (
2478
+ this.subagents.size > 0
2479
+ && providedTools
2480
+ && Object.prototype.hasOwnProperty.call(providedTools, 'task')
2481
+ ) {
2482
+ throw new FlexHarnessValidationError('The application tool provider cannot define reserved tool "task".');
2483
+ }
2484
+ const combinedTools: Record<string, unknown> = { ...(providedTools ?? {}) };
2485
+ if (this.subagents.size > 0 && (run.stored.session.depth ?? 0) < this.maxSubagentDepth) {
2486
+ combinedTools.task = this.createSubagentTool(run);
2487
+ }
2488
+ tools = Object.keys(combinedTools).length > 0
2201
2489
  ? wrapToolSet(
2202
- toolHandle.tools,
2490
+ combinedTools as TFlexAgentToolSet,
2203
2491
  this.toolOutputLimits,
2204
2492
  (toolError) => this.projectExternalError(run, toolError, 'toolExecution'),
2205
2493
  )
@@ -2247,6 +2535,426 @@ export class FlexHarness<TScope = unknown> {
2247
2535
  }
2248
2536
  }
2249
2537
 
2538
+ private createSubagentTool(run: IActiveRun): unknown {
2539
+ const available = [...this.subagents.values()]
2540
+ .map((definition) => `- ${definition.name}: ${definition.description}`)
2541
+ .join('\n');
2542
+ return plugins.tool({
2543
+ description: `Run one configured FlexHarness subagent in the foreground and return its final text.\nAvailable subagents:\n${available}`,
2544
+ inputSchema: plugins.z.object({
2545
+ description: plugins.z.string(),
2546
+ prompt: plugins.z.string(),
2547
+ subagentType: plugins.z.string(),
2548
+ taskId: plugins.z.string().optional(),
2549
+ }).strict(),
2550
+ execute: (input: IFlexSubagentTaskInput, options?: { toolCallId?: string }) =>
2551
+ this.executeSubagentTask(run, input, options?.toolCallId),
2552
+ });
2553
+ }
2554
+
2555
+ private async executeSubagentTask(
2556
+ run: IActiveRun,
2557
+ input: IFlexSubagentTaskInput,
2558
+ toolCallId: string | undefined,
2559
+ ): Promise<{ taskId: string; status: 'completed'; text: string; model: IFlexModelIdentity }> {
2560
+ run.subagentCallCount++;
2561
+ if (run.subagentCallCount > this.maxSubagentCallsPerRun) {
2562
+ throw new FlexHarnessValidationError(
2563
+ `Run "${run.runId}" exceeds maxSubagentCallsPerRun (${this.maxSubagentCallsPerRun}).`,
2564
+ );
2565
+ }
2566
+ validateUtf8String(
2567
+ input.description,
2568
+ 'task description',
2569
+ maxSubagentTaskDescriptionBytes,
2570
+ true,
2571
+ );
2572
+ validateUtf8String(input.prompt, 'task prompt', maxSubagentPromptBytes, true);
2573
+ validateUtf8String(input.subagentType, 'subagentType', maxSubagentNameBytes, true);
2574
+ if (input.taskId !== undefined) {
2575
+ validateUtf8String(input.taskId, 'taskId', maxSubagentTaskIdBytes, true);
2576
+ }
2577
+ validateUtf8String(toolCallId, 'task toolCallId', maxTransferIdentifierBytes, true);
2578
+ const definition = this.subagents.get(input.subagentType);
2579
+ if (!definition || (run.stored.session.depth ?? 0) >= this.maxSubagentDepth) {
2580
+ throw new FlexHarnessValidationError(`Subagent "${input.subagentType}" is not available.`);
2581
+ }
2582
+ const reservedSessionId = input.taskId ?? this.createSubagentSessionId(
2583
+ run.state.storageKey,
2584
+ run.sessionId,
2585
+ run.runId,
2586
+ toolCallId,
2587
+ );
2588
+ if (run.subagentSessionIds.has(reservedSessionId)) {
2589
+ throw new FlexHarnessValidationError(
2590
+ `Subagent task "${reservedSessionId}" has already been acquired by this parent run.`,
2591
+ );
2592
+ }
2593
+ run.subagentSessionIds.add(reservedSessionId);
2594
+ let child: IStoredSessionState | undefined;
2595
+ let childCreated = false;
2596
+ let queued: Awaited<ReturnType<typeof this.enqueuePromptInternal>> | undefined;
2597
+ let admission: IFlexPromptAdmission | undefined;
2598
+ const abortChild = () => {
2599
+ const reason = run.controller.signal.reason instanceof FlexHarnessAbortError
2600
+ ? run.controller.signal.reason
2601
+ : this.trustInternalError(new FlexHarnessAbortError('The parent run was aborted.'));
2602
+ const ownedChild = child ?? run.state.sessions.get(reservedSessionId);
2603
+ if (
2604
+ ownedChild
2605
+ && ownedChild.session.parentSessionId === run.sessionId
2606
+ && ownedChild.session.agent === definition.name
2607
+ && run.state.initializingSessions.has(reservedSessionId)
2608
+ ) this.abortCompactorLifecycle(ownedChild, reason);
2609
+ const childQueue = queued === undefined
2610
+ ? undefined
2611
+ : ownedChild?.outstandingPromptsById.get(queued.admission.queueId);
2612
+ if (childQueue) this.cancelQueuedPrompt(childQueue, reason);
2613
+ else if (admission) {
2614
+ this.abortExactRun(run.state, reservedSessionId, admission.runId, reason);
2615
+ }
2616
+ };
2617
+ run.controller.signal.addEventListener('abort', abortChild, { once: true });
2618
+ if (run.controller.signal.aborted) abortChild();
2619
+ try {
2620
+ await this.requestPermission(run.state, run, {
2621
+ kind: 'subagent.start',
2622
+ description: `Start foreground subagent "${definition.name}": ${input.description}`,
2623
+ toolCallId,
2624
+ metadata: {
2625
+ agent: definition.name,
2626
+ description: input.description,
2627
+ ...(input.taskId === undefined ? {} : { taskId: input.taskId }),
2628
+ },
2629
+ });
2630
+ const acquired = await this.acquireSubagentSession(
2631
+ run,
2632
+ definition,
2633
+ toolCallId,
2634
+ input.taskId,
2635
+ reservedSessionId,
2636
+ );
2637
+ child = acquired.stored;
2638
+ childCreated = acquired.created;
2639
+ if (run.controller.signal.aborted) throw run.controller.signal.reason;
2640
+ this.updateSubagentToolPart(run, toolCallId, child.session.sessionId);
2641
+ const childOptions: IFlexPromptOptions = {
2642
+ ...(definition.modelHint === undefined ? {} : { modelHint: definition.modelHint }),
2643
+ ...(definition.system === undefined ? {} : { system: definition.system }),
2644
+ ...(definition.maxSteps === undefined ? {} : { maxSteps: definition.maxSteps }),
2645
+ };
2646
+ queued = await this.enqueuePromptInternal(
2647
+ run.scopeId,
2648
+ child.session.sessionId,
2649
+ input.prompt,
2650
+ childOptions,
2651
+ undefined,
2652
+ undefined,
2653
+ true,
2654
+ run.controller.signal,
2655
+ );
2656
+ admission = await queued.started;
2657
+ const result = await queued.admission.completion;
2658
+ this.updateSubagentToolPart(run, toolCallId, child.session.sessionId, result.model);
2659
+ return {
2660
+ taskId: child.session.sessionId,
2661
+ status: 'completed',
2662
+ text: truncateUtf8(result.assistantMessage.parts
2663
+ .filter((part) => part.type === 'text')
2664
+ .map((part) => part.text)
2665
+ .join(''), maxSubagentResultTextBytes),
2666
+ model: publicSnapshot(result.model),
2667
+ };
2668
+ } catch (error) {
2669
+ if (
2670
+ childCreated
2671
+ && child
2672
+ && queued === undefined
2673
+ && run.state.sessions.get(child.session.sessionId) === child
2674
+ && !run.state.tombstones.has(child.session.sessionId)
2675
+ ) {
2676
+ try {
2677
+ await this.deleteSessionInternal(
2678
+ run.state,
2679
+ run.scopeId,
2680
+ run.scope as TScope,
2681
+ child.session.sessionId,
2682
+ );
2683
+ } catch (cleanupError) {
2684
+ throw combineErrors([error, cleanupError]);
2685
+ }
2686
+ }
2687
+ const childModel = child?.messages
2688
+ .filter((message) => message.runId === admission?.runId && message.role === 'assistant')
2689
+ .at(-1)?.model
2690
+ ?? child?.stagedTerminals.find((terminal) => terminal.runId === admission?.runId)?.model;
2691
+ const parentPart = run.callbackParts.find((part) =>
2692
+ part.type === 'tool' && part.toolCallId === toolCallId);
2693
+ if (
2694
+ child
2695
+ && childModel !== undefined
2696
+ && parentPart?.type === 'tool'
2697
+ && parentPart.model === undefined
2698
+ ) {
2699
+ this.updateSubagentToolPart(run, toolCallId, child.session.sessionId, childModel);
2700
+ }
2701
+ throw error;
2702
+ } finally {
2703
+ run.controller.signal.removeEventListener('abort', abortChild);
2704
+ }
2705
+ }
2706
+
2707
+ private async acquireSubagentSession(
2708
+ run: IActiveRun,
2709
+ definition: Readonly<IFlexSubagentDefinition>,
2710
+ toolCallId: string,
2711
+ taskId?: string,
2712
+ reservedSessionId?: string,
2713
+ ): Promise<IFlexSubagentAcquisition> {
2714
+ const state = run.state;
2715
+ const sessionId = reservedSessionId ?? taskId ?? this.createSubagentSessionId(
2716
+ state.storageKey,
2717
+ run.sessionId,
2718
+ run.runId,
2719
+ toolCallId,
2720
+ );
2721
+ let metadata: IFlexSession | undefined;
2722
+ let placeholder: IStoredSessionState | undefined;
2723
+ let initializationCompletion: Promise<void> | undefined;
2724
+ let resolveInitialization: (() => void) | undefined;
2725
+ let initializationCompleted = false;
2726
+ const completeInitialization = () => {
2727
+ if (initializationCompleted || initializationCompletion === undefined) return;
2728
+ initializationCompleted = true;
2729
+ state.initializingSessions.delete(sessionId);
2730
+ if (state.sessionInitializations.get(sessionId) === initializationCompletion) {
2731
+ state.sessionInitializations.delete(sessionId);
2732
+ }
2733
+ resolveInitialization?.();
2734
+ };
2735
+ try {
2736
+ await this.mutateScope(state, () => {
2737
+ if (state.sessions.get(run.sessionId) !== run.stored || state.tombstones.has(run.sessionId)) {
2738
+ throw new FlexHarnessAbortError('The parent session no longer owns this subagent request.');
2739
+ }
2740
+ if (state.activeRuns.get(run.sessionId) !== run || run.callbacksClosed) {
2741
+ throw new FlexHarnessAbortError('The parent run no longer owns this subagent request.');
2742
+ }
2743
+ if (run.stored.session.agent !== undefined && !this.subagents.has(run.stored.session.agent)) {
2744
+ throw new FlexHarnessValidationError(`Parent subagent "${run.stored.session.agent}" is disabled.`);
2745
+ }
2746
+ if ((run.stored.session.depth ?? 0) >= this.maxSubagentDepth) {
2747
+ throw new FlexHarnessValidationError('The maximum subagent depth has been reached.');
2748
+ }
2749
+ let ancestor: IFlexSession | undefined = run.stored.session;
2750
+ const visited = new Set<string>();
2751
+ while (ancestor) {
2752
+ if (visited.has(ancestor.sessionId) || state.tombstones.has(ancestor.sessionId)) {
2753
+ throw new FlexHarnessValidationError('The subagent ancestor chain is invalid or deleted.');
2754
+ }
2755
+ visited.add(ancestor.sessionId);
2756
+ ancestor = ancestor.parentSessionId
2757
+ ? state.sessions.get(ancestor.parentSessionId)?.session
2758
+ : undefined;
2759
+ }
2760
+ const existing = state.sessions.get(sessionId);
2761
+ if (existing) {
2762
+ if (
2763
+ existing.session.parentSessionId !== run.sessionId
2764
+ || existing.session.agent !== definition.name
2765
+ ) {
2766
+ throw new FlexHarnessValidationError(`Task session "${sessionId}" is not owned by this parent and agent.`);
2767
+ }
2768
+ if (
2769
+ taskId === undefined
2770
+ && (existing.session.parentRunId !== run.runId
2771
+ || existing.session.parentToolCallId !== toolCallId)
2772
+ ) {
2773
+ throw new FlexHarnessValidationError(
2774
+ `Subagent task "${sessionId}" does not match its deterministic invocation origin.`,
2775
+ );
2776
+ }
2777
+ if (state.initializingSessions.has(sessionId)) {
2778
+ throw new FlexHarnessSessionBusyError(sessionId, 'is still being initialized');
2779
+ }
2780
+ if (taskId === undefined && existing.messages.length > 0) {
2781
+ throw new FlexHarnessValidationError(
2782
+ `Subagent task "${sessionId}" has an uncertain prior execution and cannot be replayed automatically.`,
2783
+ );
2784
+ }
2785
+ if (state.activeRuns.has(sessionId) || existing.session.status !== 'idle') {
2786
+ throw new FlexHarnessSessionBusyError(sessionId, 'cannot be resumed while it is not idle');
2787
+ }
2788
+ if (taskId !== undefined && existing.session.parentRunId === run.runId) {
2789
+ throw new FlexHarnessValidationError('taskId can only resume a child from a later parent run.');
2790
+ }
2791
+ placeholder = existing;
2792
+ return;
2793
+ }
2794
+ if (taskId !== undefined || state.tombstones.has(sessionId)) {
2795
+ throw new FlexHarnessNotFoundError('Subagent task', sessionId);
2796
+ }
2797
+ const timestamp = new Date().toISOString();
2798
+ metadata = {
2799
+ scopeId: run.scopeId,
2800
+ sessionId,
2801
+ title: `Subagent: ${definition.name}`,
2802
+ createdAt: timestamp,
2803
+ updatedAt: timestamp,
2804
+ status: 'idle',
2805
+ activity: { status: 'idle' },
2806
+ parentSessionId: run.sessionId,
2807
+ parentRunId: run.runId,
2808
+ parentToolCallId: toolCallId,
2809
+ agent: definition.name,
2810
+ depth: (run.stored.session.depth ?? 0) + 1,
2811
+ };
2812
+ placeholder = this.createUninitializedStoredSession(metadata, state.storageKey);
2813
+ state.sessions.set(sessionId, placeholder);
2814
+ state.initializingSessions.add(sessionId);
2815
+ initializationCompletion = new Promise<void>((resolve) => {
2816
+ resolveInitialization = resolve;
2817
+ });
2818
+ state.sessionInitializations.set(sessionId, initializationCompletion);
2819
+ });
2820
+ if (!metadata) return { stored: placeholder!, created: false };
2821
+ const loaded = await this.loadSessionRuntime(
2822
+ state,
2823
+ metadata,
2824
+ run.scopeId,
2825
+ run.scope as TScope,
2826
+ placeholder!.compactorLifecycleController,
2827
+ );
2828
+ const parentLostOwnership = run.controller.signal.aborted
2829
+ || state.sessions.get(run.sessionId) !== run.stored
2830
+ || state.activeRuns.get(run.sessionId) !== run
2831
+ || run.callbacksClosed;
2832
+ if (
2833
+ parentLostOwnership
2834
+ || state.sessions.get(sessionId) !== placeholder
2835
+ || state.tombstones.has(sessionId)
2836
+ ) {
2837
+ const aborted = run.controller.signal.aborted
2838
+ ? run.controller.signal.reason
2839
+ : new FlexHarnessAbortError('The subagent session lost parent ownership during initialization.');
2840
+ try {
2841
+ await this.closeStoredSession(loaded);
2842
+ } catch (error) {
2843
+ this.orphanedStoredSessions.add(loaded);
2844
+ throw combineErrors([aborted, error]);
2845
+ }
2846
+ throw aborted;
2847
+ }
2848
+ state.sessions.set(sessionId, loaded);
2849
+ this.emitEvent(run.scopeId, sessionId, {
2850
+ type: 'session.created',
2851
+ session: publicSnapshot(metadata),
2852
+ });
2853
+ return { stored: loaded, created: true };
2854
+ } catch (error) {
2855
+ if (metadata === undefined) throw error;
2856
+ if (
2857
+ state.tombstones.has(sessionId)
2858
+ && placeholder?.compactorLifecycleController.signal.aborted
2859
+ ) {
2860
+ throw placeholder.compactorLifecycleController.signal.reason;
2861
+ }
2862
+ const projected = this.projectExternalError(run, error, 'agentSession');
2863
+ if (
2864
+ placeholder === undefined
2865
+ || (state.sessions.get(sessionId) !== placeholder && !state.tombstones.has(sessionId))
2866
+ ) throw projected;
2867
+ if (state.tombstones.has(sessionId)) throw projected;
2868
+ try {
2869
+ await this.mutateScope(state, () => {
2870
+ if (state.sessions.get(sessionId) !== placeholder) return;
2871
+ state.sessions.delete(sessionId);
2872
+ state.tombstones.set(sessionId, {
2873
+ sessionId,
2874
+ deletedAt: new Date().toISOString(),
2875
+ rootSessionId: sessionId,
2876
+ depth: 0,
2877
+ parentSessionId: run.sessionId,
2878
+ });
2879
+ }, true);
2880
+ completeInitialization();
2881
+ await this.finishTombstoneCleanup(
2882
+ state,
2883
+ sessionId,
2884
+ run.scopeId,
2885
+ { scopeId: run.scopeId, scope: run.scope as TScope },
2886
+ );
2887
+ } catch (cleanupError) {
2888
+ const combined = combineErrors([
2889
+ projected,
2890
+ this.projectExternalError(run, cleanupError, 'persistence'),
2891
+ ]);
2892
+ this.deferNamespaceDrain(
2893
+ state,
2894
+ combined,
2895
+ { scopeId: run.scopeId, scope: run.scope as TScope },
2896
+ );
2897
+ throw combined;
2898
+ }
2899
+ throw projected;
2900
+ } finally {
2901
+ completeInitialization();
2902
+ }
2903
+ }
2904
+
2905
+ private createSubagentSessionId(
2906
+ storageKey: string,
2907
+ parentSessionId: string,
2908
+ parentRunId: string,
2909
+ parentToolCallId: string,
2910
+ ): string {
2911
+ return `subagent_${plugins.crypto.createHash('sha256').update(JSON.stringify([
2912
+ 'flexharness-subagent-v1',
2913
+ storageKey,
2914
+ parentSessionId,
2915
+ parentRunId,
2916
+ parentToolCallId,
2917
+ ])).digest('hex')}`;
2918
+ }
2919
+
2920
+ private updateSubagentToolPart(
2921
+ run: IActiveRun,
2922
+ toolCallId: string,
2923
+ childSessionId: string,
2924
+ model?: IFlexModelIdentity,
2925
+ ): void {
2926
+ const partId = run.toolPartIds.get(toolCallId);
2927
+ const part = run.callbackParts.find((entry) => entry.partId === partId && entry.type === 'tool');
2928
+ if (!part || part.type !== 'tool' || part.status !== 'running') {
2929
+ throw new FlexHarnessValidationError(`Running task part "${toolCallId}" is unavailable.`);
2930
+ }
2931
+ const bytes = Buffer.byteLength(childSessionId, 'utf8')
2932
+ + (model === undefined ? 0 : jsonBytes(model));
2933
+ if (!this.reserveCallbackCapacity(run, 1, bytes, 0)) {
2934
+ throw run.callbackError ?? new FlexHarnessCallbackOverflowError('Task metadata exceeded callback limits.');
2935
+ }
2936
+ part.childSessionId = childSessionId;
2937
+ if (model !== undefined) part.model = cloneSerializable(model);
2938
+ this.emitPartEvent(run, 'part.updated', part);
2939
+ }
2940
+
2941
+ private abortExactRun(
2942
+ state: IStorageState,
2943
+ sessionId: string,
2944
+ runId: string,
2945
+ reason: unknown,
2946
+ ): boolean {
2947
+ const active = state.activeRuns.get(sessionId);
2948
+ if (!active || active.runId !== runId || active.phase === 'finalizing' || active.phase === 'promoting') {
2949
+ return false;
2950
+ }
2951
+ const cancellation = reason instanceof FlexHarnessAbortError
2952
+ ? reason
2953
+ : this.trustInternalError(new FlexHarnessAbortError('The parent run was aborted.'));
2954
+ this.cancelRun(active, cancellation);
2955
+ return true;
2956
+ }
2957
+
2250
2958
  private createReservation(
2251
2959
  run: IActiveRun,
2252
2960
  prompt: INormalizedFlexPrompt,
@@ -3020,6 +3728,161 @@ export class FlexHarness<TScope = unknown> {
3020
3728
  }
3021
3729
  }
3022
3730
 
3731
+ private async provideRunToolHandle(
3732
+ run: IActiveRun,
3733
+ context: Readonly<IFlexToolProviderContext<TScope>>,
3734
+ ): Promise<IFlexToolHandle | undefined> {
3735
+ if (!this.resourceToolProviderResolver) return this.toolProvider?.provideTools(context);
3736
+
3737
+ const resolverContext = Object.freeze({
3738
+ scopeId: context.scopeId,
3739
+ scope: context.scope,
3740
+ sessionId: context.sessionId,
3741
+ runId: context.runId,
3742
+ ...(context.parentSessionId === undefined ? {} : { parentSessionId: context.parentSessionId }),
3743
+ ...(context.agent === undefined ? {} : { agent: context.agent }),
3744
+ signal: context.signal,
3745
+ });
3746
+ const resolved = await this.resourceToolProviderResolver.resolveResourceToolProviders(resolverContext);
3747
+ const descriptors = this.normalizeResourceToolProviderDescriptors(resolved);
3748
+ const owned: Array<{ handle: IFlexToolHandle; closed: boolean }> = [];
3749
+ const tools: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
3750
+ const closeOwned = async (): Promise<void> => {
3751
+ const errors: unknown[] = [];
3752
+ for (const entry of [...owned].reverse()) {
3753
+ if (entry.closed || !entry.handle.close) continue;
3754
+ try {
3755
+ await entry.handle.close();
3756
+ entry.closed = true;
3757
+ } catch (error) {
3758
+ errors.push(error);
3759
+ }
3760
+ }
3761
+ if (errors.length > 0) throw combineErrors(errors);
3762
+ };
3763
+ try {
3764
+ context.signal.throwIfAborted();
3765
+ const applicationHandle = this.normalizeToolHandle(
3766
+ run,
3767
+ await this.toolProvider?.provideTools(context),
3768
+ );
3769
+ if (applicationHandle) {
3770
+ owned.push({ handle: applicationHandle, closed: false });
3771
+ for (const [toolName, tool] of Object.entries(applicationHandle.tools)) {
3772
+ tools[toolName] = tool;
3773
+ }
3774
+ }
3775
+ for (const descriptor of descriptors) {
3776
+ context.signal.throwIfAborted();
3777
+ const namespace = createFlexResourceToolNamespace(
3778
+ descriptor.resourceId,
3779
+ descriptor.attachmentRevision,
3780
+ );
3781
+ const resourceIdentity = createFlexResourceIdentityDigest(
3782
+ descriptor.resourceId,
3783
+ descriptor.attachmentRevision,
3784
+ );
3785
+ const resourceContext = Object.freeze({
3786
+ ...context,
3787
+ requestPermission: (request: IFlexPermissionRequestInput) => context.requestPermission({
3788
+ ...request,
3789
+ kind: `resource.${resourceIdentity}.${request.kind}`,
3790
+ ...(request.rememberKey === undefined
3791
+ ? {}
3792
+ : { rememberKey: `resource:${resourceIdentity}:${request.rememberKey}` }),
3793
+ metadata: {
3794
+ resourceId: descriptor.resourceId,
3795
+ attachmentRevision: descriptor.attachmentRevision,
3796
+ resourceIdentity,
3797
+ toolNamespace: namespace,
3798
+ ...(request.metadata === undefined ? {} : { providerMetadata: request.metadata }),
3799
+ },
3800
+ }),
3801
+ });
3802
+ const handle = this.normalizeToolHandle(
3803
+ run,
3804
+ await descriptor.provider.provideTools(resourceContext),
3805
+ );
3806
+ if (!handle) continue;
3807
+ owned.push({ handle, closed: false });
3808
+ for (const [toolName, tool] of Object.entries(handle.tools)) {
3809
+ const exposedName = createFlexResourceToolName(namespace, toolName);
3810
+ if (Object.prototype.hasOwnProperty.call(tools, exposedName)) {
3811
+ throw new FlexHarnessValidationError(`Duplicate exposed tool "${exposedName}".`);
3812
+ }
3813
+ tools[exposedName] = tool;
3814
+ }
3815
+ }
3816
+ if (owned.length === 0 && Object.keys(tools).length === 0) return undefined;
3817
+ return {
3818
+ tools: tools as TFlexAgentToolSet,
3819
+ ...(owned.some((entry) => entry.handle.close) ? { close: closeOwned } : {}),
3820
+ };
3821
+ } catch (error) {
3822
+ if (owned.some((entry) => entry.handle.close && !entry.closed)) {
3823
+ try {
3824
+ await this.closePartialToolHandle({ tools: {}, close: closeOwned }, run);
3825
+ } catch (cleanupError) {
3826
+ throw combineErrors([error, cleanupError]);
3827
+ }
3828
+ }
3829
+ throw error;
3830
+ }
3831
+ }
3832
+
3833
+ private normalizeResourceToolProviderDescriptors(
3834
+ descriptors: readonly IFlexResourceToolProviderDescriptor<TScope>[],
3835
+ ): IFlexResourceToolProviderDescriptor<TScope>[] {
3836
+ if (!Array.isArray(descriptors) || descriptors.length > maxResourceToolProviders) {
3837
+ throw new FlexHarnessValidationError(
3838
+ `Resource tool providers must be an array with at most ${maxResourceToolProviders} descriptors.`,
3839
+ );
3840
+ }
3841
+ const resourceIds = new Set<string>();
3842
+ const namespaces = new Set<string>();
3843
+ return descriptors.map((descriptor, index) => {
3844
+ if (
3845
+ !descriptor
3846
+ || typeof descriptor !== 'object'
3847
+ || Array.isArray(descriptor)
3848
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(descriptor))
3849
+ ) {
3850
+ throw new FlexHarnessValidationError(`Resource tool provider descriptor ${index} is invalid.`);
3851
+ }
3852
+ const unsupported = Object.keys(descriptor).find(
3853
+ (key) => !['resourceId', 'attachmentRevision', 'provider'].includes(key),
3854
+ );
3855
+ if (unsupported) {
3856
+ throw new FlexHarnessValidationError(
3857
+ `Resource tool provider descriptor ${index} does not support "${unsupported}".`,
3858
+ );
3859
+ }
3860
+ const resourceId = descriptor.resourceId;
3861
+ const attachmentRevision = descriptor.attachmentRevision;
3862
+ const provider = descriptor.provider;
3863
+ const provideTools = provider?.provideTools;
3864
+ const namespace = createFlexResourceToolNamespace(resourceId, attachmentRevision);
3865
+ if (resourceIds.has(resourceId)) {
3866
+ throw new FlexHarnessValidationError(`Duplicate resourceId "${resourceId}".`);
3867
+ }
3868
+ if (namespaces.has(namespace)) {
3869
+ throw new FlexHarnessValidationError(`Duplicate resource tool namespace "${namespace}".`);
3870
+ }
3871
+ if (!provider || typeof provideTools !== 'function') {
3872
+ throw new FlexHarnessValidationError(`Resource tool provider descriptor ${index} has no tool provider.`);
3873
+ }
3874
+ resourceIds.add(resourceId);
3875
+ namespaces.add(namespace);
3876
+ return {
3877
+ resourceId,
3878
+ attachmentRevision,
3879
+ provider: {
3880
+ provideTools: (context) => provideTools.call(provider, context),
3881
+ },
3882
+ };
3883
+ });
3884
+ }
3885
+
3023
3886
  private async closePartialToolHandle(handle: IFlexToolHandle, run: IActiveRun): Promise<void> {
3024
3887
  try {
3025
3888
  await handle.close?.();
@@ -3224,10 +4087,37 @@ export class FlexHarness<TScope = unknown> {
3224
4087
  scopeChanged = (await this.repairLoadedSession(state.storageKey, stored)) || scopeChanged;
3225
4088
  if (stored.projectionRevision < 0) throw new Error('Invalid projection revision.');
3226
4089
  }
3227
- for (const tombstone of [...state.tombstones.values()]) {
4090
+ for (const stored of state.sessions.values()) {
4091
+ let ancestorId = stored.session.parentSessionId;
4092
+ const visited = new Set<string>([stored.session.sessionId]);
4093
+ while (ancestorId) {
4094
+ if (visited.has(ancestorId)) {
4095
+ throw new FlexHarnessValidationError(
4096
+ `Live session "${stored.session.sessionId}" has a cyclic ancestor chain.`,
4097
+ );
4098
+ }
4099
+ visited.add(ancestorId);
4100
+ if (state.tombstones.has(ancestorId)) {
4101
+ throw new FlexHarnessValidationError(
4102
+ `Live session "${stored.session.sessionId}" descends from tombstoned ancestor "${ancestorId}".`,
4103
+ );
4104
+ }
4105
+ ancestorId = state.sessions.get(ancestorId)?.session.parentSessionId;
4106
+ }
4107
+ }
4108
+ const tombstoneRoots = this.orderTombstoneRootsChildFirst(
4109
+ state,
4110
+ [...new Set([...state.tombstones.values()].map((tombstone) =>
4111
+ tombstone.rootSessionId ?? tombstone.sessionId))],
4112
+ );
4113
+ for (const rootSessionId of tombstoneRoots) {
4114
+ if (this.descendantTombstoneRoots(state, rootSessionId).length > 0) continue;
3228
4115
  try {
3229
- await this.cleanupSessionDomains(storageKey, tombstone.sessionId);
3230
- state.tombstones.delete(tombstone.sessionId);
4116
+ const group = this.tombstoneGroup(state, rootSessionId);
4117
+ for (const tombstone of group) {
4118
+ await this.cleanupSessionDomains(storageKey, tombstone.sessionId);
4119
+ }
4120
+ for (const tombstone of group) state.tombstones.delete(tombstone.sessionId);
3231
4121
  scopeChanged = true;
3232
4122
  } catch {
3233
4123
  // A retained tombstone is retried by the next load or explicit delete call.
@@ -3260,6 +4150,7 @@ export class FlexHarness<TScope = unknown> {
3260
4150
  private createUninitializedStoredSession(
3261
4151
  session: IFlexSession,
3262
4152
  storageKey: string,
4153
+ compactorLifecycleController = new AbortController(),
3263
4154
  ): IStoredSessionState {
3264
4155
  const unavailable = new Proxy({} as plugins.IAgentSession, {
3265
4156
  get() {
@@ -3283,7 +4174,7 @@ export class FlexHarness<TScope = unknown> {
3283
4174
  agentEventStoreReleased: true,
3284
4175
  executionContextCloseCompleted: true,
3285
4176
  jobStoreReleased: true,
3286
- compactorLifecycleController: new AbortController(),
4177
+ compactorLifecycleController,
3287
4178
  promptQueue: [],
3288
4179
  outstandingPromptsById: new Map(),
3289
4180
  terminalPromptQueueEntries: new Map(),
@@ -3296,6 +4187,7 @@ export class FlexHarness<TScope = unknown> {
3296
4187
  metadata: IFlexSession,
3297
4188
  scopeId: string,
3298
4189
  scope: TScope,
4190
+ compactorLifecycleController = new AbortController(),
3299
4191
  ): Promise<IStoredSessionState> {
3300
4192
  const sessionId = metadata.sessionId;
3301
4193
  const compactorContext = this.createCompactorContext(
@@ -3380,7 +4272,7 @@ export class FlexHarness<TScope = unknown> {
3380
4272
  executionContextCloseCompleted: executionContextHandle?.close === undefined,
3381
4273
  jobStoreReleased: this.stores.jobs.releaseSession === undefined,
3382
4274
  jobs: executionContextHandle?.context.jobs,
3383
- compactorLifecycleController: new AbortController(),
4275
+ compactorLifecycleController,
3384
4276
  promptQueue: [],
3385
4277
  outstandingPromptsById: new Map(),
3386
4278
  terminalPromptQueueEntries: new Map(),
@@ -3927,50 +4819,136 @@ export class FlexHarness<TScope = unknown> {
3927
4819
  if (errors.length > 0) throw combineErrors(errors);
3928
4820
  }
3929
4821
 
4822
+ private collectSessionSubtree(
4823
+ state: IStorageState,
4824
+ rootSessionId: string,
4825
+ ): IStoredSessionState[] {
4826
+ const subtree: IStoredSessionState[] = [];
4827
+ const pending = [rootSessionId];
4828
+ const seen = new Set<string>();
4829
+ while (pending.length > 0) {
4830
+ const sessionId = pending.pop()!;
4831
+ if (seen.has(sessionId)) {
4832
+ throw new FlexHarnessValidationError('Session relationships contain a cycle.');
4833
+ }
4834
+ seen.add(sessionId);
4835
+ const stored = state.sessions.get(sessionId);
4836
+ if (!stored) continue;
4837
+ subtree.push(stored);
4838
+ const children = [...state.sessions.values()]
4839
+ .filter((candidate) => candidate.session.parentSessionId === sessionId)
4840
+ .map((candidate) => candidate.session.sessionId)
4841
+ .sort()
4842
+ .reverse();
4843
+ pending.push(...children);
4844
+ }
4845
+ return subtree;
4846
+ }
4847
+
4848
+ private tombstoneGroup(
4849
+ state: IStorageState,
4850
+ rootSessionId: string,
4851
+ ): IFlexSessionTombstone[] {
4852
+ return [...state.tombstones.values()]
4853
+ .filter((tombstone) =>
4854
+ (tombstone.rootSessionId ?? tombstone.sessionId) === rootSessionId)
4855
+ .sort((left, right) =>
4856
+ (right.depth ?? 0) - (left.depth ?? 0)
4857
+ || left.sessionId.localeCompare(right.sessionId));
4858
+ }
4859
+
4860
+ private descendantTombstoneRoots(
4861
+ state: IStorageState,
4862
+ ancestorSessionId: string,
4863
+ ): string[] {
4864
+ return [...new Set([...state.tombstones.values()]
4865
+ .map((tombstone) => tombstone.rootSessionId ?? tombstone.sessionId)
4866
+ .filter((rootSessionId) =>
4867
+ rootSessionId !== ancestorSessionId
4868
+ && this.isSessionAncestor(state, ancestorSessionId, rootSessionId)))]
4869
+ .sort((left, right) =>
4870
+ this.sessionAncestryDepth(state, right) - this.sessionAncestryDepth(state, left)
4871
+ || left.localeCompare(right));
4872
+ }
4873
+
4874
+ private orderTombstoneRootsChildFirst(
4875
+ state: IStorageState,
4876
+ rootSessionIds: readonly string[],
4877
+ ): string[] {
4878
+ return [...rootSessionIds].sort((left, right) =>
4879
+ this.sessionAncestryDepth(state, right) - this.sessionAncestryDepth(state, left)
4880
+ || left.localeCompare(right));
4881
+ }
4882
+
3930
4883
  private finishTombstoneCleanup(
3931
4884
  state: IStorageState,
3932
- sessionId: string,
4885
+ rootSessionId: string,
3933
4886
  scopeId: string,
3934
- scope?: TScope,
4887
+ invocation?: { scopeId: string; scope: TScope },
3935
4888
  ): Promise<void> {
3936
- const existing = state.tombstoneCleanups.get(sessionId);
4889
+ const existing = state.tombstoneCleanups.get(rootSessionId);
3937
4890
  if (existing) return existing;
3938
4891
  let cleanup!: Promise<void>;
3939
- cleanup = this.finishTombstoneCleanupInternal(state, sessionId, scopeId, scope).finally(() => {
3940
- if (state.tombstoneCleanups.get(sessionId) === cleanup) {
3941
- state.tombstoneCleanups.delete(sessionId);
4892
+ cleanup = this.finishTombstoneCleanupInternal(
4893
+ state,
4894
+ rootSessionId,
4895
+ scopeId,
4896
+ invocation,
4897
+ ).finally(() => {
4898
+ if (state.tombstoneCleanups.get(rootSessionId) === cleanup) {
4899
+ state.tombstoneCleanups.delete(rootSessionId);
3942
4900
  }
3943
4901
  });
3944
- state.tombstoneCleanups.set(sessionId, cleanup);
4902
+ state.tombstoneCleanups.set(rootSessionId, cleanup);
3945
4903
  return cleanup;
3946
4904
  }
3947
4905
 
3948
4906
  private async finishTombstoneCleanupInternal(
3949
4907
  state: IStorageState,
3950
- sessionId: string,
4908
+ rootSessionId: string,
3951
4909
  scopeId: string,
3952
- scope?: TScope,
4910
+ invocation?: { scopeId: string; scope: TScope },
3953
4911
  ): Promise<void> {
3954
- const retained = state.retainedSessionCleanups.get(sessionId);
3955
- if (retained) {
4912
+ for (const descendantRoot of this.descendantTombstoneRoots(state, rootSessionId)) {
4913
+ await this.finishTombstoneCleanup(state, descendantRoot, scopeId, invocation);
4914
+ }
4915
+ const group = this.tombstoneGroup(state, rootSessionId);
4916
+ if (group.length === 0) return;
4917
+ const groupIds = new Set(group.map((tombstone) => tombstone.sessionId));
4918
+ const reason = this.trustInternalError(new FlexHarnessAbortError('The session subtree was deleted.'));
4919
+ const contextFor = (
4920
+ sessionId: string,
4921
+ retained?: IRetainedSessionCleanup,
4922
+ ): IFlexAgentContextInvocation<TScope> | undefined => invocation
4923
+ ? this.createCompactorContext(
4924
+ invocation.scopeId,
4925
+ invocation.scope,
4926
+ state.storageKey,
4927
+ sessionId,
4928
+ )
4929
+ : retained?.stored.compactorContext as IFlexAgentContextInvocation<TScope> | undefined;
4930
+ for (const sessionId of groupIds) {
4931
+ const retained = state.retainedSessionCleanups.get(sessionId);
4932
+ const context = contextFor(sessionId, retained);
4933
+ if (retained) {
4934
+ this.abortCompactorLifecycle(retained.stored, reason);
4935
+ this.cancelStoredPromptQueue(retained.stored, reason, undefined, context);
4936
+ }
4937
+ const run = state.activeRuns.get(sessionId);
4938
+ if (run) this.cancelRun(run, reason, context);
4939
+ }
4940
+ await Promise.allSettled([...groupIds]
4941
+ .map((sessionId) => state.sessionInitializations.get(sessionId))
4942
+ .filter((completion): completion is Promise<void> => completion !== undefined));
4943
+ for (const tombstone of group) {
4944
+ const sessionId = tombstone.sessionId;
4945
+ const retained = state.retainedSessionCleanups.get(sessionId);
3956
4946
  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
4947
  const run = state.activeRuns.get(sessionId);
3964
- if (run) this.cancelRun(run, reason, invocation as IFlexAgentContextInvocation<TScope>);
3965
- if (run) {
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) {
4948
+ const context = contextFor(sessionId, retained);
4949
+ if (retained && !retained.stored.agentSessionAbortCompleted) {
3972
4950
  try {
3973
- await this.abortStoredSession(retained.stored, reason, invocation);
4951
+ await this.abortStoredSession(retained.stored, reason, context);
3974
4952
  } catch (error) {
3975
4953
  errors.push(this.projectOperationError(
3976
4954
  error,
@@ -3981,53 +4959,66 @@ export class FlexHarness<TScope = unknown> {
3981
4959
  ));
3982
4960
  }
3983
4961
  }
3984
- if (retained.stored.promptQueueDrain) {
3985
- const settled = await Promise.allSettled([retained.stored.promptQueueDrain]);
4962
+ if (run) {
4963
+ const settled = await Promise.allSettled([run.completion]);
3986
4964
  if (settled[0].status === 'rejected') {
3987
4965
  this.appendUnexpectedErrors(errors, settled[0].reason);
3988
4966
  }
3989
4967
  }
3990
- try {
3991
- await this.closeStoredSession(retained.stored, invocation);
3992
- } catch (error) {
3993
- this.appendUnexpectedErrors(errors, this.projectOperationError(
3994
- error,
3995
- 'toolCleanup',
3996
- scopeId,
3997
- sessionId,
3998
- 'session-delete',
3999
- ));
4968
+ if (retained) {
4969
+ if (retained.stored.promptQueueDrain) {
4970
+ const settled = await Promise.allSettled([retained.stored.promptQueueDrain]);
4971
+ if (settled[0].status === 'rejected') errors.push(settled[0].reason);
4972
+ }
4973
+ try {
4974
+ await this.closeStoredSession(retained.stored, context);
4975
+ } catch (error) {
4976
+ this.appendUnexpectedErrors(errors, this.projectOperationError(
4977
+ error,
4978
+ 'toolCleanup',
4979
+ scopeId,
4980
+ sessionId,
4981
+ 'session-delete',
4982
+ ));
4983
+ }
4000
4984
  }
4001
4985
  if (errors.length > 0) {
4002
- if (state.lifecycle === 'retired' && !this.storedSessionCleanupCompleted(retained.stored)) {
4003
- this.orphanedStoredSessions.add(retained.stored);
4004
- }
4986
+ if (
4987
+ retained
4988
+ && state.lifecycle === 'retired'
4989
+ && !this.storedSessionCleanupCompleted(retained.stored)
4990
+ ) this.orphanedStoredSessions.add(retained.stored);
4005
4991
  throw combineErrors(errors);
4006
4992
  }
4007
- this.purgeStoredPromptQueue(retained.stored);
4008
- }
4009
- if (!retained?.domainsCompleted) {
4010
- if (this.hasOrphanedSessionResources(state.storageKey, sessionId)) {
4011
- throw new Error(`Session "${sessionId}" still has runtime cleanup ownership.`);
4993
+ if (retained) this.purgeStoredPromptQueue(retained.stored);
4994
+ if (!retained?.domainsCompleted) {
4995
+ if (this.hasOrphanedSessionResources(state.storageKey, sessionId)) {
4996
+ throw new Error(`Session "${sessionId}" still has runtime cleanup ownership.`);
4997
+ }
4998
+ await this.cleanupSessionDomains(state.storageKey, sessionId);
4999
+ if (retained) retained.domainsCompleted = true;
4012
5000
  }
4013
- await this.cleanupSessionDomains(state.storageKey, sessionId);
4014
- if (retained) retained.domainsCompleted = true;
4015
5001
  }
4016
5002
  await this.mutateScope(state, () => {
4017
- state.tombstones.delete(sessionId);
4018
- state.retainedSessionCleanups.delete(sessionId);
5003
+ for (const tombstone of group) {
5004
+ state.tombstones.delete(tombstone.sessionId);
5005
+ state.retainedSessionCleanups.delete(tombstone.sessionId);
5006
+ }
4019
5007
  }, true);
4020
5008
  }
4021
5009
 
4022
5010
  private async fenceNamespace(state: IStorageState, currentRun: IActiveRun, cause: unknown): Promise<void> {
4023
5011
  if (state.lifecycle === 'retired') return;
5012
+ if (this.storageDrains.has(state.storageKey)) {
5013
+ state.fenceAdditionalErrors.push(cause);
5014
+ return;
5015
+ }
4024
5016
  if (state.fenceInProgress) {
4025
5017
  state.fenceAdditionalErrors.push(cause);
4026
5018
  return;
4027
5019
  }
4028
5020
  state.fenceInProgress = true;
4029
5021
  state.lifecycle = 'fenced';
4030
- const detachedCleanupAttempts = this.beginDetachedCleanupSettlement(state);
4031
5022
  const reason = this.trustInternalError(new FlexHarnessAbortError('The session namespace was fenced.'));
4032
5023
  if (!state.compactorLifecycleController.signal.aborted) {
4033
5024
  state.compactorLifecycleController.abort(reason);
@@ -4056,23 +5047,49 @@ export class FlexHarness<TScope = unknown> {
4056
5047
  }
4057
5048
  const otherRuns = [...state.activeRuns.values()].filter((run) => run !== currentRun);
4058
5049
  for (const run of otherRuns) this.cancelRun(run, reason, contextFor(run.sessionId));
4059
- const runResults = await Promise.allSettled(otherRuns.map((run) => run.completion));
5050
+ const dependentRuns = otherRuns.filter((run) =>
5051
+ this.sessionsAreDependencyRelated(state, currentRun.sessionId, run.sessionId));
5052
+ const independentRuns = otherRuns.filter((run) => !dependentRuns.includes(run));
5053
+ const runResults = await Promise.allSettled(independentRuns.map((run) => run.completion));
4060
5054
  for (const result of runResults) {
4061
5055
  if (result.status === 'rejected') this.appendUnexpectedErrors(cleanupErrors, result.reason);
4062
5056
  }
5057
+ if (dependentRuns.length > 0) {
5058
+ state.fenceAdditionalErrors.push(cause, ...cleanupErrors);
5059
+ const stateLoad = this.stateLoads.get(state.storageKey);
5060
+ if (!stateLoad) {
5061
+ throw combineErrors([cause, ...cleanupErrors, new Error(
5062
+ 'The fenced namespace no longer has durable cleanup ownership.',
5063
+ )]);
5064
+ }
5065
+ const deferredDrain = this.drainStorage(
5066
+ state.storageKey,
5067
+ stateLoad,
5068
+ reason,
5069
+ { scopeId: invocation.scopeId, scope: invocation.scope },
5070
+ );
5071
+ void deferredDrain.catch(() => undefined);
5072
+ return;
5073
+ }
5074
+ const detachedCleanupAttempts = this.beginDetachedCleanupSettlement(state);
4063
5075
  await Promise.allSettled([...state.sessionInitializations.values()]);
4064
5076
  cleanupErrors.push(...await this.closeOrphanedResources(state.storageKey));
4065
5077
  await state.scopeQueue;
4066
- const currentTombstoneCleanup = state.tombstoneCleanups.get(currentRun.sessionId);
5078
+ const currentTombstoneRoot = state.tombstones.get(currentRun.sessionId)?.rootSessionId
5079
+ ?? currentRun.sessionId;
5080
+ const currentTombstoneCleanup = state.tombstoneCleanups.get(currentTombstoneRoot);
5081
+ const currentTombstoneSessions = new Set(
5082
+ this.tombstoneGroup(state, currentTombstoneRoot).map((tombstone) => tombstone.sessionId),
5083
+ );
4067
5084
  if (currentTombstoneCleanup) {
4068
5085
  this.retainOrphanedTombstoneCleanup(
4069
5086
  state.storageKey,
4070
- currentRun.sessionId,
5087
+ currentTombstoneRoot,
4071
5088
  currentTombstoneCleanup,
4072
5089
  );
4073
5090
  }
4074
5091
  const tombstoneAttempts = [...state.tombstoneCleanups.entries()]
4075
- .filter(([sessionId]) => sessionId !== currentRun.sessionId);
5092
+ .filter(([rootSessionId]) => rootSessionId !== currentTombstoneRoot);
4076
5093
  const tombstoneResults = await Promise.allSettled(
4077
5094
  tombstoneAttempts.map(([, cleanup]) => cleanup),
4078
5095
  );
@@ -4080,11 +5097,14 @@ export class FlexHarness<TScope = unknown> {
4080
5097
  if (result.status === 'rejected') this.appendUnexpectedErrors(cleanupErrors, result.reason);
4081
5098
  }
4082
5099
  const attemptedTombstones = new Set(tombstoneAttempts.map(([sessionId]) => sessionId));
5100
+ const attemptedTombstoneSessions = new Set([...attemptedTombstones].flatMap((rootSessionId) =>
5101
+ this.tombstoneGroup(state, rootSessionId).map((tombstone) => tombstone.sessionId)));
4083
5102
  const storedSessions = new Set([
4084
5103
  ...state.sessions.values(),
4085
5104
  ...[...state.retainedSessionCleanups]
4086
5105
  .filter(([sessionId]) =>
4087
- sessionId !== currentRun.sessionId && !attemptedTombstones.has(sessionId))
5106
+ !currentTombstoneSessions.has(sessionId)
5107
+ && !attemptedTombstoneSessions.has(sessionId))
4088
5108
  .map(([, retained]) => retained.stored),
4089
5109
  ]);
4090
5110
  for (const stored of storedSessions) {
@@ -4119,15 +5139,16 @@ export class FlexHarness<TScope = unknown> {
4119
5139
  if (cleanupErrors.length > 0) throw combineErrors([cause, ...cleanupErrors]);
4120
5140
  state.lifecycle = 'retired';
4121
5141
  state.sessions.clear();
4122
- const currentRetainedCleanup = state.retainedSessionCleanups.get(currentRun.sessionId);
5142
+ const currentRetainedCleanups = [...state.retainedSessionCleanups]
5143
+ .filter(([sessionId]) => currentTombstoneSessions.has(sessionId));
4123
5144
  state.retainedSessionCleanups.clear();
4124
- if (currentRetainedCleanup) {
4125
- state.retainedSessionCleanups.set(currentRun.sessionId, currentRetainedCleanup);
5145
+ for (const [sessionId, retained] of currentRetainedCleanups) {
5146
+ state.retainedSessionCleanups.set(sessionId, retained);
4126
5147
  }
4127
5148
  state.sessionDeletions.clear();
4128
5149
  state.tombstoneCleanups.clear();
4129
5150
  if (currentTombstoneCleanup) {
4130
- state.tombstoneCleanups.set(currentRun.sessionId, currentTombstoneCleanup);
5151
+ state.tombstoneCleanups.set(currentTombstoneRoot, currentTombstoneCleanup);
4131
5152
  }
4132
5153
  state.activeRuns.clear();
4133
5154
  state.pendingPermissions.clear();
@@ -4143,6 +5164,75 @@ export class FlexHarness<TScope = unknown> {
4143
5164
  }
4144
5165
  }
4145
5166
 
5167
+ private deferNamespaceDrain(
5168
+ state: IStorageState,
5169
+ cause: unknown,
5170
+ invocation?: { scopeId: string; scope: TScope },
5171
+ ): void {
5172
+ state.lifecycle = 'fenced';
5173
+ state.fenceAdditionalErrors.push(cause);
5174
+ const stateLoad = this.stateLoads.get(state.storageKey);
5175
+ if (!stateLoad) return;
5176
+ const drain = this.drainStorage(
5177
+ state.storageKey,
5178
+ stateLoad,
5179
+ this.trustInternalError(new FlexHarnessAbortError('The session namespace was fenced.')),
5180
+ invocation,
5181
+ );
5182
+ void drain.catch(() => undefined);
5183
+ }
5184
+
5185
+ private sessionsAreDependencyRelated(
5186
+ state: IStorageState,
5187
+ leftSessionId: string,
5188
+ rightSessionId: string,
5189
+ ): boolean {
5190
+ return this.isSessionAncestor(state, leftSessionId, rightSessionId)
5191
+ || this.isSessionAncestor(state, rightSessionId, leftSessionId);
5192
+ }
5193
+
5194
+ private isSessionAncestor(
5195
+ state: IStorageState,
5196
+ ancestorSessionId: string,
5197
+ descendantSessionId: string,
5198
+ ): boolean {
5199
+ const visited = new Set<string>();
5200
+ let currentId: string | undefined = descendantSessionId;
5201
+ while (currentId) {
5202
+ if (visited.has(currentId)) return false;
5203
+ visited.add(currentId);
5204
+ const parentSessionId = this.sessionParentSessionId(state, currentId);
5205
+ if (parentSessionId === ancestorSessionId) return true;
5206
+ currentId = parentSessionId;
5207
+ }
5208
+ return false;
5209
+ }
5210
+
5211
+ private sessionAncestryDepth(state: IStorageState, sessionId: string): number {
5212
+ const visited = new Set<string>();
5213
+ let currentId: string | undefined = sessionId;
5214
+ let depth = 0;
5215
+ while (currentId) {
5216
+ if (visited.has(currentId)) return depth;
5217
+ visited.add(currentId);
5218
+ const parentSessionId = this.sessionParentSessionId(state, currentId);
5219
+ if (!parentSessionId) return depth;
5220
+ depth++;
5221
+ currentId = parentSessionId;
5222
+ }
5223
+ return depth;
5224
+ }
5225
+
5226
+ private sessionParentSessionId(state: IStorageState, sessionId: string): string | undefined {
5227
+ return this.sessionMetadata(state, sessionId)?.parentSessionId
5228
+ ?? state.tombstones.get(sessionId)?.parentSessionId;
5229
+ }
5230
+
5231
+ private sessionMetadata(state: IStorageState, sessionId: string): IFlexSession | undefined {
5232
+ return state.sessions.get(sessionId)?.session
5233
+ ?? state.retainedSessionCleanups.get(sessionId)?.stored.session;
5234
+ }
5235
+
4146
5236
  private async closeStoredSession(
4147
5237
  stored: IStoredSessionState,
4148
5238
  context: IFlexAgentContextInvocation<unknown> = stored.compactorContext!,
@@ -4497,20 +5587,27 @@ export class FlexHarness<TScope = unknown> {
4497
5587
  }
4498
5588
  this.purgeStoredPromptQueue(stored);
4499
5589
  }
4500
- for (const sessionId of [...state.tombstones.keys()]) {
4501
- if (attemptedTombstones.has(sessionId)) continue;
4502
- const retained = state.retainedSessionCleanups.get(sessionId);
5590
+ const roots = this.orderTombstoneRootsChildFirst(
5591
+ state,
5592
+ [...new Set([...state.tombstones.values()].map((tombstone) =>
5593
+ tombstone.rootSessionId ?? tombstone.sessionId))],
5594
+ );
5595
+ for (const rootSessionId of roots) {
5596
+ if (attemptedTombstones.has(rootSessionId)) continue;
5597
+ if (this.descendantTombstoneRoots(state, rootSessionId).length > 0) continue;
5598
+ const retained = state.retainedSessionCleanups.get(rootSessionId);
4503
5599
  try {
4504
5600
  await this.finishTombstoneCleanup(
4505
5601
  state,
4506
- sessionId,
5602
+ rootSessionId,
4507
5603
  invocation?.scopeId ?? retained?.stored.session.scopeId ?? state.scopeIdHint,
4508
- invocation?.scope,
5604
+ invocation,
4509
5605
  );
4510
5606
  } catch (error) {
4511
5607
  this.appendUnexpectedErrors(errors, error);
4512
5608
  }
4513
5609
  }
5610
+ errors.push(...state.fenceAdditionalErrors.splice(0));
4514
5611
  if (errors.length > 0) throw combineErrors(errors);
4515
5612
  state.lifecycle = 'retired';
4516
5613
  state.sessions.clear();