@modelprofile.com/flexharness 3.3.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.
@@ -16,6 +16,7 @@ import {
16
16
  errorToInfo,
17
17
  } from './errors.js';
18
18
  import type {
19
+ IFlexAgentContextInvocation,
19
20
  IFlexAgentSessionPolicy,
20
21
  IFlexAttachmentMessagePart,
21
22
  IFlexBackgroundExecution,
@@ -49,6 +50,7 @@ import type {
49
50
  IFlexScopeSnapshot,
50
51
  IFlexSession,
51
52
  IFlexSessionTombstone,
53
+ IFlexSubagentDefinition,
52
54
  IFlexTerminalProjection,
53
55
  IFlexToolHandle,
54
56
  IFlexToolMessagePart,
@@ -96,6 +98,7 @@ type TRunPhase =
96
98
 
97
99
  interface IStoredSessionState {
98
100
  storageKey: string;
101
+ compactorContext?: IFlexAgentContextInvocation<unknown>;
99
102
  session: IFlexSession;
100
103
  messages: IFlexMessage[];
101
104
  stagedTerminals: IFlexTerminalProjection[];
@@ -113,6 +116,7 @@ interface IStoredSessionState {
113
116
  executionContextCloseCompleted: boolean;
114
117
  jobStoreReleased: boolean;
115
118
  jobs?: NonNullable<plugins.IToolExecutionContext['jobs']>;
119
+ compactorLifecycleController: AbortController;
116
120
  promptQueue: IQueuedPrompt[];
117
121
  outstandingPromptsById: Map<string, IQueuedPrompt>;
118
122
  terminalPromptQueueEntries: Map<string, IFlexPromptQueueEntry>;
@@ -139,6 +143,12 @@ interface IOrphanedProviderRelease {
139
143
  release: () => Promise<void>;
140
144
  }
141
145
 
146
+ interface IOrphanedTombstoneCleanup {
147
+ storageKey: string;
148
+ sessionId: string;
149
+ completion: Promise<void>;
150
+ }
151
+
142
152
  interface IOrphanedExecutionContextOwner {
143
153
  storageKey: string;
144
154
  sessionId: string;
@@ -146,6 +156,7 @@ interface IOrphanedExecutionContextOwner {
146
156
 
147
157
  interface IStorageState {
148
158
  storageKey: string;
159
+ compactorLifecycleController: AbortController;
149
160
  scopeIdHint: string;
150
161
  revision: number;
151
162
  sessions: Map<string, IStoredSessionState>;
@@ -167,6 +178,8 @@ interface IStorageState {
167
178
  interface IActiveRun {
168
179
  state: IStorageState;
169
180
  stored: IStoredSessionState;
181
+ originCompactorContext: IFlexAgentContextInvocation<unknown>;
182
+ deferredCompactorContext?: IFlexAgentContextInvocation<unknown>;
170
183
  scopeId: string;
171
184
  scope: unknown;
172
185
  sessionId: string;
@@ -187,6 +200,8 @@ interface IActiveRun {
187
200
  callbacksClosed: boolean;
188
201
  reasoningPartIds: Map<string, string>;
189
202
  toolPartIds: Map<string, string>;
203
+ subagentCallCount: number;
204
+ subagentSessionIds: Set<string>;
190
205
  pendingPermissionIds: Set<string>;
191
206
  phase: TRunPhase;
192
207
  reservedUserMessage?: IFlexMessage;
@@ -269,6 +284,18 @@ interface IExecutableToolRecord extends Record<string, unknown> {
269
284
  execute?: (input: unknown, options: unknown) => unknown;
270
285
  }
271
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
+
272
299
  type TEventDetails = Record<string, unknown> & {
273
300
  type: TFlexHarnessEvent['type'];
274
301
  };
@@ -297,6 +324,19 @@ const maxProjectedErrorMessageBytes = 2048;
297
324
  const maxProjectedErrorCodeBytes = 128;
298
325
  const maxScheduleDebounceMs = 24 * 60 * 60 * 1000;
299
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;
300
340
  const repairCancellationMessage = 'The process stopped before this run completed.';
301
341
  const scopeRetirementMessage = 'The scope is being retired.';
302
342
  const externalErrorFallback: IFlexErrorInfo = Object.freeze({
@@ -396,6 +436,102 @@ function validateIdentifier(value: string, name: string): void {
396
436
  }
397
437
  }
398
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
+
399
535
  function requireTransferIdentifier(value: string, field: string): void {
400
536
  if (Buffer.byteLength(value, 'utf8') > maxTransferIdentifierBytes) {
401
537
  throw new FlexHarnessValidationError(`${field} exceeds the transfer limit.`);
@@ -694,7 +830,9 @@ function resolvePromptQueueLimits(
694
830
  return resolved;
695
831
  }
696
832
 
697
- function normalizeAgentSessionPolicy(policy: IFlexAgentSessionPolicy = {}): IFlexAgentSessionPolicy {
833
+ function normalizeAgentSessionPolicy<TScope>(
834
+ policy: IFlexAgentSessionPolicy<TScope> = {},
835
+ ): IFlexAgentSessionPolicy<TScope> {
698
836
  return {
699
837
  ...(policy.contextBuilder === undefined ? {} : { contextBuilder: policy.contextBuilder }),
700
838
  ...(policy.contextCompactor === undefined ? {} : { contextCompactor: policy.contextCompactor }),
@@ -723,15 +861,19 @@ export class FlexHarness<TScope = unknown> {
723
861
  private readonly toolProvider: IFlexHarnessOptions<TScope>['toolProvider'];
724
862
  private readonly executionContextProvider: IFlexHarnessOptions<TScope>['executionContextProvider'];
725
863
  private readonly stores: IFlexHarnessStores;
726
- private readonly agentSessionPolicy: IFlexAgentSessionPolicy;
864
+ private readonly agentSessionPolicy: IFlexAgentSessionPolicy<TScope>;
727
865
  private readonly toolOutputLimits: Required<NonNullable<IFlexHarnessOptions<TScope>['toolOutputLimits']>>;
728
866
  private readonly callbackLimits: Required<IFlexCallbackLimits>;
729
867
  private readonly promptQueueLimits: Required<IFlexPromptQueueLimits>;
730
868
  private readonly externalErrorProjector?: TFlexExternalErrorProjector;
869
+ private readonly subagents: ReadonlyMap<string, Readonly<IFlexSubagentDefinition>>;
870
+ private readonly maxSubagentDepth: number;
871
+ private readonly maxSubagentCallsPerRun: number;
731
872
  private readonly stateLoads = new Map<string, Promise<IStorageState>>();
732
873
  private readonly scopeAdmissions = new Map<string, IScopeAdmissionState>();
733
874
  private readonly scopeRetirements = new Map<string, Promise<void>>();
734
875
  private readonly storageDrains = new Map<string, Promise<void>>();
876
+ private readonly storageCompactorLifecycleControllers = new Map<string, AbortController>();
735
877
  private orphanedResourceQueue: Promise<void> = Promise.resolve();
736
878
  private readonly listeners = new Set<TFlexHarnessEventListener>();
737
879
  private readonly trustedInternalErrors = new WeakSet<object>();
@@ -741,7 +883,15 @@ export class FlexHarness<TScope = unknown> {
741
883
  IOrphanedExecutionContextOwner
742
884
  >();
743
885
  private readonly orphanedProviderReleases = new Map<string, IOrphanedProviderRelease>();
886
+ private readonly orphanedTombstoneCleanups = new Map<string, IOrphanedTombstoneCleanup>();
744
887
  private readonly pendingPromptAdmissionOwners = new Set<IPendingPromptAdmission>();
888
+ private readonly compactorInvocationContext = new plugins.AsyncLocalStorage<
889
+ IFlexAgentContextInvocation<TScope>
890
+ >();
891
+ private readonly deferredCompactorContexts = new WeakMap<
892
+ IFlexAgentContextInvocation<unknown>,
893
+ IFlexAgentContextInvocation<unknown>
894
+ >();
745
895
  private sequence = 0;
746
896
  private promptQueueSequence = 0;
747
897
  private pendingPromptAdmissions = 0;
@@ -763,6 +913,19 @@ export class FlexHarness<TScope = unknown> {
763
913
  this.callbackLimits = resolveCallbackLimits(options.callbackLimits);
764
914
  this.promptQueueLimits = resolvePromptQueueLimits(options.promptQueueLimits);
765
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
+ );
766
929
  }
767
930
 
768
931
  public async listSessions(scopeId: string): Promise<IFlexSession[]> {
@@ -828,6 +991,7 @@ export class FlexHarness<TScope = unknown> {
828
991
  updatedAt: timestamp,
829
992
  status: 'idle',
830
993
  activity: { status: 'idle' },
994
+ depth: 0,
831
995
  };
832
996
  resolved.state.sessions.set(
833
997
  sessionId,
@@ -841,6 +1005,7 @@ export class FlexHarness<TScope = unknown> {
841
1005
  metadata,
842
1006
  scopeId,
843
1007
  resolved.scope.scope,
1008
+ placeholder.compactorLifecycleController,
844
1009
  );
845
1010
  const loaded = await initialization;
846
1011
  if (resolved.state.sessions.get(sessionId) !== placeholder) {
@@ -867,6 +1032,8 @@ export class FlexHarness<TScope = unknown> {
867
1032
  const tombstone: IFlexSessionTombstone = {
868
1033
  sessionId,
869
1034
  deletedAt: new Date().toISOString(),
1035
+ rootSessionId: sessionId,
1036
+ depth: 0,
870
1037
  };
871
1038
  let tombstoneCommitted = false;
872
1039
  let domainCleanupCompleted = false;
@@ -876,7 +1043,13 @@ export class FlexHarness<TScope = unknown> {
876
1043
  resolved.state.tombstones.set(sessionId, tombstone);
877
1044
  }, true);
878
1045
  tombstoneCommitted = true;
879
- await this.finishTombstoneCleanup(resolved.state, sessionId, scopeId);
1046
+ completeInitialization();
1047
+ await this.finishTombstoneCleanup(
1048
+ resolved.state,
1049
+ sessionId,
1050
+ scopeId,
1051
+ { scopeId, scope: resolved.scope.scope },
1052
+ );
880
1053
  domainCleanupCompleted = true;
881
1054
  } catch (cleanupError) {
882
1055
  const projectedCleanup = this.projectOperationError(
@@ -898,6 +1071,7 @@ export class FlexHarness<TScope = unknown> {
898
1071
  this.trustInternalError(new FlexHarnessAbortError(
899
1072
  'The session namespace was fenced after uncertain creation cleanup.',
900
1073
  )),
1074
+ { scopeId, scope: resolved.scope.scope },
901
1075
  );
902
1076
  } catch {
903
1077
  // The projected creation and cleanup errors remain safe for the caller.
@@ -950,56 +1124,115 @@ export class FlexHarness<TScope = unknown> {
950
1124
  }
951
1125
 
952
1126
  public async deleteSession(scopeId: string, sessionId: string): Promise<void> {
953
- const { state } = await this.resolveState(scopeId);
1127
+ const { scope, state } = await this.resolveState(scopeId);
954
1128
  validateIdentifier(sessionId, 'sessionId');
955
- 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);
956
1132
  if (existingDeletion) return existingDeletion;
957
1133
  let deletion!: Promise<void>;
958
- deletion = this.deleteSessionInternal(state, scopeId, sessionId).finally(() => {
959
- if (state.sessionDeletions.get(sessionId) === deletion) {
960
- state.sessionDeletions.delete(sessionId);
1134
+ deletion = this.deleteSessionInternal(state, scopeId, scope.scope, sessionId).finally(() => {
1135
+ if (state.sessionDeletions.get(deletionKey) === deletion) {
1136
+ state.sessionDeletions.delete(deletionKey);
961
1137
  }
962
1138
  });
963
- state.sessionDeletions.set(sessionId, deletion);
1139
+ state.sessionDeletions.set(deletionKey, deletion);
964
1140
  return deletion;
965
1141
  }
966
1142
 
967
1143
  private async deleteSessionInternal(
968
1144
  state: IStorageState,
969
1145
  scopeId: string,
1146
+ scope: TScope,
970
1147
  sessionId: string,
971
1148
  ): Promise<void> {
972
1149
  const existingTombstone = state.tombstones.get(sessionId);
973
1150
  if (existingTombstone) {
1151
+ const rootSessionId = existingTombstone.rootSessionId ?? existingTombstone.sessionId;
1152
+ const descendantRoots = this.descendantTombstoneRoots(state, sessionId)
1153
+ .filter((candidate) => candidate !== rootSessionId);
974
1154
  try {
975
- await this.finishTombstoneCleanup(state, sessionId, scopeId);
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 });
976
1166
  } catch (error) {
977
1167
  throw this.projectOperationError(error, 'persistence', scopeId, sessionId, 'session-delete');
978
1168
  }
979
1169
  return;
980
1170
  }
981
1171
  const stored = this.requireSession(state, sessionId);
982
- const deletedSession = publicSnapshot(stored.session);
1172
+ let deletedSessions: IFlexSession[] = [];
1173
+ let descendantRoots: string[] = [];
983
1174
  await this.mutateScope(state, () => {
984
1175
  if (state.sessions.get(sessionId) !== stored) {
985
1176
  throw new FlexHarnessNotFoundError('Session', sessionId);
986
1177
  }
987
- state.sessions.delete(sessionId);
988
- state.retainedSessionCleanups.set(sessionId, {
989
- stored,
990
- domainsCompleted: false,
991
- });
992
- state.tombstones.set(sessionId, {
993
- sessionId,
994
- deletedAt: new Date().toISOString(),
995
- });
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
+ }
996
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
+ }
997
1215
  try {
998
- await this.finishTombstoneCleanup(state, sessionId, scopeId);
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 });
999
1227
  } catch (error) {
1000
1228
  throw this.projectOperationError(error, 'persistence', scopeId, sessionId, 'session-delete');
1001
1229
  }
1002
- 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
+ }
1003
1236
  }
1004
1237
 
1005
1238
  public async getMessages(scopeId: string, sessionId: string): Promise<IFlexMessage[]> {
@@ -1177,7 +1410,7 @@ export class FlexHarness<TScope = unknown> {
1177
1410
  ): Promise<boolean> {
1178
1411
  validateIdentifier(queueId, 'queueId');
1179
1412
  requireTransferIdentifier(queueId, 'queueId');
1180
- const { state } = await this.resolveState(scopeId);
1413
+ const { scope, state } = await this.resolveState(scopeId);
1181
1414
  const stored = this.requireSession(state, sessionId);
1182
1415
  const queued = stored.outstandingPromptsById.get(queueId);
1183
1416
  if (!queued) {
@@ -1187,6 +1420,7 @@ export class FlexHarness<TScope = unknown> {
1187
1420
  return this.cancelQueuedPrompt(
1188
1421
  queued,
1189
1422
  this.trustInternalError(new FlexHarnessAbortError('The queued prompt was cancelled.')),
1423
+ this.createCompactorContext(scopeId, scope.scope, state.storageKey, sessionId),
1190
1424
  );
1191
1425
  }
1192
1426
 
@@ -1196,20 +1430,28 @@ export class FlexHarness<TScope = unknown> {
1196
1430
  scheduleKey: string,
1197
1431
  ): Promise<boolean> {
1198
1432
  validateIdentifier(scheduleKey, 'scheduleKey');
1199
- const { state } = await this.resolveState(scopeId);
1433
+ const { scope, state } = await this.resolveState(scopeId);
1200
1434
  const stored = this.requireSession(state, sessionId);
1201
1435
  const queued = [...stored.outstandingPromptsById.values()]
1202
1436
  .find((entry) => entry.scheduleKey === scheduleKey);
1203
1437
  if (!queued) return false;
1204
1438
  const cancellation = this.trustInternalError(new FlexHarnessAbortError('The scheduled run was cancelled.'));
1205
- return this.cancelQueuedPrompt(queued, cancellation);
1439
+ return this.cancelQueuedPrompt(
1440
+ queued,
1441
+ cancellation,
1442
+ this.createCompactorContext(scopeId, scope.scope, state.storageKey, sessionId),
1443
+ );
1206
1444
  }
1207
1445
 
1208
1446
  public async abort(scopeId: string, sessionId: string): Promise<boolean> {
1209
- const { state } = await this.resolveState(scopeId);
1447
+ const { scope, state } = await this.resolveState(scopeId);
1210
1448
  const run = state.activeRuns.get(sessionId);
1211
1449
  if (!run || run.phase === 'finalizing' || run.phase === 'promoting') return false;
1212
- this.cancelRun(run, this.trustInternalError(new FlexHarnessAbortError()));
1450
+ this.cancelRun(
1451
+ run,
1452
+ this.trustInternalError(new FlexHarnessAbortError()),
1453
+ this.createCompactorContext(scopeId, scope.scope, state.storageKey, sessionId),
1454
+ );
1213
1455
  return true;
1214
1456
  }
1215
1457
 
@@ -1233,12 +1475,16 @@ export class FlexHarness<TScope = unknown> {
1233
1475
  decision: TFlexPermissionDecision,
1234
1476
  ): Promise<void> {
1235
1477
  validateIdentifier(permissionId, 'permissionId');
1236
- const { state } = await this.resolveState(scopeId);
1478
+ const { scope, state } = await this.resolveState(scopeId);
1237
1479
  const pending = state.pendingPermissions.get(permissionId);
1238
1480
  if (!pending || pending.request.sessionId !== sessionId) {
1239
1481
  throw new FlexHarnessNotFoundError('Permission', permissionId);
1240
1482
  }
1241
- const response = pending.responseQueue.then(() => this.applyPermissionResponse(state, pending, decision));
1483
+ const context = this.createCompactorContext(scopeId, scope.scope, state.storageKey, sessionId);
1484
+ const response = pending.responseQueue.then(() => this.withCompactorContext(
1485
+ context,
1486
+ () => this.applyPermissionResponse(state, pending, decision, context),
1487
+ ));
1242
1488
  pending.responseQueue = response.then(() => undefined, () => undefined);
1243
1489
  return response;
1244
1490
  }
@@ -1275,7 +1521,7 @@ export class FlexHarness<TScope = unknown> {
1275
1521
  if (!reconciliation || typeof reconciliation !== 'object' || Array.isArray(reconciliation)) {
1276
1522
  throw new FlexHarnessValidationError('Tool execution reconciliation must be a plain object.');
1277
1523
  }
1278
- const { state } = await this.resolveState(scopeId);
1524
+ const { scope, state } = await this.resolveState(scopeId);
1279
1525
  const stored = this.requireSession(state, sessionId);
1280
1526
  this.assertStateAcceptingWork(state);
1281
1527
  let canonical: plugins.TAgentToolExecutionReconciliationOptions;
@@ -1299,7 +1545,10 @@ export class FlexHarness<TScope = unknown> {
1299
1545
  throw new FlexHarnessValidationError('Unknown tool execution reconciliation.');
1300
1546
  }
1301
1547
  try {
1302
- await stored.agentSession.reconcileToolExecution(intentId, canonical);
1548
+ await this.withCompactorContext(
1549
+ this.createCompactorContext(scopeId, scope.scope, state.storageKey, sessionId),
1550
+ () => stored.agentSession.reconcileToolExecution(intentId, canonical),
1551
+ );
1303
1552
  } catch (error) {
1304
1553
  throw this.projectOperationError(error, 'agentSession', scopeId, sessionId, 'tool-reconciliation');
1305
1554
  }
@@ -1311,22 +1560,28 @@ export class FlexHarness<TScope = unknown> {
1311
1560
  event: IJsonObject & { type: string },
1312
1561
  ): Promise<void> {
1313
1562
  this.validateRuntimeEvent(event);
1314
- const { state } = await this.resolveState(scopeId);
1563
+ const { scope, state } = await this.resolveState(scopeId);
1315
1564
  const stored = this.requireSession(state, sessionId);
1316
1565
  this.assertStateAcceptingWork(state);
1317
1566
  try {
1318
- await stored.agentSession.pushRuntimeEvent(cloneSerializable(event));
1567
+ await this.withCompactorContext(
1568
+ this.createCompactorContext(scopeId, scope.scope, state.storageKey, sessionId),
1569
+ () => stored.agentSession.pushRuntimeEvent(cloneSerializable(event)),
1570
+ );
1319
1571
  } catch (error) {
1320
1572
  throw this.projectOperationError(error, 'agentSession', scopeId, sessionId, 'runtime-event');
1321
1573
  }
1322
1574
  }
1323
1575
 
1324
1576
  public async compactSession(scopeId: string, sessionId: string): Promise<void> {
1325
- const { state } = await this.resolveState(scopeId);
1577
+ const { scope, state } = await this.resolveState(scopeId);
1326
1578
  const stored = this.requireSession(state, sessionId);
1327
1579
  this.assertStateAcceptingWork(state);
1328
1580
  try {
1329
- await stored.agentSession.compact();
1581
+ await this.withCompactorContext(
1582
+ this.createCompactorContext(scopeId, scope.scope, state.storageKey, sessionId),
1583
+ () => stored.agentSession.compact(),
1584
+ );
1330
1585
  } catch (error) {
1331
1586
  throw this.projectOperationError(error, 'agentSession', scopeId, sessionId, 'compaction');
1332
1587
  }
@@ -1403,11 +1658,14 @@ export class FlexHarness<TScope = unknown> {
1403
1658
  ): Promise<void> {
1404
1659
  validateIdentifier(executionId, 'executionId');
1405
1660
  requireTransferIdentifier(executionId, 'executionId');
1406
- const { state } = await this.resolveState(scopeId);
1661
+ const { scope, state } = await this.resolveState(scopeId);
1407
1662
  const stored = this.requireSession(state, sessionId);
1408
1663
  this.assertStateAcceptingWork(state);
1409
1664
  try {
1410
- await stored.agentSession.abortBackgroundExecution(executionId);
1665
+ await this.withCompactorContext(
1666
+ this.createCompactorContext(scopeId, scope.scope, state.storageKey, sessionId),
1667
+ () => stored.agentSession.abortBackgroundExecution(executionId),
1668
+ );
1411
1669
  } catch (error) {
1412
1670
  throw this.projectOperationError(error, 'agentSession', scopeId, sessionId, 'background-abort');
1413
1671
  }
@@ -1461,6 +1719,8 @@ export class FlexHarness<TScope = unknown> {
1461
1719
  options: IFlexPromptOptions,
1462
1720
  scheduleKey?: string,
1463
1721
  debounceMs?: number,
1722
+ subagentAdmission = false,
1723
+ admissionSignal?: AbortSignal,
1464
1724
  ): Promise<{ admission: IFlexPromptQueueAdmission; started: Promise<IFlexPromptAdmission> }> {
1465
1725
  this.assertOpen();
1466
1726
  const normalizedPrompt = normalizeFlexPrompt(prompt);
@@ -1481,20 +1741,35 @@ export class FlexHarness<TScope = unknown> {
1481
1741
  resolveSettled: () => resolveSettled(),
1482
1742
  };
1483
1743
  this.pendingPromptAdmissionOwners.add(pendingOwner);
1744
+ const admissionSignals = [
1745
+ pendingOwner.controller.signal,
1746
+ ...(admissionSignal === undefined ? [] : [admissionSignal]),
1747
+ ];
1484
1748
  let abortAdmission!: () => void;
1485
1749
  const abortPromise = new Promise<never>((_resolve, reject) => {
1486
- abortAdmission = () => reject(
1487
- pendingOwner.controller.signal.reason ?? new FlexHarnessAbortError(),
1488
- );
1489
- pendingOwner.controller.signal.addEventListener('abort', abortAdmission, { once: true });
1490
- 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();
1491
1758
  });
1492
1759
  try {
1493
1760
  const resolved = await Promise.race([this.resolveState(scopeId), abortPromise]);
1494
1761
  const state = resolved.state;
1495
1762
  await Promise.race([state.scopeQueue, abortPromise]);
1763
+ if (admissionSignal?.aborted) {
1764
+ throw admissionSignal.reason ?? new FlexHarnessAbortError();
1765
+ }
1496
1766
  this.assertStateAcceptingWork(state);
1497
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
+ }
1498
1773
  if (stored.outstandingPromptsById.size >= this.promptQueueLimits.maxOutstandingPromptsPerSession) {
1499
1774
  throw new FlexHarnessQueueFullError(
1500
1775
  `Session "${sessionId}" has reached its outstanding prompt limit.`,
@@ -1560,7 +1835,9 @@ export class FlexHarness<TScope = unknown> {
1560
1835
  started,
1561
1836
  };
1562
1837
  } finally {
1563
- pendingOwner.controller.signal.removeEventListener('abort', abortAdmission);
1838
+ for (const signal of admissionSignals) {
1839
+ signal.removeEventListener('abort', abortAdmission);
1840
+ }
1564
1841
  this.pendingPromptAdmissionOwners.delete(pendingOwner);
1565
1842
  releasePendingAdmission();
1566
1843
  pendingOwner.resolveSettled();
@@ -1636,6 +1913,12 @@ export class FlexHarness<TScope = unknown> {
1636
1913
  return {
1637
1914
  state: queued.state,
1638
1915
  stored: queued.stored,
1916
+ originCompactorContext: this.createCompactorContext(
1917
+ queued.scopeId,
1918
+ queued.scope as TScope,
1919
+ queued.state.storageKey,
1920
+ queued.sessionId,
1921
+ ),
1639
1922
  scopeId: queued.scopeId,
1640
1923
  scope: queued.scope,
1641
1924
  sessionId: queued.sessionId,
@@ -1653,6 +1936,8 @@ export class FlexHarness<TScope = unknown> {
1653
1936
  callbacksClosed: false,
1654
1937
  reasoningPartIds: new Map(),
1655
1938
  toolPartIds: new Map(),
1939
+ subagentCallCount: 0,
1940
+ subagentSessionIds: new Set(),
1656
1941
  pendingPermissionIds: new Set(),
1657
1942
  phase: 'admitting',
1658
1943
  completion: queued.completion,
@@ -1676,11 +1961,14 @@ export class FlexHarness<TScope = unknown> {
1676
1961
  const options = queued.options!;
1677
1962
  let projectionReserved = false;
1678
1963
  try {
1679
- run.transaction = await run.stored.agentSession.beginGeneration(
1680
- cloneSerializable(prompt.modelMessage.content) as Parameters<
1681
- plugins.IAgentSession['beginGeneration']
1682
- >[0],
1683
- { generationId: run.runId },
1964
+ run.transaction = await this.withRunCompactorContext(
1965
+ run,
1966
+ () => run.stored.agentSession.beginGeneration(
1967
+ cloneSerializable(prompt.modelMessage.content) as Parameters<
1968
+ plugins.IAgentSession['beginGeneration']
1969
+ >[0],
1970
+ { generationId: run.runId },
1971
+ ),
1684
1972
  );
1685
1973
  this.assertPromptPromotion(queued, run);
1686
1974
  const reservation = this.createReservation(run, prompt);
@@ -1759,13 +2047,13 @@ export class FlexHarness<TScope = unknown> {
1759
2047
  prepare: (context: { generationId: string; abortSignal: AbortSignal }) =>
1760
2048
  this.prepareGeneration(run, options, context.abortSignal),
1761
2049
  };
1762
- const generation = run.scheduleKey
2050
+ const generation = this.withRunCompactorContext(run, () => run.scheduleKey
1763
2051
  ? run.stored.agentSession.scheduleGenerate({
1764
2052
  key: run.scheduleKey,
1765
2053
  debounceMs: run.debounceMs,
1766
2054
  ...generateOptions,
1767
2055
  })
1768
- : run.stored.agentSession.generate(generateOptions);
2056
+ : run.stored.agentSession.generate(generateOptions));
1769
2057
  const execution = this.executeRun(run, generation).then(
1770
2058
  (result) => this.finishQueuedPrompt(queued, 'completed', undefined, result),
1771
2059
  (error) => this.finishQueuedPrompt(
@@ -1853,7 +2141,11 @@ export class FlexHarness<TScope = unknown> {
1853
2141
  }
1854
2142
  }
1855
2143
 
1856
- private cancelQueuedPrompt(queued: IQueuedPrompt, reason: FlexHarnessAbortError): boolean {
2144
+ private cancelQueuedPrompt(
2145
+ queued: IQueuedPrompt,
2146
+ reason: FlexHarnessAbortError,
2147
+ context?: IFlexAgentContextInvocation<TScope>,
2148
+ ): boolean {
1857
2149
  if (queued.stored.outstandingPromptsById.get(queued.queueId) !== queued) return false;
1858
2150
  if (queued.status === 'queued') {
1859
2151
  this.finishQueuedPrompt(queued, 'cancelled', reason);
@@ -1863,7 +2155,7 @@ export class FlexHarness<TScope = unknown> {
1863
2155
  const run = queued.state.activeRuns.get(queued.sessionId);
1864
2156
  if (!run || run.queueId !== queued.queueId) return false;
1865
2157
  if (run.phase === 'finalizing' || run.phase === 'promoting') return false;
1866
- this.cancelRun(run, reason);
2158
+ this.cancelRun(run, reason, context);
1867
2159
  return true;
1868
2160
  }
1869
2161
 
@@ -1871,10 +2163,15 @@ export class FlexHarness<TScope = unknown> {
1871
2163
  stored: IStoredSessionState,
1872
2164
  reason: FlexHarnessAbortError,
1873
2165
  excludedQueueId?: string,
2166
+ context?: IFlexAgentContextInvocation<unknown>,
1874
2167
  ): void {
1875
2168
  for (const queued of [...stored.outstandingPromptsById.values()]) {
1876
2169
  if (queued.queueId === excludedQueueId) continue;
1877
- this.cancelQueuedPrompt(queued, reason);
2170
+ this.cancelQueuedPrompt(
2171
+ queued,
2172
+ reason,
2173
+ context as IFlexAgentContextInvocation<TScope> | undefined,
2174
+ );
1878
2175
  }
1879
2176
  }
1880
2177
 
@@ -1908,7 +2205,10 @@ export class FlexHarness<TScope = unknown> {
1908
2205
  if (run.controller.signal.aborted) throw run.controller.signal.reason;
1909
2206
  run.phase = 'finalizing';
1910
2207
  try {
1911
- await run.stored.agentSession.finalizeGeneration(run.transaction!, 'accepted');
2208
+ await this.withRunCompactorContext(
2209
+ run,
2210
+ () => run.stored.agentSession.finalizeGeneration(run.transaction!, 'accepted'),
2211
+ );
1912
2212
  } catch (error) {
1913
2213
  const projected = this.projectExternalError(run, error, 'agentSession');
1914
2214
  await this.fenceNamespace(run.state, run, projected);
@@ -1978,7 +2278,10 @@ export class FlexHarness<TScope = unknown> {
1978
2278
  run.phase = 'finalizing';
1979
2279
  const desiredOutcome: TCanonicalOutcome = cancelled || !generated ? 'interrupted' : 'rejected';
1980
2280
  try {
1981
- await run.stored.agentSession.finalizeGeneration(run.transaction!, desiredOutcome);
2281
+ await this.withRunCompactorContext(
2282
+ run,
2283
+ () => run.stored.agentSession.finalizeGeneration(run.transaction!, desiredOutcome),
2284
+ );
1982
2285
  } catch (finalizationError) {
1983
2286
  errors.push(this.projectExternalError(run, finalizationError, 'agentSession'));
1984
2287
  }
@@ -2030,28 +2333,36 @@ export class FlexHarness<TScope = unknown> {
2030
2333
  queued.status = 'running';
2031
2334
  this.emitPromptQueueEvent(queued, 'prompt.running');
2032
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
+ };
2033
2342
  const modelOutcome = Promise.resolve()
2034
- .then(() => this.modelResolver.resolveModel({
2343
+ .then(() => this.modelResolver.resolveModel(Object.freeze({
2035
2344
  scopeId: run.scopeId,
2036
2345
  scope: run.scope as TScope,
2037
2346
  sessionId: run.sessionId,
2038
2347
  runId: run.runId,
2039
2348
  ...(options.modelHint ? { modelHint: options.modelHint } : {}),
2349
+ ...resolverRelationship,
2040
2350
  signal,
2041
- }))
2351
+ })))
2042
2352
  .then(
2043
2353
  (value): IResolverOutcome<IFlexResolvedModel> => ({ source: 'model', success: true, value }),
2044
2354
  (error): IResolverOutcome<IFlexResolvedModel> => ({ source: 'model', success: false, error }),
2045
2355
  );
2046
2356
  const toolOutcome = Promise.resolve()
2047
- .then(() => this.toolProvider?.provideTools({
2357
+ .then(() => this.toolProvider?.provideTools(Object.freeze({
2048
2358
  scopeId: run.scopeId,
2049
2359
  scope: run.scope as TScope,
2050
2360
  sessionId: run.sessionId,
2051
2361
  runId: run.runId,
2362
+ ...resolverRelationship,
2052
2363
  signal,
2053
2364
  requestPermission: (request) => this.requestPermission(run.state, run, request),
2054
- }))
2365
+ })))
2055
2366
  .then(
2056
2367
  (value): IResolverOutcome<IFlexToolHandle | undefined> => ({ source: 'tools', success: true, value }),
2057
2368
  (error): IResolverOutcome<IFlexToolHandle | undefined> => ({ source: 'tools', success: false, error }),
@@ -2118,9 +2429,21 @@ export class FlexHarness<TScope = unknown> {
2118
2429
  run.modelResolution = model!;
2119
2430
  let tools: TFlexAgentToolSet | undefined;
2120
2431
  try {
2121
- 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
2122
2445
  ? wrapToolSet(
2123
- toolHandle.tools,
2446
+ combinedTools as TFlexAgentToolSet,
2124
2447
  this.toolOutputLimits,
2125
2448
  (toolError) => this.projectExternalError(run, toolError, 'toolExecution'),
2126
2449
  )
@@ -2168,6 +2491,426 @@ export class FlexHarness<TScope = unknown> {
2168
2491
  }
2169
2492
  }
2170
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
+
2171
2914
  private createReservation(
2172
2915
  run: IActiveRun,
2173
2916
  prompt: INormalizedFlexPrompt,
@@ -2352,11 +3095,14 @@ export class FlexHarness<TScope = unknown> {
2352
3095
  private emitTerminalProjection(run: IActiveRun, terminal: IFlexTerminalProjection): void {
2353
3096
  for (const part of terminal.assistantMessage.parts) {
2354
3097
  const callbackPart = run.callbackParts.find((entry) => entry.partId === part.partId);
2355
- if (
2356
- (part.type === 'reasoning' || part.type === 'tool')
2357
- && callbackPart?.type === part.type
2358
- && callbackPart.status === 'running'
2359
- ) {
3098
+ const shouldComplete = part.type === 'text'
3099
+ ? callbackPart?.type === 'text'
3100
+ : part.type === 'reasoning'
3101
+ ? callbackPart?.type === 'reasoning' && callbackPart.status === 'running'
3102
+ : part.type === 'tool'
3103
+ ? callbackPart?.type === 'tool' && callbackPart.status === 'running'
3104
+ : false;
3105
+ if (shouldComplete) {
2360
3106
  this.emitPartEvent(run, 'part.completed', part);
2361
3107
  }
2362
3108
  }
@@ -2617,6 +3363,7 @@ export class FlexHarness<TScope = unknown> {
2617
3363
  state: IStorageState,
2618
3364
  pending: IPendingPermission,
2619
3365
  decision: TFlexPermissionDecision,
3366
+ context: IFlexAgentContextInvocation<TScope>,
2620
3367
  ): Promise<void> {
2621
3368
  if (pending.settled) {
2622
3369
  throw new FlexHarnessPermissionStateError(`Permission "${pending.request.permissionId}" has already been resolved.`);
@@ -2714,7 +3461,7 @@ export class FlexHarness<TScope = unknown> {
2714
3461
  const rejection = this.trustInternalError(
2715
3462
  new FlexHarnessPermissionRejectedError(pending.request.permissionId),
2716
3463
  );
2717
- this.abortRunInternally(pending.run, rejection);
3464
+ this.abortRunInternally(pending.run, rejection, context);
2718
3465
  pending.reject(rejection);
2719
3466
  } else {
2720
3467
  pending.resolve();
@@ -2818,19 +3565,50 @@ export class FlexHarness<TScope = unknown> {
2818
3565
  pending.reject(error);
2819
3566
  }
2820
3567
 
2821
- private cancelRun(run: IActiveRun, reason: FlexHarnessAbortError): void {
2822
- if (run.internalFailure === undefined) run.ownerCancellation ??= reason;
2823
- this.rejectRunPermissions(run.state, run, reason);
2824
- if (!run.controller.signal.aborted) run.controller.abort(reason);
2825
- if (run.phase === 'admitting') return;
2826
- if (run.scheduleKey) run.stored.agentSession.cancelScheduledGeneration(run.scheduleKey, reason);
2827
- else run.stored.agentSession.abortCurrentGeneration(reason);
3568
+ private cancelRun(
3569
+ run: IActiveRun,
3570
+ reason: FlexHarnessAbortError,
3571
+ context?: IFlexAgentContextInvocation<TScope>,
3572
+ ): void {
3573
+ const invocationContext = context ?? this.createRunCompactorContext(run);
3574
+ if (run.internalFailure === undefined && run.ownerCancellation === undefined) {
3575
+ run.ownerCancellation = reason;
3576
+ run.deferredCompactorContext = invocationContext;
3577
+ this.deferredCompactorContexts.set(run.originCompactorContext, invocationContext);
3578
+ }
3579
+ this.withCompactorContext(
3580
+ (run.deferredCompactorContext ?? invocationContext) as IFlexAgentContextInvocation<TScope>,
3581
+ () => {
3582
+ this.rejectRunPermissions(run.state, run, reason);
3583
+ if (!run.controller.signal.aborted) run.controller.abort(reason);
3584
+ if (run.phase === 'admitting') return;
3585
+ if (run.scheduleKey) {
3586
+ run.stored.agentSession.cancelScheduledGeneration(run.scheduleKey, reason);
3587
+ } else {
3588
+ run.stored.agentSession.abortCurrentGeneration(reason);
3589
+ }
3590
+ },
3591
+ );
2828
3592
  }
2829
3593
 
2830
- private abortRunInternally(run: IActiveRun, error: unknown): void {
2831
- run.internalFailure ??= error ?? new Error('Internal FlexHarness run failure.');
2832
- if (!run.controller.signal.aborted) run.controller.abort(run.internalFailure);
2833
- run.stored.agentSession.abortCurrentGeneration(run.internalFailure);
3594
+ private abortRunInternally(
3595
+ run: IActiveRun,
3596
+ error: unknown,
3597
+ context?: IFlexAgentContextInvocation<TScope>,
3598
+ ): void {
3599
+ const invocationContext = context ?? this.createRunCompactorContext(run);
3600
+ if (run.internalFailure === undefined) {
3601
+ run.internalFailure = error ?? new Error('Internal FlexHarness run failure.');
3602
+ run.deferredCompactorContext = invocationContext;
3603
+ this.deferredCompactorContexts.set(run.originCompactorContext, invocationContext);
3604
+ }
3605
+ this.withCompactorContext(
3606
+ (run.deferredCompactorContext ?? invocationContext) as IFlexAgentContextInvocation<TScope>,
3607
+ () => {
3608
+ if (!run.controller.signal.aborted) run.controller.abort(run.internalFailure);
3609
+ run.stored.agentSession.abortCurrentGeneration(run.internalFailure);
3610
+ },
3611
+ );
2834
3612
  }
2835
3613
 
2836
3614
  private async rollbackAdmission(
@@ -2841,7 +3619,10 @@ export class FlexHarness<TScope = unknown> {
2841
3619
  const errors: unknown[] = [];
2842
3620
  if (run.transaction) {
2843
3621
  try {
2844
- await run.stored.agentSession.finalizeGeneration(run.transaction, 'interrupted');
3622
+ await this.withRunCompactorContext(
3623
+ run,
3624
+ () => run.stored.agentSession.finalizeGeneration(run.transaction!, 'interrupted'),
3625
+ );
2845
3626
  } catch (error) {
2846
3627
  errors.push(this.projectExternalError(run, error, 'agentSession'));
2847
3628
  }
@@ -3068,6 +3849,7 @@ export class FlexHarness<TScope = unknown> {
3068
3849
  storageKey: string,
3069
3850
  scopeId: string,
3070
3851
  scope: TScope,
3852
+ compactorLifecycleController: AbortController,
3071
3853
  ): Promise<IStorageState> {
3072
3854
  const snapshot = cloneSerializable(await this.stores.scopes.load(storageKey) ?? {
3073
3855
  schemaVersion: 1,
@@ -3077,6 +3859,7 @@ export class FlexHarness<TScope = unknown> {
3077
3859
  } satisfies IFlexScopeSnapshot);
3078
3860
  const state: IStorageState = {
3079
3861
  storageKey,
3862
+ compactorLifecycleController,
3080
3863
  scopeIdHint: scopeId,
3081
3864
  revision: snapshot.revision,
3082
3865
  sessions: new Map(),
@@ -3105,10 +3888,37 @@ export class FlexHarness<TScope = unknown> {
3105
3888
  scopeChanged = (await this.repairLoadedSession(state.storageKey, stored)) || scopeChanged;
3106
3889
  if (stored.projectionRevision < 0) throw new Error('Invalid projection revision.');
3107
3890
  }
3108
- 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;
3109
3916
  try {
3110
- await this.cleanupSessionDomains(storageKey, tombstone.sessionId);
3111
- 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);
3112
3922
  scopeChanged = true;
3113
3923
  } catch {
3114
3924
  // A retained tombstone is retried by the next load or explicit delete call.
@@ -3141,6 +3951,7 @@ export class FlexHarness<TScope = unknown> {
3141
3951
  private createUninitializedStoredSession(
3142
3952
  session: IFlexSession,
3143
3953
  storageKey: string,
3954
+ compactorLifecycleController = new AbortController(),
3144
3955
  ): IStoredSessionState {
3145
3956
  const unavailable = new Proxy({} as plugins.IAgentSession, {
3146
3957
  get() {
@@ -3164,6 +3975,7 @@ export class FlexHarness<TScope = unknown> {
3164
3975
  agentEventStoreReleased: true,
3165
3976
  executionContextCloseCompleted: true,
3166
3977
  jobStoreReleased: true,
3978
+ compactorLifecycleController,
3167
3979
  promptQueue: [],
3168
3980
  outstandingPromptsById: new Map(),
3169
3981
  terminalPromptQueueEntries: new Map(),
@@ -3176,8 +3988,15 @@ export class FlexHarness<TScope = unknown> {
3176
3988
  metadata: IFlexSession,
3177
3989
  scopeId: string,
3178
3990
  scope: TScope,
3991
+ compactorLifecycleController = new AbortController(),
3179
3992
  ): Promise<IStoredSessionState> {
3180
3993
  const sessionId = metadata.sessionId;
3994
+ const compactorContext = this.createCompactorContext(
3995
+ scopeId,
3996
+ scope,
3997
+ state.storageKey,
3998
+ sessionId,
3999
+ );
3181
4000
  const [projectionResult, permissionResult, eventStoreResult, jobStoreResult] = await Promise.allSettled([
3182
4001
  this.stores.projections.load(state.storageKey, sessionId),
3183
4002
  this.stores.permissions.load(state.storageKey, sessionId),
@@ -3216,13 +4035,16 @@ export class FlexHarness<TScope = unknown> {
3216
4035
  let executionContextHandle: IFlexExecutionContextHandle | undefined;
3217
4036
  try {
3218
4037
  if (this.executionContextProvider) {
3219
- executionContextHandle = await this.executionContextProvider.provideExecutionContext({
3220
- scopeId,
3221
- scope,
3222
- storageKey: state.storageKey,
3223
- sessionId,
3224
- jobStore,
3225
- });
4038
+ executionContextHandle = await this.withCompactorContext(
4039
+ compactorContext,
4040
+ () => this.executionContextProvider!.provideExecutionContext({
4041
+ scopeId,
4042
+ scope,
4043
+ storageKey: state.storageKey,
4044
+ sessionId,
4045
+ jobStore,
4046
+ }),
4047
+ );
3226
4048
  if (
3227
4049
  executionContextHandle
3228
4050
  && (!executionContextHandle.context
@@ -3233,6 +4055,7 @@ export class FlexHarness<TScope = unknown> {
3233
4055
  }
3234
4056
  const stored: IStoredSessionState = {
3235
4057
  storageKey: state.storageKey,
4058
+ compactorContext,
3236
4059
  session: cloneSerializable(metadata),
3237
4060
  messages: cloneSerializable(projection?.messages ?? []),
3238
4061
  stagedTerminals: cloneSerializable(projection?.stagedTerminals ?? []),
@@ -3250,21 +4073,75 @@ export class FlexHarness<TScope = unknown> {
3250
4073
  executionContextCloseCompleted: executionContextHandle?.close === undefined,
3251
4074
  jobStoreReleased: this.stores.jobs.releaseSession === undefined,
3252
4075
  jobs: executionContextHandle?.context.jobs,
4076
+ compactorLifecycleController,
3253
4077
  promptQueue: [],
3254
4078
  outstandingPromptsById: new Map(),
3255
4079
  terminalPromptQueueEntries: new Map(),
3256
4080
  outstandingPromptBytes: 0,
3257
4081
  };
4082
+ const {
4083
+ contextCompactor,
4084
+ ...agentSessionPolicy
4085
+ } = this.agentSessionPolicy;
4086
+ const executionContext = executionContextHandle?.context;
4087
+ const jobContext = executionContext?.jobs;
4088
+ const contextualExecutionContext = executionContext && jobContext?.subscribe
4089
+ ? {
4090
+ ...executionContext,
4091
+ jobs: {
4092
+ start: jobContext.start.bind(jobContext),
4093
+ get: jobContext.get.bind(jobContext),
4094
+ list: jobContext.list.bind(jobContext),
4095
+ ...(jobContext.abort ? { abort: jobContext.abort.bind(jobContext) } : {}),
4096
+ subscribe: (listener: Parameters<NonNullable<typeof jobContext.subscribe>>[0]) =>
4097
+ jobContext.subscribe!((event) =>
4098
+ this.withCompactorContext(compactorContext, () => listener(event))),
4099
+ },
4100
+ }
4101
+ : executionContext;
3258
4102
  const agentSessionOptions: plugins.IAgentSessionOptions & {
3259
4103
  transactionOutcomeErrorProjector: (error: unknown) => string;
3260
4104
  } = {
3261
- ...this.agentSessionPolicy,
4105
+ ...agentSessionPolicy,
3262
4106
  sessionId,
3263
4107
  eventStore,
3264
- executionContext: executionContextHandle?.context,
4108
+ executionContext: contextualExecutionContext,
3265
4109
  contextBuilder: ({ events }) => hydrateAgentMessages(
3266
4110
  (this.agentSessionPolicy.contextBuilder ?? ((options) => plugins.buildModelMessages(options.events)))({ events }),
3267
4111
  ),
4112
+ ...(contextCompactor
4113
+ ? {
4114
+ contextCompactor: async (messages, events, options) => {
4115
+ const ambientContext = this.compactorInvocationContext.getStore();
4116
+ const invocationContext = ambientContext
4117
+ ? (this.deferredCompactorContexts.get(ambientContext) ?? ambientContext) as
4118
+ IFlexAgentContextInvocation<TScope>
4119
+ : undefined;
4120
+ if (
4121
+ !invocationContext
4122
+ || invocationContext.storageKey !== state.storageKey
4123
+ || invocationContext.sessionId !== sessionId
4124
+ ) {
4125
+ throw new Error('Agent context compaction is missing its exact FlexHarness invocation context.');
4126
+ }
4127
+ return contextCompactor(messages, events, {
4128
+ ...options,
4129
+ ...invocationContext,
4130
+ scope: invocationContext.scope as TScope,
4131
+ abortSignal: options.abortSignal
4132
+ ? AbortSignal.any([
4133
+ options.abortSignal,
4134
+ state.compactorLifecycleController.signal,
4135
+ stored.compactorLifecycleController.signal,
4136
+ ])
4137
+ : AbortSignal.any([
4138
+ state.compactorLifecycleController.signal,
4139
+ stored.compactorLifecycleController.signal,
4140
+ ]),
4141
+ });
4142
+ },
4143
+ }
4144
+ : {}),
3268
4145
  transactionOutcomeErrorProjector: () => 'The model operation failed.',
3269
4146
  onToken: (delta) => this.onTextDelta(state, sessionId, delta),
3270
4147
  onReasoningStart: (id) => this.onReasoningStart(state, sessionId, id),
@@ -3273,7 +4150,10 @@ export class FlexHarness<TScope = unknown> {
3273
4150
  onToolCallStart: (event) => this.onToolStart(state, sessionId, event),
3274
4151
  onToolCallFinish: (event) => this.onToolFinish(state, sessionId, event),
3275
4152
  };
3276
- stored.agentSession = await plugins.AgentSession.create(agentSessionOptions);
4153
+ stored.agentSession = await this.withCompactorContext(
4154
+ compactorContext,
4155
+ () => plugins.AgentSession.create(agentSessionOptions),
4156
+ );
3277
4157
  return stored;
3278
4158
  } catch (error) {
3279
4159
  const cleanupErrors: unknown[] = [];
@@ -3740,38 +4620,136 @@ export class FlexHarness<TScope = unknown> {
3740
4620
  if (errors.length > 0) throw combineErrors(errors);
3741
4621
  }
3742
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
+
3743
4684
  private finishTombstoneCleanup(
3744
4685
  state: IStorageState,
3745
- sessionId: string,
4686
+ rootSessionId: string,
3746
4687
  scopeId: string,
4688
+ invocation?: { scopeId: string; scope: TScope },
3747
4689
  ): Promise<void> {
3748
- const existing = state.tombstoneCleanups.get(sessionId);
4690
+ const existing = state.tombstoneCleanups.get(rootSessionId);
3749
4691
  if (existing) return existing;
3750
4692
  let cleanup!: Promise<void>;
3751
- cleanup = this.finishTombstoneCleanupInternal(state, sessionId, scopeId).finally(() => {
3752
- if (state.tombstoneCleanups.get(sessionId) === cleanup) {
3753
- 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);
3754
4701
  }
3755
4702
  });
3756
- state.tombstoneCleanups.set(sessionId, cleanup);
4703
+ state.tombstoneCleanups.set(rootSessionId, cleanup);
3757
4704
  return cleanup;
3758
4705
  }
3759
4706
 
3760
4707
  private async finishTombstoneCleanupInternal(
3761
4708
  state: IStorageState,
3762
- sessionId: string,
4709
+ rootSessionId: string,
3763
4710
  scopeId: string,
4711
+ invocation?: { scopeId: string; scope: TScope },
3764
4712
  ): Promise<void> {
3765
- const retained = state.retainedSessionCleanups.get(sessionId);
3766
- 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);
3767
4747
  const errors: unknown[] = [];
3768
- const reason = this.trustInternalError(new FlexHarnessAbortError('The session was deleted.'));
3769
- this.cancelStoredPromptQueue(retained.stored, reason);
3770
4748
  const run = state.activeRuns.get(sessionId);
3771
- if (run) this.cancelRun(run, reason);
3772
- if (!retained.stored.agentSessionAbortCompleted) {
4749
+ const context = contextFor(sessionId, retained);
4750
+ if (retained && !retained.stored.agentSessionAbortCompleted) {
3773
4751
  try {
3774
- await this.abortStoredSession(retained.stored, reason);
4752
+ await this.abortStoredSession(retained.stored, reason, context);
3775
4753
  } catch (error) {
3776
4754
  errors.push(this.projectOperationError(
3777
4755
  error,
@@ -3784,70 +4762,135 @@ export class FlexHarness<TScope = unknown> {
3784
4762
  }
3785
4763
  if (run) {
3786
4764
  const settled = await Promise.allSettled([run.completion]);
3787
- if (settled[0].status === 'rejected' && !isAbortError(settled[0].reason)) {
3788
- errors.push(settled[0].reason);
4765
+ if (settled[0].status === 'rejected') {
4766
+ this.appendUnexpectedErrors(errors, settled[0].reason);
3789
4767
  }
3790
4768
  }
3791
- if (retained.stored.promptQueueDrain) {
3792
- const settled = await Promise.allSettled([retained.stored.promptQueueDrain]);
3793
- if (settled[0].status === 'rejected') errors.push(settled[0].reason);
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
+ }
3794
4785
  }
3795
- try {
3796
- await this.closeStoredSession(retained.stored);
3797
- } catch (error) {
3798
- errors.push(this.projectOperationError(
3799
- error,
3800
- 'toolCleanup',
3801
- scopeId,
3802
- sessionId,
3803
- 'session-delete',
3804
- ));
4786
+ if (errors.length > 0) {
4787
+ if (
4788
+ retained
4789
+ && state.lifecycle === 'retired'
4790
+ && !this.storedSessionCleanupCompleted(retained.stored)
4791
+ ) this.orphanedStoredSessions.add(retained.stored);
4792
+ throw combineErrors(errors);
3805
4793
  }
3806
- if (errors.length > 0) throw combineErrors(errors);
3807
- this.purgeStoredPromptQueue(retained.stored);
3808
- }
3809
- if (!retained?.domainsCompleted) {
3810
- if (this.hasOrphanedSessionResources(state.storageKey, sessionId)) {
3811
- 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;
3812
4801
  }
3813
- await this.cleanupSessionDomains(state.storageKey, sessionId);
3814
- if (retained) retained.domainsCompleted = true;
3815
4802
  }
3816
4803
  await this.mutateScope(state, () => {
3817
- state.tombstones.delete(sessionId);
3818
- state.retainedSessionCleanups.delete(sessionId);
4804
+ for (const tombstone of group) {
4805
+ state.tombstones.delete(tombstone.sessionId);
4806
+ state.retainedSessionCleanups.delete(tombstone.sessionId);
4807
+ }
3819
4808
  }, true);
3820
4809
  }
3821
4810
 
3822
4811
  private async fenceNamespace(state: IStorageState, currentRun: IActiveRun, cause: unknown): Promise<void> {
3823
4812
  if (state.lifecycle === 'retired') return;
4813
+ if (this.storageDrains.has(state.storageKey)) {
4814
+ state.fenceAdditionalErrors.push(cause);
4815
+ return;
4816
+ }
3824
4817
  if (state.fenceInProgress) {
3825
4818
  state.fenceAdditionalErrors.push(cause);
3826
4819
  return;
3827
4820
  }
3828
4821
  state.fenceInProgress = true;
3829
4822
  state.lifecycle = 'fenced';
3830
- const detachedCleanupAttempts = this.beginDetachedCleanupSettlement(state);
3831
4823
  const reason = this.trustInternalError(new FlexHarnessAbortError('The session namespace was fenced.'));
4824
+ if (!state.compactorLifecycleController.signal.aborted) {
4825
+ state.compactorLifecycleController.abort(reason);
4826
+ }
3832
4827
  const cleanupErrors: unknown[] = [];
4828
+ const ambientContext = this.compactorInvocationContext.getStore();
4829
+ const invocation = ambientContext?.storageKey === state.storageKey
4830
+ && ambientContext.sessionId === currentRun.sessionId
4831
+ ? ambientContext
4832
+ : this.effectiveRunCompactorContext(currentRun);
4833
+ const contextFor = (sessionId: string) => this.createCompactorContext(
4834
+ invocation.scopeId,
4835
+ invocation.scope,
4836
+ state.storageKey,
4837
+ sessionId,
4838
+ );
3833
4839
  try {
3834
4840
  for (const stored of state.sessions.values()) {
4841
+ this.abortCompactorLifecycle(stored, reason);
3835
4842
  this.cancelStoredPromptQueue(
3836
4843
  stored,
3837
4844
  reason,
3838
4845
  stored === currentRun.stored ? currentRun.queueId : undefined,
4846
+ contextFor(stored.session.sessionId),
3839
4847
  );
3840
4848
  }
3841
4849
  const otherRuns = [...state.activeRuns.values()].filter((run) => run !== currentRun);
3842
- for (const run of otherRuns) this.cancelRun(run, reason);
3843
- const runResults = await Promise.allSettled(otherRuns.map((run) => run.completion));
4850
+ for (const run of otherRuns) this.cancelRun(run, reason, contextFor(run.sessionId));
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));
3844
4855
  for (const result of runResults) {
3845
4856
  if (result.status === 'rejected') this.appendUnexpectedErrors(cleanupErrors, result.reason);
3846
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);
3847
4876
  await Promise.allSettled([...state.sessionInitializations.values()]);
3848
4877
  cleanupErrors.push(...await this.closeOrphanedResources(state.storageKey));
3849
4878
  await state.scopeQueue;
3850
- const tombstoneAttempts = [...state.tombstoneCleanups.entries()];
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
+ );
4885
+ if (currentTombstoneCleanup) {
4886
+ this.retainOrphanedTombstoneCleanup(
4887
+ state.storageKey,
4888
+ currentTombstoneRoot,
4889
+ currentTombstoneCleanup,
4890
+ );
4891
+ }
4892
+ const tombstoneAttempts = [...state.tombstoneCleanups.entries()]
4893
+ .filter(([rootSessionId]) => rootSessionId !== currentTombstoneRoot);
3851
4894
  const tombstoneResults = await Promise.allSettled(
3852
4895
  tombstoneAttempts.map(([, cleanup]) => cleanup),
3853
4896
  );
@@ -3855,15 +4898,19 @@ export class FlexHarness<TScope = unknown> {
3855
4898
  if (result.status === 'rejected') this.appendUnexpectedErrors(cleanupErrors, result.reason);
3856
4899
  }
3857
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)));
3858
4903
  const storedSessions = new Set([
3859
4904
  ...state.sessions.values(),
3860
4905
  ...[...state.retainedSessionCleanups]
3861
- .filter(([sessionId]) => !attemptedTombstones.has(sessionId))
4906
+ .filter(([sessionId]) =>
4907
+ !currentTombstoneSessions.has(sessionId)
4908
+ && !attemptedTombstoneSessions.has(sessionId))
3862
4909
  .map(([, retained]) => retained.stored),
3863
4910
  ]);
3864
4911
  for (const stored of storedSessions) {
3865
4912
  try {
3866
- await this.abortStoredSession(stored, reason);
4913
+ await this.abortStoredSession(stored, reason, contextFor(stored.session.sessionId));
3867
4914
  } catch (error) {
3868
4915
  cleanupErrors.push(this.projectOperationError(
3869
4916
  error,
@@ -3874,7 +4921,7 @@ export class FlexHarness<TScope = unknown> {
3874
4921
  ));
3875
4922
  }
3876
4923
  try {
3877
- await this.closeStoredSession(stored);
4924
+ await this.closeStoredSession(stored, contextFor(stored.session.sessionId));
3878
4925
  } catch (error) {
3879
4926
  if (!this.storedSessionCleanupCompleted(stored)) {
3880
4927
  cleanupErrors.push(this.projectOperationError(
@@ -3893,24 +4940,111 @@ export class FlexHarness<TScope = unknown> {
3893
4940
  if (cleanupErrors.length > 0) throw combineErrors([cause, ...cleanupErrors]);
3894
4941
  state.lifecycle = 'retired';
3895
4942
  state.sessions.clear();
4943
+ const currentRetainedCleanups = [...state.retainedSessionCleanups]
4944
+ .filter(([sessionId]) => currentTombstoneSessions.has(sessionId));
3896
4945
  state.retainedSessionCleanups.clear();
4946
+ for (const [sessionId, retained] of currentRetainedCleanups) {
4947
+ state.retainedSessionCleanups.set(sessionId, retained);
4948
+ }
3897
4949
  state.sessionDeletions.clear();
3898
4950
  state.tombstoneCleanups.clear();
4951
+ if (currentTombstoneCleanup) {
4952
+ state.tombstoneCleanups.set(currentTombstoneRoot, currentTombstoneCleanup);
4953
+ }
3899
4954
  state.activeRuns.clear();
3900
4955
  state.pendingPermissions.clear();
3901
4956
  state.initializingSessions.clear();
3902
4957
  state.sessionInitializations.clear();
3903
4958
  this.stateLoads.delete(state.storageKey);
4959
+ if (
4960
+ this.storageCompactorLifecycleControllers.get(state.storageKey)
4961
+ === state.compactorLifecycleController
4962
+ ) this.storageCompactorLifecycleControllers.delete(state.storageKey);
3904
4963
  } finally {
3905
4964
  state.fenceInProgress = false;
3906
4965
  }
3907
4966
  }
3908
4967
 
3909
- private async closeStoredSession(stored: IStoredSessionState): Promise<void> {
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
+
5037
+ private async closeStoredSession(
5038
+ stored: IStoredSessionState,
5039
+ context: IFlexAgentContextInvocation<unknown> = stored.compactorContext!,
5040
+ ): Promise<void> {
3910
5041
  const errors: unknown[] = [];
3911
5042
  if (!stored.agentSessionCloseCompleted) {
3912
5043
  try {
3913
- await stored.agentSession.close();
5044
+ await this.withCompactorContext(
5045
+ context,
5046
+ () => stored.agentSession.close(),
5047
+ );
3914
5048
  if (!stored.agentSession.closeCleanupCompleted) {
3915
5049
  throw new Error('AgentSession.close() resolved before cleanup ownership was released.');
3916
5050
  }
@@ -3960,9 +5094,13 @@ export class FlexHarness<TScope = unknown> {
3960
5094
  private async abortStoredSession(
3961
5095
  stored: IStoredSessionState,
3962
5096
  reason: FlexHarnessAbortError,
5097
+ context: IFlexAgentContextInvocation<unknown> = stored.compactorContext!,
3963
5098
  ): Promise<void> {
3964
5099
  if (stored.agentSessionAbortCompleted) return;
3965
- await stored.agentSession.abortSession(reason, { abortBackgroundJobs: true });
5100
+ await this.withCompactorContext(
5101
+ context,
5102
+ () => stored.agentSession.abortSession(reason, { abortBackgroundJobs: true }),
5103
+ );
3966
5104
  stored.agentSessionAbortCompleted = true;
3967
5105
  }
3968
5106
 
@@ -4018,6 +5156,29 @@ export class FlexHarness<TScope = unknown> {
4018
5156
  retained.storageKey === storageKey && retained.sessionId === sessionId);
4019
5157
  }
4020
5158
 
5159
+ private retainOrphanedTombstoneCleanup(
5160
+ storageKey: string,
5161
+ sessionId: string,
5162
+ completion: Promise<void>,
5163
+ ): void {
5164
+ const key = JSON.stringify([storageKey, sessionId]);
5165
+ if (this.orphanedTombstoneCleanups.has(key)) return;
5166
+ const retained: IOrphanedTombstoneCleanup = { storageKey, sessionId, completion };
5167
+ this.orphanedTombstoneCleanups.set(key, retained);
5168
+ void completion.then(
5169
+ () => {
5170
+ if (this.orphanedTombstoneCleanups.get(key) === retained) {
5171
+ this.orphanedTombstoneCleanups.delete(key);
5172
+ }
5173
+ },
5174
+ () => {
5175
+ if (this.orphanedTombstoneCleanups.get(key) === retained) {
5176
+ this.orphanedTombstoneCleanups.delete(key);
5177
+ }
5178
+ },
5179
+ );
5180
+ }
5181
+
4021
5182
  private closeOrphanedResources(storageKey?: string): Promise<unknown[]> {
4022
5183
  const operation = this.orphanedResourceQueue.then(() =>
4023
5184
  this.closeOrphanedResourcesInternal(storageKey));
@@ -4027,6 +5188,14 @@ export class FlexHarness<TScope = unknown> {
4027
5188
 
4028
5189
  private async closeOrphanedResourcesInternal(storageKey?: string): Promise<unknown[]> {
4029
5190
  const errors: unknown[] = [];
5191
+ const tombstoneCleanups = [...this.orphanedTombstoneCleanups.values()]
5192
+ .filter((retained) => storageKey === undefined || retained.storageKey === storageKey);
5193
+ const tombstoneResults = await Promise.allSettled(
5194
+ tombstoneCleanups.map((retained) => retained.completion),
5195
+ );
5196
+ for (const result of tombstoneResults) {
5197
+ if (result.status === 'rejected') this.appendUnexpectedErrors(errors, result.reason);
5198
+ }
4030
5199
  for (const stored of [...this.orphanedStoredSessions]) {
4031
5200
  if (storageKey !== undefined && stored.storageKey !== storageKey) continue;
4032
5201
  try {
@@ -4067,6 +5236,7 @@ export class FlexHarness<TScope = unknown> {
4067
5236
  ...[...this.orphanedStoredSessions].map((stored) => stored.storageKey),
4068
5237
  ...[...this.orphanedExecutionContexts.values()].map((owner) => owner.storageKey),
4069
5238
  ...[...this.orphanedProviderReleases.values()].map((retained) => retained.storageKey),
5239
+ ...[...this.orphanedTombstoneCleanups.values()].map((retained) => retained.storageKey),
4070
5240
  ]);
4071
5241
  }
4072
5242
 
@@ -4089,7 +5259,12 @@ export class FlexHarness<TScope = unknown> {
4089
5259
  }
4090
5260
  } else {
4091
5261
  try {
4092
- await this.drainStorage(scope.storageKey, stateLoad, this.createScopeRetirementError());
5262
+ await this.drainStorage(
5263
+ scope.storageKey,
5264
+ stateLoad,
5265
+ this.createScopeRetirementError(),
5266
+ { scopeId, scope: scope.scope },
5267
+ );
4093
5268
  } catch (error) {
4094
5269
  this.appendUnexpectedErrors(errors, error);
4095
5270
  }
@@ -4101,15 +5276,25 @@ export class FlexHarness<TScope = unknown> {
4101
5276
  storageKey: string,
4102
5277
  stateLoad: Promise<IStorageState>,
4103
5278
  reason: FlexHarnessAbortError,
5279
+ invocation?: { scopeId: string; scope: TScope },
4104
5280
  ): Promise<void> {
5281
+ const compactorController = this.storageCompactorLifecycleControllers.get(storageKey);
5282
+ if (compactorController && !compactorController.signal.aborted) {
5283
+ compactorController.abort(reason);
5284
+ }
4105
5285
  const existing = this.storageDrains.get(storageKey);
4106
5286
  if (existing) return existing;
4107
5287
  let completed = false;
4108
5288
  let drain!: Promise<void>;
4109
- drain = this.drainStorageInternal(storageKey, stateLoad, reason).then(() => {
5289
+ drain = this.drainStorageInternal(storageKey, stateLoad, reason, invocation).then(() => {
4110
5290
  completed = true;
4111
5291
  }).finally(() => {
4112
- if (completed && this.stateLoads.get(storageKey) === stateLoad) this.stateLoads.delete(storageKey);
5292
+ if (completed && this.stateLoads.get(storageKey) === stateLoad) {
5293
+ this.stateLoads.delete(storageKey);
5294
+ if (this.storageCompactorLifecycleControllers.get(storageKey) === compactorController) {
5295
+ this.storageCompactorLifecycleControllers.delete(storageKey);
5296
+ }
5297
+ }
4113
5298
  if (this.storageDrains.get(storageKey) === drain) this.storageDrains.delete(storageKey);
4114
5299
  });
4115
5300
  this.storageDrains.set(storageKey, drain);
@@ -4120,6 +5305,7 @@ export class FlexHarness<TScope = unknown> {
4120
5305
  storageKey: string,
4121
5306
  stateLoad: Promise<IStorageState>,
4122
5307
  reason: FlexHarnessAbortError,
5308
+ invocation?: { scopeId: string; scope: TScope },
4123
5309
  ): Promise<void> {
4124
5310
  let state: IStorageState;
4125
5311
  try {
@@ -4132,14 +5318,35 @@ export class FlexHarness<TScope = unknown> {
4132
5318
  if (state.lifecycle !== 'fenced') state.lifecycle = 'retiring';
4133
5319
  const detachedCleanupAttempts = this.beginDetachedCleanupSettlement(state);
4134
5320
  const errors: unknown[] = [];
4135
- for (const stored of state.sessions.values()) this.cancelStoredPromptQueue(stored, reason);
5321
+ const contextFor = (sessionId: string) => invocation
5322
+ ? this.createCompactorContext(invocation.scopeId, invocation.scope, storageKey, sessionId)
5323
+ : undefined;
5324
+ for (const stored of state.sessions.values()) {
5325
+ this.abortCompactorLifecycle(stored, reason);
5326
+ this.cancelStoredPromptQueue(
5327
+ stored,
5328
+ reason,
5329
+ undefined,
5330
+ contextFor(stored.session.sessionId),
5331
+ );
5332
+ }
4136
5333
  for (const run of state.activeRuns.values()) {
4137
- if (run.phase !== 'finalizing' && run.phase !== 'promoting') this.cancelRun(run, reason);
5334
+ if (run.phase !== 'finalizing' && run.phase !== 'promoting') {
5335
+ this.cancelRun(run, reason, contextFor(run.sessionId));
5336
+ }
4138
5337
  }
4139
5338
  for (const pending of [...state.pendingPermissions.values()]) this.rejectPending(state, pending, reason);
5339
+ const runResults = await Promise.allSettled([...state.activeRuns.values()].map((run) => run.completion));
5340
+ for (const result of runResults) {
5341
+ if (result.status === 'rejected') this.appendUnexpectedErrors(errors, result.reason);
5342
+ }
4140
5343
  for (const stored of state.sessions.values()) {
4141
5344
  try {
4142
- await this.abortStoredSession(stored, reason);
5345
+ await this.abortStoredSession(
5346
+ stored,
5347
+ reason,
5348
+ contextFor(stored.session.sessionId),
5349
+ );
4143
5350
  } catch (error) {
4144
5351
  errors.push(this.projectOperationError(
4145
5352
  error,
@@ -4150,10 +5357,6 @@ export class FlexHarness<TScope = unknown> {
4150
5357
  ));
4151
5358
  }
4152
5359
  }
4153
- const runResults = await Promise.allSettled([...state.activeRuns.values()].map((run) => run.completion));
4154
- for (const result of runResults) {
4155
- if (result.status === 'rejected') this.appendUnexpectedErrors(errors, result.reason);
4156
- }
4157
5360
  await Promise.allSettled([...state.sessionInitializations.values()]);
4158
5361
  errors.push(...await this.closeOrphanedResources(state.storageKey));
4159
5362
  await state.scopeQueue;
@@ -4173,7 +5376,7 @@ export class FlexHarness<TScope = unknown> {
4173
5376
  errors.push(...await this.settleDetachedCleanups(state, detachedCleanupAttempts));
4174
5377
  for (const stored of state.sessions.values()) {
4175
5378
  try {
4176
- await this.closeStoredSession(stored);
5379
+ await this.closeStoredSession(stored, contextFor(stored.session.sessionId));
4177
5380
  } catch (error) {
4178
5381
  this.appendUnexpectedErrors(errors, this.projectOperationError(
4179
5382
  error,
@@ -4185,19 +5388,27 @@ export class FlexHarness<TScope = unknown> {
4185
5388
  }
4186
5389
  this.purgeStoredPromptQueue(stored);
4187
5390
  }
4188
- for (const sessionId of [...state.tombstones.keys()]) {
4189
- if (attemptedTombstones.has(sessionId)) continue;
4190
- 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);
4191
5400
  try {
4192
5401
  await this.finishTombstoneCleanup(
4193
5402
  state,
4194
- sessionId,
4195
- retained?.stored.session.scopeId ?? state.scopeIdHint,
5403
+ rootSessionId,
5404
+ invocation?.scopeId ?? retained?.stored.session.scopeId ?? state.scopeIdHint,
5405
+ invocation,
4196
5406
  );
4197
5407
  } catch (error) {
4198
5408
  this.appendUnexpectedErrors(errors, error);
4199
5409
  }
4200
5410
  }
5411
+ errors.push(...state.fenceAdditionalErrors.splice(0));
4201
5412
  if (errors.length > 0) throw combineErrors(errors);
4202
5413
  state.lifecycle = 'retired';
4203
5414
  state.sessions.clear();
@@ -4240,10 +5451,12 @@ export class FlexHarness<TScope = unknown> {
4240
5451
  }
4241
5452
  this.listeners.clear();
4242
5453
  if (errors.length > 0) throw combineErrors(errors);
5454
+ this.compactorInvocationContext.disable();
4243
5455
  this.stateLoads.clear();
4244
5456
  this.scopeAdmissions.clear();
4245
5457
  this.scopeRetirements.clear();
4246
5458
  this.storageDrains.clear();
5459
+ this.storageCompactorLifecycleControllers.clear();
4247
5460
  }
4248
5461
 
4249
5462
  private appendUnexpectedErrors(target: unknown[], error: unknown): void {
@@ -4252,6 +5465,12 @@ export class FlexHarness<TScope = unknown> {
4252
5465
  } else if (!isAbortError(error)) target.push(error);
4253
5466
  }
4254
5467
 
5468
+ private abortCompactorLifecycle(stored: IStoredSessionState, reason: FlexHarnessAbortError): void {
5469
+ if (!stored.compactorLifecycleController.signal.aborted) {
5470
+ stored.compactorLifecycleController.abort(reason);
5471
+ }
5472
+ }
5473
+
4255
5474
  private async resolveState(
4256
5475
  scopeId: string,
4257
5476
  ): Promise<{ scope: IFlexResolvedScope<TScope>; state: IStorageState }> {
@@ -4279,10 +5498,26 @@ export class FlexHarness<TScope = unknown> {
4279
5498
  ) throw this.createScopeRetirementError();
4280
5499
  stateLoad = this.stateLoads.get(scope.storageKey);
4281
5500
  if (!stateLoad) {
4282
- stateLoad = this.loadState(scope.storageKey, scopeId, scope.scope);
5501
+ const compactorLifecycleController = new AbortController();
5502
+ this.storageCompactorLifecycleControllers.set(
5503
+ scope.storageKey,
5504
+ compactorLifecycleController,
5505
+ );
5506
+ stateLoad = this.loadState(
5507
+ scope.storageKey,
5508
+ scopeId,
5509
+ scope.scope,
5510
+ compactorLifecycleController,
5511
+ );
4283
5512
  this.stateLoads.set(scope.storageKey, stateLoad);
4284
5513
  void stateLoad.catch(() => {
4285
- if (this.stateLoads.get(scope.storageKey) === stateLoad) this.stateLoads.delete(scope.storageKey);
5514
+ if (this.stateLoads.get(scope.storageKey) === stateLoad) {
5515
+ this.stateLoads.delete(scope.storageKey);
5516
+ if (
5517
+ this.storageCompactorLifecycleControllers.get(scope.storageKey)
5518
+ === compactorLifecycleController
5519
+ ) this.storageCompactorLifecycleControllers.delete(scope.storageKey);
5520
+ }
4286
5521
  });
4287
5522
  }
4288
5523
  }
@@ -4328,6 +5563,37 @@ export class FlexHarness<TScope = unknown> {
4328
5563
  }
4329
5564
  }
4330
5565
 
5566
+ private createCompactorContext(
5567
+ scopeId: string,
5568
+ scope: TScope,
5569
+ storageKey: string,
5570
+ sessionId: string,
5571
+ ): IFlexAgentContextInvocation<TScope> {
5572
+ return Object.freeze({ scopeId, scope, storageKey, sessionId });
5573
+ }
5574
+
5575
+ private withCompactorContext<TResult>(
5576
+ context: IFlexAgentContextInvocation<unknown>,
5577
+ operation: () => TResult,
5578
+ ): TResult {
5579
+ return this.compactorInvocationContext.run(
5580
+ context as IFlexAgentContextInvocation<TScope>,
5581
+ operation,
5582
+ );
5583
+ }
5584
+
5585
+ private createRunCompactorContext(run: IActiveRun): IFlexAgentContextInvocation<TScope> {
5586
+ return run.originCompactorContext as IFlexAgentContextInvocation<TScope>;
5587
+ }
5588
+
5589
+ private effectiveRunCompactorContext(run: IActiveRun): IFlexAgentContextInvocation<TScope> {
5590
+ return (run.deferredCompactorContext ?? run.originCompactorContext) as IFlexAgentContextInvocation<TScope>;
5591
+ }
5592
+
5593
+ private withRunCompactorContext<TResult>(run: IActiveRun, operation: () => TResult): TResult {
5594
+ return this.withCompactorContext(this.effectiveRunCompactorContext(run), operation);
5595
+ }
5596
+
4331
5597
  private requireSession(state: IStorageState, sessionId: string): IStoredSessionState {
4332
5598
  validateIdentifier(sessionId, 'sessionId');
4333
5599
  requireTransferIdentifier(sessionId, 'sessionId');