@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.
@@ -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
- const existingDeletion = state.sessionDeletions.get(sessionId);
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(sessionId) === deletion) {
989
- state.sessionDeletions.delete(sessionId);
1135
+ if (state.sessionDeletions.get(deletionKey) === deletion) {
1136
+ state.sessionDeletions.delete(deletionKey);
990
1137
  }
991
1138
  });
992
- state.sessionDeletions.set(sessionId, deletion);
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.finishTombstoneCleanup(state, sessionId, scopeId, scope);
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
- const deletedSession = publicSnapshot(stored.session);
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
- 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
- });
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.finishTombstoneCleanup(state, sessionId, scopeId, scope);
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
- this.emitEvent(scopeId, sessionId, { type: 'session.deleted', session: deletedSession });
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 = () => 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();
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
- pendingOwner.controller.signal.removeEventListener('abort', abortAdmission);
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
- tools = toolHandle
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
- toolHandle.tools,
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 tombstone of [...state.tombstones.values()]) {
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
- await this.cleanupSessionDomains(storageKey, tombstone.sessionId);
3230
- state.tombstones.delete(tombstone.sessionId);
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: new AbortController(),
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: new AbortController(),
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
- sessionId: string,
4686
+ rootSessionId: string,
3933
4687
  scopeId: string,
3934
- scope?: TScope,
4688
+ invocation?: { scopeId: string; scope: TScope },
3935
4689
  ): Promise<void> {
3936
- const existing = state.tombstoneCleanups.get(sessionId);
4690
+ const existing = state.tombstoneCleanups.get(rootSessionId);
3937
4691
  if (existing) return existing;
3938
4692
  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);
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(sessionId, cleanup);
4703
+ state.tombstoneCleanups.set(rootSessionId, cleanup);
3945
4704
  return cleanup;
3946
4705
  }
3947
4706
 
3948
4707
  private async finishTombstoneCleanupInternal(
3949
4708
  state: IStorageState,
3950
- sessionId: string,
4709
+ rootSessionId: string,
3951
4710
  scopeId: string,
3952
- scope?: TScope,
4711
+ invocation?: { scopeId: string; scope: TScope },
3953
4712
  ): Promise<void> {
3954
- const retained = state.retainedSessionCleanups.get(sessionId);
3955
- if (retained) {
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
- 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) {
4749
+ const context = contextFor(sessionId, retained);
4750
+ if (retained && !retained.stored.agentSessionAbortCompleted) {
3972
4751
  try {
3973
- await this.abortStoredSession(retained.stored, reason, invocation);
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 (retained.stored.promptQueueDrain) {
3985
- const settled = await Promise.allSettled([retained.stored.promptQueueDrain]);
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
- 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
- ));
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 (state.lifecycle === 'retired' && !this.storedSessionCleanupCompleted(retained.stored)) {
4003
- this.orphanedStoredSessions.add(retained.stored);
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
- if (!retained?.domainsCompleted) {
4010
- if (this.hasOrphanedSessionResources(state.storageKey, sessionId)) {
4011
- throw new Error(`Session "${sessionId}" still has runtime cleanup ownership.`);
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
- state.tombstones.delete(sessionId);
4018
- state.retainedSessionCleanups.delete(sessionId);
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 runResults = await Promise.allSettled(otherRuns.map((run) => run.completion));
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 currentTombstoneCleanup = state.tombstoneCleanups.get(currentRun.sessionId);
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
- currentRun.sessionId,
4888
+ currentTombstoneRoot,
4071
4889
  currentTombstoneCleanup,
4072
4890
  );
4073
4891
  }
4074
4892
  const tombstoneAttempts = [...state.tombstoneCleanups.entries()]
4075
- .filter(([sessionId]) => sessionId !== currentRun.sessionId);
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
- sessionId !== currentRun.sessionId && !attemptedTombstones.has(sessionId))
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 currentRetainedCleanup = state.retainedSessionCleanups.get(currentRun.sessionId);
4943
+ const currentRetainedCleanups = [...state.retainedSessionCleanups]
4944
+ .filter(([sessionId]) => currentTombstoneSessions.has(sessionId));
4123
4945
  state.retainedSessionCleanups.clear();
4124
- if (currentRetainedCleanup) {
4125
- state.retainedSessionCleanups.set(currentRun.sessionId, currentRetainedCleanup);
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(currentRun.sessionId, currentTombstoneCleanup);
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
- for (const sessionId of [...state.tombstones.keys()]) {
4501
- if (attemptedTombstones.has(sessionId)) continue;
4502
- const retained = state.retainedSessionCleanups.get(sessionId);
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
- sessionId,
5403
+ rootSessionId,
4507
5404
  invocation?.scopeId ?? retained?.stored.session.scopeId ?? state.scopeIdHint,
4508
- invocation?.scope,
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();