@modelprofile.com/flexharness 5.0.0 → 5.2.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.
@@ -33,6 +33,8 @@ import type {
33
33
  IFlexCallbackLimits,
34
34
  IFlexCreateProjectTaskInput,
35
35
  IFlexCreateSessionOptions,
36
+ IFlexDeleteSessionGenerationCohortInput,
37
+ IFlexDeleteSessionGenerationCohortResult,
36
38
  IFlexErrorInfo,
37
39
  IFlexEventArchiveMetadata,
38
40
  IFlexExecutionContextHandle,
@@ -74,6 +76,8 @@ import type {
74
76
  IFlexSchedulePromptOptions,
75
77
  IFlexScopeSnapshot,
76
78
  IFlexSession,
79
+ IFlexSessionGeneration,
80
+ IFlexSessionGenerationCohortEntry,
77
81
  IFlexSessionReversionGroup,
78
82
  IFlexSessionReversionInfo,
79
83
  IFlexSessionTombstone,
@@ -173,11 +177,13 @@ import {
173
177
  import { wrapToolSet } from './utils.tooloutput.js';
174
178
  import {
175
179
  validateCreateProjectTaskInput,
180
+ validateDeleteSessionGenerationCohortInput,
176
181
  validateIdentifier,
177
182
  validateProjectTaskId,
178
183
  validateProjectedErrorInfo,
179
184
  validatePromptOptions,
180
185
  validateSlashCommandExecutionOptions,
186
+ validateSessionGenerationId,
181
187
  validateUpdateProjectTaskInput,
182
188
  validateUpdateSessionOptions,
183
189
  validateUtf8String,
@@ -438,6 +444,16 @@ interface IFlexSubagentAcquisition {
438
444
  created: boolean;
439
445
  }
440
446
 
447
+ interface ISessionDeletionPlan {
448
+ matched: boolean;
449
+ deletion?: Promise<void>;
450
+ }
451
+
452
+ interface IReservedSessionDeletion {
453
+ deletedSessions: IFlexSession[];
454
+ descendantRoots: string[];
455
+ }
456
+
441
457
  type TProjectTaskToolInput =
442
458
  | { action: 'list' }
443
459
  | {
@@ -519,7 +535,7 @@ function requireSessionGeneration(
519
535
  IFlexSession | IFlexSessionTombstone,
520
536
  'sessionGenerationId' | 'sessionGenerationSequence'
521
537
  >,
522
- ): { sessionGenerationId: string; sessionGenerationSequence: number } {
538
+ ): IFlexSessionGeneration {
523
539
  if (
524
540
  !session.sessionGenerationId
525
541
  || !Number.isSafeInteger(session.sessionGenerationSequence)
@@ -533,6 +549,18 @@ function requireSessionGeneration(
533
549
  };
534
550
  }
535
551
 
552
+ function matchesSessionGeneration(
553
+ session: Pick<
554
+ IFlexSession | IFlexSessionTombstone,
555
+ 'sessionGenerationId' | 'sessionGenerationSequence'
556
+ >,
557
+ expected: Readonly<IFlexSessionGeneration>,
558
+ ): boolean {
559
+ const actual = requireSessionGeneration(session);
560
+ return actual.sessionGenerationId === expected.sessionGenerationId
561
+ && actual.sessionGenerationSequence === expected.sessionGenerationSequence;
562
+ }
563
+
536
564
  function sha256Hex(value: string): string {
537
565
  return plugins.crypto.createHash('sha256').update(value, 'utf8').digest('hex');
538
566
  }
@@ -726,8 +754,14 @@ export class FlexHarness<TScope = unknown> {
726
754
  if (!options || typeof options !== 'object' || Array.isArray(options)) {
727
755
  throw new FlexHarnessValidationError('createSession options must be a plain object.');
728
756
  }
729
- const unsupported = Object.keys(options).find((key) => key !== 'sessionId' && key !== 'title');
757
+ const unsupported = Object.keys(options).find(
758
+ (key) => !['sessionId', 'sessionGenerationId', 'title'].includes(key),
759
+ );
730
760
  if (unsupported) throw new FlexHarnessValidationError(`createSession does not support "${unsupported}".`);
761
+ const requestedSessionGenerationId = options.sessionGenerationId;
762
+ if (requestedSessionGenerationId !== undefined) {
763
+ validateSessionGenerationId(requestedSessionGenerationId);
764
+ }
731
765
  const resolved = await this.resolveState(scopeId);
732
766
  const sessionId = options.sessionId ?? plugins.crypto.randomUUID();
733
767
  validateIdentifier(sessionId, 'sessionId');
@@ -774,7 +808,7 @@ export class FlexHarness<TScope = unknown> {
774
808
  metadata = {
775
809
  scopeId,
776
810
  sessionId,
777
- sessionGenerationId: createSessionGenerationId(),
811
+ sessionGenerationId: requestedSessionGenerationId ?? createSessionGenerationId(),
778
812
  sessionGenerationSequence: resolved.state.revision + 1,
779
813
  ...(options.title ? { title: options.title } : {}),
780
814
  createdAt: timestamp,
@@ -877,7 +911,7 @@ export class FlexHarness<TScope = unknown> {
877
911
  completeInitialization();
878
912
  }
879
913
  const result = publicSnapshot(metadata);
880
- this.emitEvent(scopeId, sessionId, { type: 'session.created', session: result });
914
+ this.emitEvent(scopeId, metadata, { type: 'session.created', session: result });
881
915
  return result;
882
916
  }
883
917
 
@@ -945,7 +979,7 @@ export class FlexHarness<TScope = unknown> {
945
979
  stored.session.updatedAt = timestamp;
946
980
  result = publicSnapshot(stored.session);
947
981
  });
948
- this.emitEvent(scopeId, sessionId, { type: 'session.updated', session: result });
982
+ this.emitEvent(scopeId, result, { type: 'session.updated', session: result });
949
983
  return result;
950
984
  }
951
985
 
@@ -1124,17 +1158,136 @@ export class FlexHarness<TScope = unknown> {
1124
1158
  );
1125
1159
  }
1126
1160
 
1161
+ public async deleteSessionGenerationCohort(
1162
+ scopeId: string,
1163
+ input: IFlexDeleteSessionGenerationCohortInput,
1164
+ ): Promise<IFlexDeleteSessionGenerationCohortResult> {
1165
+ const validated = validateDeleteSessionGenerationCohortInput(input);
1166
+ requireTransferIdentifier(validated.root.sessionId, 'root.sessionId');
1167
+ for (const entry of validated.authorizedBySessionId.values()) {
1168
+ requireTransferIdentifier(entry.sessionId, 'authorizedCohort sessionId');
1169
+ }
1170
+ this.assertNoAmbientSlashCommandTeardown({
1171
+ scopeId,
1172
+ sessionId: validated.root.sessionId,
1173
+ });
1174
+ const { scope, state } = await this.resolveState(scopeId);
1175
+ this.assertNoAmbientSlashCommandTeardown({ storageKey: state.storageKey });
1176
+ const plan = await this.withScopeMutationOwnership(state, async (): Promise<ISessionDeletionPlan> => {
1177
+ const stored = state.sessions.get(validated.root.sessionId);
1178
+ if (stored) {
1179
+ if (!matchesSessionGeneration(stored.session, validated.root)) {
1180
+ return { matched: false };
1181
+ }
1182
+ const subtree = this.collectSessionSubtree(state, validated.root.sessionId);
1183
+ const descendantRoots = this.descendantTombstoneRoots(
1184
+ state,
1185
+ validated.root.sessionId,
1186
+ );
1187
+ this.assertAuthorizedSessionGenerationCohort(
1188
+ [
1189
+ ...subtree.map((entry) => entry.session),
1190
+ ...this.tombstonesForRoots(state, descendantRoots),
1191
+ ],
1192
+ validated.authorizedBySessionId,
1193
+ );
1194
+ const reservation = await this.mutateScopeOwned(state, () =>
1195
+ this.reserveLiveSessionDeletion(
1196
+ state,
1197
+ validated.root.sessionId,
1198
+ stored,
1199
+ subtree,
1200
+ descendantRoots,
1201
+ ));
1202
+ return {
1203
+ matched: true,
1204
+ deletion: this.startReservedSessionDeletion(
1205
+ state,
1206
+ scopeId,
1207
+ scope.scope,
1208
+ validated.root.sessionId,
1209
+ reservation,
1210
+ ),
1211
+ };
1212
+ }
1213
+ const tombstone = state.tombstones.get(validated.root.sessionId);
1214
+ if (!tombstone || !matchesSessionGeneration(tombstone, validated.root)) {
1215
+ return { matched: false };
1216
+ }
1217
+ const rootSessionId = tombstone.rootSessionId ?? tombstone.sessionId;
1218
+ const descendantRoots = this.descendantTombstoneRoots(state, rootSessionId);
1219
+ this.assertAuthorizedSessionGenerationCohort(
1220
+ this.tombstonesForRoots(state, [rootSessionId, ...descendantRoots]),
1221
+ validated.authorizedBySessionId,
1222
+ );
1223
+ return {
1224
+ matched: true,
1225
+ deletion: this.startSessionDeletion(
1226
+ state,
1227
+ scopeId,
1228
+ scope.scope,
1229
+ validated.root.sessionId,
1230
+ ),
1231
+ };
1232
+ });
1233
+ if (!plan.matched) return publicSnapshot({ matched: false });
1234
+ if (!plan.deletion) throw new Error('Matched session deletion has no cleanup operation.');
1235
+ await plan.deletion;
1236
+ return publicSnapshot({ matched: true });
1237
+ }
1238
+
1127
1239
  public async deleteSession(scopeId: string, sessionId: string): Promise<void> {
1128
1240
  this.assertNoAmbientSlashCommandTeardown({ scopeId, sessionId });
1129
1241
  const { scope, state } = await this.resolveState(scopeId);
1130
1242
  this.assertNoAmbientSlashCommandTeardown({ storageKey: state.storageKey });
1131
1243
  validateIdentifier(sessionId, 'sessionId');
1132
1244
  await state.scopeQueue;
1245
+ return this.startSessionDeletion(state, scopeId, scope.scope, sessionId);
1246
+ }
1247
+
1248
+ private startSessionDeletion(
1249
+ state: IStorageState,
1250
+ scopeId: string,
1251
+ scope: TScope,
1252
+ sessionId: string,
1253
+ ): Promise<void> {
1133
1254
  const deletionKey = state.tombstones.get(sessionId)?.rootSessionId ?? sessionId;
1255
+ return this.registerSessionDeletion(
1256
+ state,
1257
+ deletionKey,
1258
+ () => this.deleteSessionInternal(state, scopeId, scope, sessionId),
1259
+ );
1260
+ }
1261
+
1262
+ private startReservedSessionDeletion(
1263
+ state: IStorageState,
1264
+ scopeId: string,
1265
+ scope: TScope,
1266
+ sessionId: string,
1267
+ reservation: IReservedSessionDeletion,
1268
+ ): Promise<void> {
1269
+ return this.registerSessionDeletion(
1270
+ state,
1271
+ sessionId,
1272
+ () => this.finishReservedSessionDeletion(
1273
+ state,
1274
+ scopeId,
1275
+ scope,
1276
+ sessionId,
1277
+ reservation,
1278
+ ),
1279
+ );
1280
+ }
1281
+
1282
+ private registerSessionDeletion(
1283
+ state: IStorageState,
1284
+ deletionKey: string,
1285
+ operation: () => Promise<void>,
1286
+ ): Promise<void> {
1134
1287
  const existingDeletion = state.sessionDeletions.get(deletionKey);
1135
1288
  if (existingDeletion) return existingDeletion;
1136
1289
  let deletion!: Promise<void>;
1137
- deletion = this.deleteSessionInternal(state, scopeId, scope.scope, sessionId).finally(() => {
1290
+ deletion = operation().finally(() => {
1138
1291
  if (state.sessionDeletions.get(deletionKey) === deletion) {
1139
1292
  state.sessionDeletions.delete(deletionKey);
1140
1293
  }
@@ -1143,6 +1296,60 @@ export class FlexHarness<TScope = unknown> {
1143
1296
  return deletion;
1144
1297
  }
1145
1298
 
1299
+ private assertAuthorizedSessionGenerationCohort(
1300
+ entries: readonly (IFlexSession | IFlexSessionTombstone)[],
1301
+ authorizedBySessionId: ReadonlyMap<
1302
+ string,
1303
+ Readonly<IFlexSessionGenerationCohortEntry>
1304
+ >,
1305
+ ): void {
1306
+ for (const entry of entries) {
1307
+ const authorized = authorizedBySessionId.get(entry.sessionId);
1308
+ if (!authorized || !matchesSessionGeneration(entry, authorized)) {
1309
+ throw new FlexHarnessValidationError(
1310
+ `authorizedCohort does not authorize the exact generation of session "${entry.sessionId}".`,
1311
+ );
1312
+ }
1313
+ }
1314
+ }
1315
+
1316
+ private reserveLiveSessionDeletion(
1317
+ state: IStorageState,
1318
+ sessionId: string,
1319
+ stored: IStoredSessionState,
1320
+ subtree: readonly IStoredSessionState[],
1321
+ descendantRoots: readonly string[],
1322
+ ): IReservedSessionDeletion {
1323
+ if (state.sessions.get(sessionId) !== stored) {
1324
+ throw new FlexHarnessNotFoundError('Session', sessionId);
1325
+ }
1326
+ const rootDepth = stored.session.depth ?? 0;
1327
+ const deletedAt = new Date().toISOString();
1328
+ const deletedSessions = subtree.map((entry) => cloneSerializable(entry.session));
1329
+ for (const entry of subtree) {
1330
+ const entrySessionId = entry.session.sessionId;
1331
+ state.sessions.delete(entrySessionId);
1332
+ state.retainedSessionCleanups.set(entrySessionId, {
1333
+ stored: entry,
1334
+ domainsCompleted: false,
1335
+ });
1336
+ state.tombstones.set(entrySessionId, {
1337
+ sessionId: entrySessionId,
1338
+ ...requireSessionGeneration(entry.session),
1339
+ deletedAt,
1340
+ rootSessionId: sessionId,
1341
+ depth: (entry.session.depth ?? rootDepth) - rootDepth,
1342
+ ...(entry.session.parentSessionId === undefined
1343
+ ? {}
1344
+ : { parentSessionId: entry.session.parentSessionId }),
1345
+ });
1346
+ }
1347
+ return {
1348
+ deletedSessions,
1349
+ descendantRoots: [...descendantRoots],
1350
+ };
1351
+ }
1352
+
1146
1353
  private async deleteSessionInternal(
1147
1354
  state: IStorageState,
1148
1355
  scopeId: string,
@@ -1172,36 +1379,28 @@ export class FlexHarness<TScope = unknown> {
1172
1379
  return;
1173
1380
  }
1174
1381
  const stored = this.requireSession(state, sessionId);
1175
- let deletedSessions: IFlexSession[] = [];
1176
- let descendantRoots: string[] = [];
1177
- await this.mutateScope(state, () => {
1178
- if (state.sessions.get(sessionId) !== stored) {
1179
- throw new FlexHarnessNotFoundError('Session', sessionId);
1180
- }
1382
+ const reservation = await this.mutateScope(state, () => {
1181
1383
  const subtree = this.collectSessionSubtree(state, sessionId);
1182
- descendantRoots = this.descendantTombstoneRoots(state, sessionId);
1183
- const rootDepth = stored.session.depth ?? 0;
1184
- const deletedAt = new Date().toISOString();
1185
- deletedSessions = subtree.map((entry) => cloneSerializable(entry.session));
1186
- for (const entry of subtree) {
1187
- const entrySessionId = entry.session.sessionId;
1188
- state.sessions.delete(entrySessionId);
1189
- state.retainedSessionCleanups.set(entrySessionId, {
1190
- stored: entry,
1191
- domainsCompleted: false,
1192
- });
1193
- state.tombstones.set(entrySessionId, {
1194
- sessionId: entrySessionId,
1195
- ...requireSessionGeneration(entry.session),
1196
- deletedAt,
1197
- rootSessionId: sessionId,
1198
- depth: (entry.session.depth ?? rootDepth) - rootDepth,
1199
- ...(entry.session.parentSessionId === undefined
1200
- ? {}
1201
- : { parentSessionId: entry.session.parentSessionId }),
1202
- });
1203
- }
1384
+ const descendantRoots = this.descendantTombstoneRoots(state, sessionId);
1385
+ return this.reserveLiveSessionDeletion(
1386
+ state,
1387
+ sessionId,
1388
+ stored,
1389
+ subtree,
1390
+ descendantRoots,
1391
+ );
1204
1392
  });
1393
+ await this.finishReservedSessionDeletion(state, scopeId, scope, sessionId, reservation);
1394
+ }
1395
+
1396
+ private async finishReservedSessionDeletion(
1397
+ state: IStorageState,
1398
+ scopeId: string,
1399
+ scope: TScope,
1400
+ sessionId: string,
1401
+ reservation: IReservedSessionDeletion,
1402
+ ): Promise<void> {
1403
+ const { deletedSessions, descendantRoots } = reservation;
1205
1404
  const reason = this.trustInternalError(new FlexHarnessAbortError('The session subtree was deleted.'));
1206
1405
  const slashCommandErrors: unknown[] = [];
1207
1406
  for (const deleted of deletedSessions) {
@@ -1250,7 +1449,7 @@ export class FlexHarness<TScope = unknown> {
1250
1449
  throw this.projectOperationError(error, 'persistence', scopeId, sessionId, 'session-delete');
1251
1450
  }
1252
1451
  for (const deleted of childFirstDeletedSessions) {
1253
- this.emitEvent(scopeId, deleted.sessionId, {
1452
+ this.emitEvent(scopeId, deleted, {
1254
1453
  type: 'session.deleted',
1255
1454
  session: publicSnapshot(deleted),
1256
1455
  });
@@ -1849,6 +2048,7 @@ export class FlexHarness<TScope = unknown> {
1849
2048
  scope,
1850
2049
  storageKey: state.storageKey,
1851
2050
  sessionId: stored.session.sessionId,
2051
+ ...requireSessionGeneration(stored.session),
1852
2052
  rawArguments,
1853
2053
  arguments: arguments_,
1854
2054
  signal,
@@ -2414,7 +2614,7 @@ export class FlexHarness<TScope = unknown> {
2414
2614
  .find((segment) => segment?.status === 'completed');
2415
2615
  if (!target) throw new FlexHarnessValidationError('Pending reversion has no target candidate.');
2416
2616
  await this.resumePendingApply(state, stored, scopeId, scope, operationSignal);
2417
- this.emitEvent(scopeId, sessionId, {
2617
+ this.emitEvent(scopeId, stored.session, {
2418
2618
  type: 'session.history.changed',
2419
2619
  direction,
2420
2620
  runId: target.runId,
@@ -2465,7 +2665,7 @@ export class FlexHarness<TScope = unknown> {
2465
2665
  scope,
2466
2666
  operationSignal,
2467
2667
  );
2468
- this.emitEvent(scopeId, sessionId, {
2668
+ this.emitEvent(scopeId, stored.session, {
2469
2669
  type: 'session.history.changed',
2470
2670
  direction,
2471
2671
  runId: unit.target.runId,
@@ -2512,6 +2712,7 @@ export class FlexHarness<TScope = unknown> {
2512
2712
  ): Promise<void> {
2513
2713
  const pending = stored.pendingReversion;
2514
2714
  if (pending?.kind !== 'apply') return;
2715
+ const sessionGeneration = requireSessionGeneration(stored.session);
2515
2716
  const segmentMap = new Map(stored.reversionSegments.map((segment) => [segment.runId, segment]));
2516
2717
  if (
2517
2718
  pending.segmentRunIds.some((runId) =>
@@ -2552,6 +2753,7 @@ export class FlexHarness<TScope = unknown> {
2552
2753
  scope,
2553
2754
  storageKey: state.storageKey,
2554
2755
  sessionId: stored.session.sessionId,
2756
+ ...sessionGeneration,
2555
2757
  runId,
2556
2758
  captureId,
2557
2759
  reference: cloneSerializable(reference),
@@ -2685,6 +2887,7 @@ export class FlexHarness<TScope = unknown> {
2685
2887
  scopeId,
2686
2888
  scope,
2687
2889
  stored.session.sessionId,
2890
+ requireSessionGeneration(stored.session),
2688
2891
  pending.runId,
2689
2892
  pending.captureId,
2690
2893
  pending.state === 'prepared' ? 'finalizing' : pending.state,
@@ -2760,7 +2963,7 @@ export class FlexHarness<TScope = unknown> {
2760
2963
  throw this.projectOperationError(error, 'persistence', scopeId, sessionId, 'archive-horizon');
2761
2964
  }
2762
2965
  if (branchCommitted) {
2763
- this.emitEvent(scopeId, sessionId, {
2966
+ this.emitEvent(scopeId, stored.session, {
2764
2967
  type: 'session.history.changed',
2765
2968
  direction: 'branch',
2766
2969
  });
@@ -3226,14 +3429,14 @@ export class FlexHarness<TScope = unknown> {
3226
3429
  }
3227
3430
 
3228
3431
  const runType = run.scheduleKey ? 'run.scheduled' : 'run.started';
3229
- this.emitEvent(run.scopeId, run.sessionId, {
3432
+ this.emitEvent(run.scopeId, run.stored.session, {
3230
3433
  type: runType,
3231
3434
  runId: run.runId,
3232
3435
  messageId: run.userMessageId,
3233
3436
  session: publicSnapshot(run.stored.session),
3234
3437
  });
3235
3438
  for (const message of [run.reservedUserMessage!, run.reservedAssistantMessage!]) {
3236
- this.emitEvent(run.scopeId, run.sessionId, {
3439
+ this.emitEvent(run.scopeId, run.stored.session, {
3237
3440
  type: 'message.created',
3238
3441
  runId: run.runId,
3239
3442
  messageId: message.messageId,
@@ -3302,7 +3505,7 @@ export class FlexHarness<TScope = unknown> {
3302
3505
  type: 'prompt.queued' | 'prompt.started' | 'prompt.running' | 'prompt.finished',
3303
3506
  entry: IFlexPromptQueueEntry = this.projectPromptQueueEntry(queued),
3304
3507
  ): void {
3305
- this.emitEvent(queued.scopeId, queued.sessionId, {
3508
+ this.emitEvent(queued.scopeId, queued.stored.session, {
3306
3509
  type,
3307
3510
  queueId: queued.queueId,
3308
3511
  ...(entry.runId ? { runId: entry.runId } : {}),
@@ -3986,6 +4189,7 @@ export class FlexHarness<TScope = unknown> {
3986
4189
  scopeId: run.scopeId,
3987
4190
  scope: run.scope as TScope,
3988
4191
  sessionId: run.sessionId,
4192
+ ...requireSessionGeneration(run.stored.session),
3989
4193
  runId: run.runId,
3990
4194
  ...(options.modelHint ? { modelHint: options.modelHint } : {}),
3991
4195
  ...resolverRelationship,
@@ -3999,6 +4203,7 @@ export class FlexHarness<TScope = unknown> {
3999
4203
  scopeId: run.scopeId,
4000
4204
  scope: run.scope as TScope,
4001
4205
  sessionId: run.sessionId,
4206
+ ...requireSessionGeneration(run.stored.session),
4002
4207
  runId: run.runId,
4003
4208
  ...resolverRelationship,
4004
4209
  signal,
@@ -4184,7 +4389,7 @@ export class FlexHarness<TScope = unknown> {
4184
4389
  run.stored.session.updatedAt = new Date().toISOString();
4185
4390
  session = publicSnapshot(run.stored.session);
4186
4391
  });
4187
- this.emitEvent(run.scopeId, run.sessionId, {
4392
+ this.emitEvent(run.scopeId, session, {
4188
4393
  type: 'session.updated',
4189
4394
  session,
4190
4395
  });
@@ -4716,7 +4921,7 @@ export class FlexHarness<TScope = unknown> {
4716
4921
  throw aborted;
4717
4922
  }
4718
4923
  state.sessions.set(sessionId, loaded);
4719
- this.emitEvent(run.scopeId, sessionId, {
4924
+ this.emitEvent(run.scopeId, metadata, {
4720
4925
  type: 'session.created',
4721
4926
  session: publicSnapshot(metadata),
4722
4927
  });
@@ -4967,6 +5172,7 @@ export class FlexHarness<TScope = unknown> {
4967
5172
  scope: run.scope as TScope,
4968
5173
  storageKey: run.state.storageKey,
4969
5174
  sessionId: run.sessionId,
5175
+ ...requireSessionGeneration(run.stored.session),
4970
5176
  runId: run.runId,
4971
5177
  captureId,
4972
5178
  signal,
@@ -5001,6 +5207,7 @@ export class FlexHarness<TScope = unknown> {
5001
5207
  scope: TScope,
5002
5208
  storageKey: string,
5003
5209
  sessionId: string,
5210
+ sessionGeneration: Readonly<IFlexSessionGeneration>,
5004
5211
  runId: string,
5005
5212
  captureId: string,
5006
5213
  ): Promise<TNormalizedCaptureInspection> {
@@ -5011,6 +5218,7 @@ export class FlexHarness<TScope = unknown> {
5011
5218
  scope,
5012
5219
  storageKey,
5013
5220
  sessionId,
5221
+ ...sessionGeneration,
5014
5222
  runId,
5015
5223
  captureId,
5016
5224
  signal,
@@ -5044,6 +5252,7 @@ export class FlexHarness<TScope = unknown> {
5044
5252
  scope: TScope,
5045
5253
  storageKey: string,
5046
5254
  sessionId: string,
5255
+ sessionGeneration: Readonly<IFlexSessionGeneration>,
5047
5256
  runId: string,
5048
5257
  captureId: string,
5049
5258
  ): Promise<TFlexTurnReversionFinalizedOutcome> {
@@ -5053,6 +5262,7 @@ export class FlexHarness<TScope = unknown> {
5053
5262
  scope,
5054
5263
  storageKey,
5055
5264
  sessionId,
5265
+ ...sessionGeneration,
5056
5266
  runId,
5057
5267
  captureId,
5058
5268
  signal,
@@ -5072,6 +5282,7 @@ export class FlexHarness<TScope = unknown> {
5072
5282
  scopeId: string,
5073
5283
  scope: TScope,
5074
5284
  sessionId: string,
5285
+ sessionGeneration: Readonly<IFlexSessionGeneration>,
5075
5286
  runId: string,
5076
5287
  captureId: string,
5077
5288
  durableState: 'preparing' | 'prepared' | 'finalizing',
@@ -5083,6 +5294,7 @@ export class FlexHarness<TScope = unknown> {
5083
5294
  scope,
5084
5295
  state.storageKey,
5085
5296
  sessionId,
5297
+ sessionGeneration,
5086
5298
  runId,
5087
5299
  captureId,
5088
5300
  );
@@ -5107,6 +5319,7 @@ export class FlexHarness<TScope = unknown> {
5107
5319
  scope,
5108
5320
  state.storageKey,
5109
5321
  sessionId,
5322
+ sessionGeneration,
5110
5323
  runId,
5111
5324
  captureId,
5112
5325
  );
@@ -5116,6 +5329,7 @@ export class FlexHarness<TScope = unknown> {
5116
5329
  scope,
5117
5330
  state.storageKey,
5118
5331
  sessionId,
5332
+ sessionGeneration,
5119
5333
  runId,
5120
5334
  captureId,
5121
5335
  );
@@ -5268,6 +5482,7 @@ export class FlexHarness<TScope = unknown> {
5268
5482
  run.scope as TScope,
5269
5483
  run.state.storageKey,
5270
5484
  run.sessionId,
5485
+ requireSessionGeneration(run.stored.session),
5271
5486
  run.runId,
5272
5487
  captureId,
5273
5488
  );
@@ -5349,6 +5564,7 @@ export class FlexHarness<TScope = unknown> {
5349
5564
  run.scopeId,
5350
5565
  run.scope as TScope,
5351
5566
  run.sessionId,
5567
+ requireSessionGeneration(run.stored.session),
5352
5568
  run.runId,
5353
5569
  captureId,
5354
5570
  pending.state === 'prepared' ? 'finalizing' : pending.state,
@@ -5514,7 +5730,7 @@ export class FlexHarness<TScope = unknown> {
5514
5730
  return;
5515
5731
  }
5516
5732
  await this.mutateProjection(state, stored, () => this.commitRevertedBranchState(stored), true);
5517
- this.emitEvent(scopeId, stored.session.sessionId, {
5733
+ this.emitEvent(scopeId, stored.session, {
5518
5734
  type: 'session.history.changed',
5519
5735
  direction: 'branch',
5520
5736
  });
@@ -5598,6 +5814,7 @@ export class FlexHarness<TScope = unknown> {
5598
5814
  }
5599
5815
  return;
5600
5816
  }
5817
+ const sessionGeneration = requireSessionGeneration(stored.session);
5601
5818
  while (stored.pendingReversionReleases.length > 0) {
5602
5819
  const release = stored.pendingReversionReleases[0];
5603
5820
  if (this.reversionProviderProtocolVersion() !== release.protocolVersion) {
@@ -5612,6 +5829,7 @@ export class FlexHarness<TScope = unknown> {
5612
5829
  scope,
5613
5830
  storageKey: state.storageKey,
5614
5831
  sessionId: stored.session.sessionId,
5832
+ ...sessionGeneration,
5615
5833
  runId: release.runId,
5616
5834
  captureId: release.captureId,
5617
5835
  reference: cloneSerializable(release.reference),
@@ -5649,11 +5867,13 @@ export class FlexHarness<TScope = unknown> {
5649
5867
  private async releaseDeletedSessionReversions(
5650
5868
  state: IStorageState,
5651
5869
  storageKey: string,
5652
- sessionId: string,
5870
+ tombstone: IFlexSessionTombstone,
5653
5871
  scopeId: string,
5654
5872
  scope: TScope,
5655
5873
  stored?: IStoredSessionState,
5656
5874
  ): Promise<void> {
5875
+ const sessionId = tombstone.sessionId;
5876
+ const sessionGeneration = requireSessionGeneration(stored?.session ?? tombstone);
5657
5877
  if (stored) await stored.projectionQueue;
5658
5878
  let durableProjection = await this.stores.projections.load(storageKey, sessionId);
5659
5879
  if (!durableProjection) return;
@@ -5712,6 +5932,7 @@ export class FlexHarness<TScope = unknown> {
5712
5932
  scope,
5713
5933
  storageKey,
5714
5934
  sessionId,
5935
+ ...sessionGeneration,
5715
5936
  runId,
5716
5937
  captureId,
5717
5938
  reference: cloneSerializable(reference),
@@ -5825,6 +6046,7 @@ export class FlexHarness<TScope = unknown> {
5825
6046
  scope,
5826
6047
  storageKey,
5827
6048
  sessionId,
6049
+ sessionGeneration,
5828
6050
  capture.runId,
5829
6051
  capture.captureId,
5830
6052
  );
@@ -5854,6 +6076,7 @@ export class FlexHarness<TScope = unknown> {
5854
6076
  scope,
5855
6077
  storageKey,
5856
6078
  sessionId,
6079
+ sessionGeneration,
5857
6080
  capture.runId,
5858
6081
  capture.captureId,
5859
6082
  );
@@ -5863,6 +6086,7 @@ export class FlexHarness<TScope = unknown> {
5863
6086
  scope,
5864
6087
  storageKey,
5865
6088
  sessionId,
6089
+ sessionGeneration,
5866
6090
  capture.runId,
5867
6091
  capture.captureId,
5868
6092
  );
@@ -5928,6 +6152,7 @@ export class FlexHarness<TScope = unknown> {
5928
6152
  scope,
5929
6153
  storageKey,
5930
6154
  sessionId,
6155
+ ...sessionGeneration,
5931
6156
  runId: release.runId,
5932
6157
  captureId: release.captureId,
5933
6158
  reference: cloneSerializable(release.reference),
@@ -6031,7 +6256,7 @@ export class FlexHarness<TScope = unknown> {
6031
6256
  }
6032
6257
  }
6033
6258
  for (const message of [terminal.userMessage, terminal.assistantMessage]) {
6034
- this.emitEvent(run.scopeId, run.sessionId, {
6259
+ this.emitEvent(run.scopeId, run.stored.session, {
6035
6260
  type: 'message.updated',
6036
6261
  runId: run.runId,
6037
6262
  messageId: message.messageId,
@@ -6049,7 +6274,7 @@ export class FlexHarness<TScope = unknown> {
6049
6274
  const status: IFlexRunFinishedEvent['status'] = error
6050
6275
  ? cancelled ? 'cancelled' : 'failed'
6051
6276
  : 'completed';
6052
- this.emitEvent(run.scopeId, run.sessionId, {
6277
+ this.emitEvent(run.scopeId, run.stored.session, {
6053
6278
  type: 'run.finished',
6054
6279
  runId: run.runId,
6055
6280
  messageId: run.assistantMessageId,
@@ -6307,6 +6532,7 @@ export class FlexHarness<TScope = unknown> {
6307
6532
  permissionId: plugins.crypto.randomUUID(),
6308
6533
  scopeId: run.scopeId,
6309
6534
  sessionId: run.sessionId,
6535
+ ...requireSessionGeneration(run.stored.session),
6310
6536
  runId: run.runId,
6311
6537
  kind: input.kind,
6312
6538
  description: truncateUtf8(input.description, maxTransferMetadataBytes),
@@ -6355,7 +6581,7 @@ export class FlexHarness<TScope = unknown> {
6355
6581
  return promise;
6356
6582
  }
6357
6583
  if (!pending.settled) {
6358
- this.emitEvent(run.scopeId, run.sessionId, {
6584
+ this.emitEvent(run.scopeId, run.stored.session, {
6359
6585
  type: 'permission.requested',
6360
6586
  runId: run.runId,
6361
6587
  request: publicSnapshot(request),
@@ -6456,7 +6682,7 @@ export class FlexHarness<TScope = unknown> {
6456
6682
  state.pendingPermissions.delete(pending.request.permissionId);
6457
6683
  pending.run.pendingPermissionIds.delete(pending.request.permissionId);
6458
6684
  pending.run.controller.signal.removeEventListener('abort', pending.abortListener);
6459
- this.emitEvent(pending.request.scopeId, pending.request.sessionId, {
6685
+ this.emitEvent(pending.request.scopeId, pending.run.stored.session, {
6460
6686
  type: 'permission.resolved',
6461
6687
  runId: pending.request.runId,
6462
6688
  request: publicSnapshot(pending.request),
@@ -6707,6 +6933,7 @@ export class FlexHarness<TScope = unknown> {
6707
6933
  scopeId: context.scopeId,
6708
6934
  scope: context.scope,
6709
6935
  sessionId: context.sessionId,
6936
+ ...requireSessionGeneration(run.stored.session),
6710
6937
  runId: context.runId,
6711
6938
  ...(context.parentSessionId === undefined ? {} : { parentSessionId: context.parentSessionId }),
6712
6939
  ...(context.agent === undefined ? {} : { agent: context.agent }),
@@ -7116,7 +7343,7 @@ export class FlexHarness<TScope = unknown> {
7116
7343
  await this.releaseDeletedSessionReversions(
7117
7344
  state,
7118
7345
  storageKey,
7119
- tombstone.sessionId,
7346
+ tombstone,
7120
7347
  scopeId,
7121
7348
  scope,
7122
7349
  );
@@ -7690,93 +7917,112 @@ export class FlexHarness<TScope = unknown> {
7690
7917
  };
7691
7918
  }
7692
7919
 
7920
+ private withScopeMutationOwnership<TValue>(
7921
+ state: IStorageState,
7922
+ operation: () => TValue | Promise<TValue>,
7923
+ ): Promise<TValue> {
7924
+ const owned = state.scopeQueue.then(async (): Promise<TValue> => {
7925
+ this.assertStateAcceptingWork(state);
7926
+ return operation();
7927
+ });
7928
+ state.scopeQueue = owned.then(() => undefined, () => undefined);
7929
+ return owned;
7930
+ }
7931
+
7693
7932
  private mutateScope<TValue>(
7694
7933
  state: IStorageState,
7695
7934
  mutation: () => TValue,
7696
7935
  allowDuringDrain = false,
7697
7936
  ): Promise<TValue> {
7698
- const operation = state.scopeQueue.then(async () => {
7937
+ const operation = state.scopeQueue.then(() => {
7699
7938
  if (!allowDuringDrain) this.assertStateAcceptingWork(state);
7700
- const beforeRevision = state.revision;
7701
- const beforeSessions = new Map([...state.sessions].map(([id, stored]) => [id, {
7702
- stored,
7703
- metadata: cloneSerializable(stored.session),
7704
- }]));
7705
- const beforeTombstones = new Map([...state.tombstones].map(([id, value]) => [id, cloneSerializable(value)]));
7706
- const beforeRetainedCleanups = new Map(state.retainedSessionCleanups);
7707
- const beforeSnapshot: IFlexScopeSnapshot = {
7708
- schemaVersion: 1,
7709
- revision: beforeRevision,
7710
- sessions: [...beforeSessions.values()]
7711
- .map((entry) => cloneSerializable(entry.metadata))
7712
- .sort((left, right) => left.sessionId.localeCompare(right.sessionId)),
7713
- tombstones: [...beforeTombstones.values()]
7714
- .map((value) => cloneSerializable(value))
7715
- .sort((left, right) => left.sessionId.localeCompare(right.sessionId)),
7716
- };
7717
- const restore = () => {
7718
- state.revision = beforeRevision;
7719
- state.sessions.clear();
7720
- for (const [id, entry] of beforeSessions) {
7721
- entry.stored.session = entry.metadata;
7722
- state.sessions.set(id, entry.stored);
7723
- }
7724
- state.tombstones.clear();
7725
- for (const [id, value] of beforeTombstones) state.tombstones.set(id, value);
7726
- state.retainedSessionCleanups.clear();
7727
- for (const [id, retained] of beforeRetainedCleanups) {
7728
- state.retainedSessionCleanups.set(id, retained);
7729
- }
7730
- };
7731
- let result: TValue;
7732
- try {
7733
- result = mutation();
7734
- } catch (error) {
7939
+ return this.mutateScopeOwned(state, mutation);
7940
+ });
7941
+ state.scopeQueue = operation.then(() => undefined, () => undefined);
7942
+ return operation;
7943
+ }
7944
+
7945
+ private async mutateScopeOwned<TValue>(
7946
+ state: IStorageState,
7947
+ mutation: () => TValue,
7948
+ ): Promise<TValue> {
7949
+ const beforeRevision = state.revision;
7950
+ const beforeSessions = new Map([...state.sessions].map(([id, stored]) => [id, {
7951
+ stored,
7952
+ metadata: cloneSerializable(stored.session),
7953
+ }]));
7954
+ const beforeTombstones = new Map([...state.tombstones].map(([id, value]) => [id, cloneSerializable(value)]));
7955
+ const beforeRetainedCleanups = new Map(state.retainedSessionCleanups);
7956
+ const beforeSnapshot: IFlexScopeSnapshot = {
7957
+ schemaVersion: 1,
7958
+ revision: beforeRevision,
7959
+ sessions: [...beforeSessions.values()]
7960
+ .map((entry) => cloneSerializable(entry.metadata))
7961
+ .sort((left, right) => left.sessionId.localeCompare(right.sessionId)),
7962
+ tombstones: [...beforeTombstones.values()]
7963
+ .map((value) => cloneSerializable(value))
7964
+ .sort((left, right) => left.sessionId.localeCompare(right.sessionId)),
7965
+ };
7966
+ const restore = () => {
7967
+ state.revision = beforeRevision;
7968
+ state.sessions.clear();
7969
+ for (const [id, entry] of beforeSessions) {
7970
+ entry.stored.session = entry.metadata;
7971
+ state.sessions.set(id, entry.stored);
7972
+ }
7973
+ state.tombstones.clear();
7974
+ for (const [id, value] of beforeTombstones) state.tombstones.set(id, value);
7975
+ state.retainedSessionCleanups.clear();
7976
+ for (const [id, retained] of beforeRetainedCleanups) {
7977
+ state.retainedSessionCleanups.set(id, retained);
7978
+ }
7979
+ };
7980
+ let result: TValue;
7981
+ try {
7982
+ result = mutation();
7983
+ } catch (error) {
7984
+ restore();
7985
+ throw error;
7986
+ }
7987
+ const snapshot = this.createScopeSnapshot(state, beforeRevision + 1);
7988
+ try {
7989
+ await this.stores.scopes.save(state.storageKey, snapshot, beforeRevision);
7990
+ state.revision = snapshot.revision;
7991
+ return result;
7992
+ } catch (error) {
7993
+ if (error instanceof FlexHarnessStoreConflictError) {
7735
7994
  restore();
7736
7995
  throw error;
7737
7996
  }
7738
- const snapshot = this.createScopeSnapshot(state, beforeRevision + 1);
7739
- try {
7740
- await this.stores.scopes.save(state.storageKey, snapshot, beforeRevision);
7741
- state.revision = snapshot.revision;
7742
- return result;
7743
- } catch (error) {
7744
- if (error instanceof FlexHarnessStoreConflictError) {
7745
- restore();
7746
- throw error;
7747
- }
7748
- if (error instanceof FlexHarnessStoreCommitUncertainError) {
7749
- state.revision = snapshot.revision;
7750
- state.lifecycle = 'fenced';
7751
- throw error;
7752
- }
7753
- let current: IFlexScopeSnapshot | undefined;
7754
- let reconciliationError: unknown;
7755
- try {
7756
- current = await this.stores.scopes.load(state.storageKey);
7757
- } catch (loadError) {
7758
- reconciliationError = loadError;
7759
- }
7760
- if (current && JSON.stringify(current) === JSON.stringify(snapshot)) {
7761
- state.revision = snapshot.revision;
7762
- throw error;
7763
- }
7764
- if (
7765
- (current === undefined && beforeRevision === 0)
7766
- || (current !== undefined && JSON.stringify(current) === JSON.stringify(beforeSnapshot))
7767
- ) {
7768
- restore();
7769
- throw error;
7770
- }
7997
+ if (error instanceof FlexHarnessStoreCommitUncertainError) {
7771
7998
  state.revision = snapshot.revision;
7772
7999
  state.lifecycle = 'fenced';
7773
- throw reconciliationError === undefined
7774
- ? error
7775
- : combineErrors([error, reconciliationError]);
8000
+ throw error;
7776
8001
  }
7777
- });
7778
- state.scopeQueue = operation.then(() => undefined, () => undefined);
7779
- return operation;
8002
+ let current: IFlexScopeSnapshot | undefined;
8003
+ let reconciliationError: unknown;
8004
+ try {
8005
+ current = await this.stores.scopes.load(state.storageKey);
8006
+ } catch (loadError) {
8007
+ reconciliationError = loadError;
8008
+ }
8009
+ if (current && JSON.stringify(current) === JSON.stringify(snapshot)) {
8010
+ state.revision = snapshot.revision;
8011
+ throw error;
8012
+ }
8013
+ if (
8014
+ (current === undefined && beforeRevision === 0)
8015
+ || (current !== undefined && JSON.stringify(current) === JSON.stringify(beforeSnapshot))
8016
+ ) {
8017
+ restore();
8018
+ throw error;
8019
+ }
8020
+ state.revision = snapshot.revision;
8021
+ state.lifecycle = 'fenced';
8022
+ throw reconciliationError === undefined
8023
+ ? error
8024
+ : combineErrors([error, reconciliationError]);
8025
+ }
7780
8026
  }
7781
8027
 
7782
8028
  private mutateProjection<TValue>(
@@ -8210,6 +8456,14 @@ export class FlexHarness<TScope = unknown> {
8210
8456
  || left.sessionId.localeCompare(right.sessionId));
8211
8457
  }
8212
8458
 
8459
+ private tombstonesForRoots(
8460
+ state: IStorageState,
8461
+ rootSessionIds: readonly string[],
8462
+ ): IFlexSessionTombstone[] {
8463
+ return [...new Set(rootSessionIds)]
8464
+ .flatMap((rootSessionId) => this.tombstoneGroup(state, rootSessionId));
8465
+ }
8466
+
8213
8467
  private descendantTombstoneRoots(
8214
8468
  state: IStorageState,
8215
8469
  ancestorSessionId: string,
@@ -8362,7 +8616,7 @@ export class FlexHarness<TScope = unknown> {
8362
8616
  await this.releaseDeletedSessionReversions(
8363
8617
  state,
8364
8618
  state.storageKey,
8365
- sessionId,
8619
+ tombstone,
8366
8620
  releaseContext.scopeId,
8367
8621
  releaseContext.scope,
8368
8622
  retained?.stored,
@@ -9390,7 +9644,7 @@ export class FlexHarness<TScope = unknown> {
9390
9644
  part: TFlexMessagePart,
9391
9645
  ): void {
9392
9646
  const { messageIndex, partIndex } = this.partEventCoordinates(run, part.partId);
9393
- this.emitEvent(run.scopeId, run.sessionId, {
9647
+ this.emitEvent(run.scopeId, run.stored.session, {
9394
9648
  type,
9395
9649
  runId: run.runId,
9396
9650
  messageId: run.assistantMessageId,
@@ -9408,7 +9662,7 @@ export class FlexHarness<TScope = unknown> {
9408
9662
  accounting: { baseTextUtf8Bytes: number; textUtf8Bytes: number },
9409
9663
  ): void {
9410
9664
  const { messageIndex, partIndex } = this.partEventCoordinates(run, part.partId);
9411
- this.emitEvent(run.scopeId, run.sessionId, {
9665
+ this.emitEvent(run.scopeId, run.stored.session, {
9412
9666
  type: 'part.delta',
9413
9667
  runId: run.runId,
9414
9668
  messageId: run.assistantMessageId,
@@ -9438,14 +9692,16 @@ export class FlexHarness<TScope = unknown> {
9438
9692
  return { messageIndex, partIndex };
9439
9693
  }
9440
9694
 
9441
- private emitEvent(scopeId: string, sessionId: string, details: TEventDetails): void {
9695
+ private emitEvent(scopeId: string, sourceSession: IFlexSession, details: TEventDetails): void {
9696
+ const generation = requireSessionGeneration(sourceSession);
9442
9697
  const event = deepFreeze(cloneSerializable({
9443
9698
  eventId: plugins.crypto.randomUUID(),
9444
9699
  sequence: ++this.sequence,
9445
9700
  timestamp: new Date().toISOString(),
9446
9701
  scopeId,
9447
- sessionId,
9448
9702
  ...details,
9703
+ sessionId: sourceSession.sessionId,
9704
+ ...generation,
9449
9705
  })) as TFlexHarnessEvent;
9450
9706
  for (const listener of this.listeners) {
9451
9707
  try {