@modelprofile.com/flexharness 5.1.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,
@@ -75,6 +77,7 @@ import type {
75
77
  IFlexScopeSnapshot,
76
78
  IFlexSession,
77
79
  IFlexSessionGeneration,
80
+ IFlexSessionGenerationCohortEntry,
78
81
  IFlexSessionReversionGroup,
79
82
  IFlexSessionReversionInfo,
80
83
  IFlexSessionTombstone,
@@ -174,11 +177,13 @@ import {
174
177
  import { wrapToolSet } from './utils.tooloutput.js';
175
178
  import {
176
179
  validateCreateProjectTaskInput,
180
+ validateDeleteSessionGenerationCohortInput,
177
181
  validateIdentifier,
178
182
  validateProjectTaskId,
179
183
  validateProjectedErrorInfo,
180
184
  validatePromptOptions,
181
185
  validateSlashCommandExecutionOptions,
186
+ validateSessionGenerationId,
182
187
  validateUpdateProjectTaskInput,
183
188
  validateUpdateSessionOptions,
184
189
  validateUtf8String,
@@ -439,6 +444,16 @@ interface IFlexSubagentAcquisition {
439
444
  created: boolean;
440
445
  }
441
446
 
447
+ interface ISessionDeletionPlan {
448
+ matched: boolean;
449
+ deletion?: Promise<void>;
450
+ }
451
+
452
+ interface IReservedSessionDeletion {
453
+ deletedSessions: IFlexSession[];
454
+ descendantRoots: string[];
455
+ }
456
+
442
457
  type TProjectTaskToolInput =
443
458
  | { action: 'list' }
444
459
  | {
@@ -534,6 +549,18 @@ function requireSessionGeneration(
534
549
  };
535
550
  }
536
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
+
537
564
  function sha256Hex(value: string): string {
538
565
  return plugins.crypto.createHash('sha256').update(value, 'utf8').digest('hex');
539
566
  }
@@ -727,8 +754,14 @@ export class FlexHarness<TScope = unknown> {
727
754
  if (!options || typeof options !== 'object' || Array.isArray(options)) {
728
755
  throw new FlexHarnessValidationError('createSession options must be a plain object.');
729
756
  }
730
- 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
+ );
731
760
  if (unsupported) throw new FlexHarnessValidationError(`createSession does not support "${unsupported}".`);
761
+ const requestedSessionGenerationId = options.sessionGenerationId;
762
+ if (requestedSessionGenerationId !== undefined) {
763
+ validateSessionGenerationId(requestedSessionGenerationId);
764
+ }
732
765
  const resolved = await this.resolveState(scopeId);
733
766
  const sessionId = options.sessionId ?? plugins.crypto.randomUUID();
734
767
  validateIdentifier(sessionId, 'sessionId');
@@ -775,7 +808,7 @@ export class FlexHarness<TScope = unknown> {
775
808
  metadata = {
776
809
  scopeId,
777
810
  sessionId,
778
- sessionGenerationId: createSessionGenerationId(),
811
+ sessionGenerationId: requestedSessionGenerationId ?? createSessionGenerationId(),
779
812
  sessionGenerationSequence: resolved.state.revision + 1,
780
813
  ...(options.title ? { title: options.title } : {}),
781
814
  createdAt: timestamp,
@@ -878,7 +911,7 @@ export class FlexHarness<TScope = unknown> {
878
911
  completeInitialization();
879
912
  }
880
913
  const result = publicSnapshot(metadata);
881
- this.emitEvent(scopeId, sessionId, { type: 'session.created', session: result });
914
+ this.emitEvent(scopeId, metadata, { type: 'session.created', session: result });
882
915
  return result;
883
916
  }
884
917
 
@@ -946,7 +979,7 @@ export class FlexHarness<TScope = unknown> {
946
979
  stored.session.updatedAt = timestamp;
947
980
  result = publicSnapshot(stored.session);
948
981
  });
949
- this.emitEvent(scopeId, sessionId, { type: 'session.updated', session: result });
982
+ this.emitEvent(scopeId, result, { type: 'session.updated', session: result });
950
983
  return result;
951
984
  }
952
985
 
@@ -1125,17 +1158,136 @@ export class FlexHarness<TScope = unknown> {
1125
1158
  );
1126
1159
  }
1127
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
+
1128
1239
  public async deleteSession(scopeId: string, sessionId: string): Promise<void> {
1129
1240
  this.assertNoAmbientSlashCommandTeardown({ scopeId, sessionId });
1130
1241
  const { scope, state } = await this.resolveState(scopeId);
1131
1242
  this.assertNoAmbientSlashCommandTeardown({ storageKey: state.storageKey });
1132
1243
  validateIdentifier(sessionId, 'sessionId');
1133
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> {
1134
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> {
1135
1287
  const existingDeletion = state.sessionDeletions.get(deletionKey);
1136
1288
  if (existingDeletion) return existingDeletion;
1137
1289
  let deletion!: Promise<void>;
1138
- deletion = this.deleteSessionInternal(state, scopeId, scope.scope, sessionId).finally(() => {
1290
+ deletion = operation().finally(() => {
1139
1291
  if (state.sessionDeletions.get(deletionKey) === deletion) {
1140
1292
  state.sessionDeletions.delete(deletionKey);
1141
1293
  }
@@ -1144,6 +1296,60 @@ export class FlexHarness<TScope = unknown> {
1144
1296
  return deletion;
1145
1297
  }
1146
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
+
1147
1353
  private async deleteSessionInternal(
1148
1354
  state: IStorageState,
1149
1355
  scopeId: string,
@@ -1173,36 +1379,28 @@ export class FlexHarness<TScope = unknown> {
1173
1379
  return;
1174
1380
  }
1175
1381
  const stored = this.requireSession(state, sessionId);
1176
- let deletedSessions: IFlexSession[] = [];
1177
- let descendantRoots: string[] = [];
1178
- await this.mutateScope(state, () => {
1179
- if (state.sessions.get(sessionId) !== stored) {
1180
- throw new FlexHarnessNotFoundError('Session', sessionId);
1181
- }
1382
+ const reservation = await this.mutateScope(state, () => {
1182
1383
  const subtree = this.collectSessionSubtree(state, sessionId);
1183
- descendantRoots = this.descendantTombstoneRoots(state, sessionId);
1184
- const rootDepth = stored.session.depth ?? 0;
1185
- const deletedAt = new Date().toISOString();
1186
- deletedSessions = subtree.map((entry) => cloneSerializable(entry.session));
1187
- for (const entry of subtree) {
1188
- const entrySessionId = entry.session.sessionId;
1189
- state.sessions.delete(entrySessionId);
1190
- state.retainedSessionCleanups.set(entrySessionId, {
1191
- stored: entry,
1192
- domainsCompleted: false,
1193
- });
1194
- state.tombstones.set(entrySessionId, {
1195
- sessionId: entrySessionId,
1196
- ...requireSessionGeneration(entry.session),
1197
- deletedAt,
1198
- rootSessionId: sessionId,
1199
- depth: (entry.session.depth ?? rootDepth) - rootDepth,
1200
- ...(entry.session.parentSessionId === undefined
1201
- ? {}
1202
- : { parentSessionId: entry.session.parentSessionId }),
1203
- });
1204
- }
1384
+ const descendantRoots = this.descendantTombstoneRoots(state, sessionId);
1385
+ return this.reserveLiveSessionDeletion(
1386
+ state,
1387
+ sessionId,
1388
+ stored,
1389
+ subtree,
1390
+ descendantRoots,
1391
+ );
1205
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;
1206
1404
  const reason = this.trustInternalError(new FlexHarnessAbortError('The session subtree was deleted.'));
1207
1405
  const slashCommandErrors: unknown[] = [];
1208
1406
  for (const deleted of deletedSessions) {
@@ -1251,7 +1449,7 @@ export class FlexHarness<TScope = unknown> {
1251
1449
  throw this.projectOperationError(error, 'persistence', scopeId, sessionId, 'session-delete');
1252
1450
  }
1253
1451
  for (const deleted of childFirstDeletedSessions) {
1254
- this.emitEvent(scopeId, deleted.sessionId, {
1452
+ this.emitEvent(scopeId, deleted, {
1255
1453
  type: 'session.deleted',
1256
1454
  session: publicSnapshot(deleted),
1257
1455
  });
@@ -2416,7 +2614,7 @@ export class FlexHarness<TScope = unknown> {
2416
2614
  .find((segment) => segment?.status === 'completed');
2417
2615
  if (!target) throw new FlexHarnessValidationError('Pending reversion has no target candidate.');
2418
2616
  await this.resumePendingApply(state, stored, scopeId, scope, operationSignal);
2419
- this.emitEvent(scopeId, sessionId, {
2617
+ this.emitEvent(scopeId, stored.session, {
2420
2618
  type: 'session.history.changed',
2421
2619
  direction,
2422
2620
  runId: target.runId,
@@ -2467,7 +2665,7 @@ export class FlexHarness<TScope = unknown> {
2467
2665
  scope,
2468
2666
  operationSignal,
2469
2667
  );
2470
- this.emitEvent(scopeId, sessionId, {
2668
+ this.emitEvent(scopeId, stored.session, {
2471
2669
  type: 'session.history.changed',
2472
2670
  direction,
2473
2671
  runId: unit.target.runId,
@@ -2765,7 +2963,7 @@ export class FlexHarness<TScope = unknown> {
2765
2963
  throw this.projectOperationError(error, 'persistence', scopeId, sessionId, 'archive-horizon');
2766
2964
  }
2767
2965
  if (branchCommitted) {
2768
- this.emitEvent(scopeId, sessionId, {
2966
+ this.emitEvent(scopeId, stored.session, {
2769
2967
  type: 'session.history.changed',
2770
2968
  direction: 'branch',
2771
2969
  });
@@ -3231,14 +3429,14 @@ export class FlexHarness<TScope = unknown> {
3231
3429
  }
3232
3430
 
3233
3431
  const runType = run.scheduleKey ? 'run.scheduled' : 'run.started';
3234
- this.emitEvent(run.scopeId, run.sessionId, {
3432
+ this.emitEvent(run.scopeId, run.stored.session, {
3235
3433
  type: runType,
3236
3434
  runId: run.runId,
3237
3435
  messageId: run.userMessageId,
3238
3436
  session: publicSnapshot(run.stored.session),
3239
3437
  });
3240
3438
  for (const message of [run.reservedUserMessage!, run.reservedAssistantMessage!]) {
3241
- this.emitEvent(run.scopeId, run.sessionId, {
3439
+ this.emitEvent(run.scopeId, run.stored.session, {
3242
3440
  type: 'message.created',
3243
3441
  runId: run.runId,
3244
3442
  messageId: message.messageId,
@@ -3307,7 +3505,7 @@ export class FlexHarness<TScope = unknown> {
3307
3505
  type: 'prompt.queued' | 'prompt.started' | 'prompt.running' | 'prompt.finished',
3308
3506
  entry: IFlexPromptQueueEntry = this.projectPromptQueueEntry(queued),
3309
3507
  ): void {
3310
- this.emitEvent(queued.scopeId, queued.sessionId, {
3508
+ this.emitEvent(queued.scopeId, queued.stored.session, {
3311
3509
  type,
3312
3510
  queueId: queued.queueId,
3313
3511
  ...(entry.runId ? { runId: entry.runId } : {}),
@@ -3991,6 +4189,7 @@ export class FlexHarness<TScope = unknown> {
3991
4189
  scopeId: run.scopeId,
3992
4190
  scope: run.scope as TScope,
3993
4191
  sessionId: run.sessionId,
4192
+ ...requireSessionGeneration(run.stored.session),
3994
4193
  runId: run.runId,
3995
4194
  ...(options.modelHint ? { modelHint: options.modelHint } : {}),
3996
4195
  ...resolverRelationship,
@@ -4190,7 +4389,7 @@ export class FlexHarness<TScope = unknown> {
4190
4389
  run.stored.session.updatedAt = new Date().toISOString();
4191
4390
  session = publicSnapshot(run.stored.session);
4192
4391
  });
4193
- this.emitEvent(run.scopeId, run.sessionId, {
4392
+ this.emitEvent(run.scopeId, session, {
4194
4393
  type: 'session.updated',
4195
4394
  session,
4196
4395
  });
@@ -4722,7 +4921,7 @@ export class FlexHarness<TScope = unknown> {
4722
4921
  throw aborted;
4723
4922
  }
4724
4923
  state.sessions.set(sessionId, loaded);
4725
- this.emitEvent(run.scopeId, sessionId, {
4924
+ this.emitEvent(run.scopeId, metadata, {
4726
4925
  type: 'session.created',
4727
4926
  session: publicSnapshot(metadata),
4728
4927
  });
@@ -5531,7 +5730,7 @@ export class FlexHarness<TScope = unknown> {
5531
5730
  return;
5532
5731
  }
5533
5732
  await this.mutateProjection(state, stored, () => this.commitRevertedBranchState(stored), true);
5534
- this.emitEvent(scopeId, stored.session.sessionId, {
5733
+ this.emitEvent(scopeId, stored.session, {
5535
5734
  type: 'session.history.changed',
5536
5735
  direction: 'branch',
5537
5736
  });
@@ -6057,7 +6256,7 @@ export class FlexHarness<TScope = unknown> {
6057
6256
  }
6058
6257
  }
6059
6258
  for (const message of [terminal.userMessage, terminal.assistantMessage]) {
6060
- this.emitEvent(run.scopeId, run.sessionId, {
6259
+ this.emitEvent(run.scopeId, run.stored.session, {
6061
6260
  type: 'message.updated',
6062
6261
  runId: run.runId,
6063
6262
  messageId: message.messageId,
@@ -6075,7 +6274,7 @@ export class FlexHarness<TScope = unknown> {
6075
6274
  const status: IFlexRunFinishedEvent['status'] = error
6076
6275
  ? cancelled ? 'cancelled' : 'failed'
6077
6276
  : 'completed';
6078
- this.emitEvent(run.scopeId, run.sessionId, {
6277
+ this.emitEvent(run.scopeId, run.stored.session, {
6079
6278
  type: 'run.finished',
6080
6279
  runId: run.runId,
6081
6280
  messageId: run.assistantMessageId,
@@ -6333,6 +6532,7 @@ export class FlexHarness<TScope = unknown> {
6333
6532
  permissionId: plugins.crypto.randomUUID(),
6334
6533
  scopeId: run.scopeId,
6335
6534
  sessionId: run.sessionId,
6535
+ ...requireSessionGeneration(run.stored.session),
6336
6536
  runId: run.runId,
6337
6537
  kind: input.kind,
6338
6538
  description: truncateUtf8(input.description, maxTransferMetadataBytes),
@@ -6381,7 +6581,7 @@ export class FlexHarness<TScope = unknown> {
6381
6581
  return promise;
6382
6582
  }
6383
6583
  if (!pending.settled) {
6384
- this.emitEvent(run.scopeId, run.sessionId, {
6584
+ this.emitEvent(run.scopeId, run.stored.session, {
6385
6585
  type: 'permission.requested',
6386
6586
  runId: run.runId,
6387
6587
  request: publicSnapshot(request),
@@ -6482,7 +6682,7 @@ export class FlexHarness<TScope = unknown> {
6482
6682
  state.pendingPermissions.delete(pending.request.permissionId);
6483
6683
  pending.run.pendingPermissionIds.delete(pending.request.permissionId);
6484
6684
  pending.run.controller.signal.removeEventListener('abort', pending.abortListener);
6485
- this.emitEvent(pending.request.scopeId, pending.request.sessionId, {
6685
+ this.emitEvent(pending.request.scopeId, pending.run.stored.session, {
6486
6686
  type: 'permission.resolved',
6487
6687
  runId: pending.request.runId,
6488
6688
  request: publicSnapshot(pending.request),
@@ -7717,93 +7917,112 @@ export class FlexHarness<TScope = unknown> {
7717
7917
  };
7718
7918
  }
7719
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
+
7720
7932
  private mutateScope<TValue>(
7721
7933
  state: IStorageState,
7722
7934
  mutation: () => TValue,
7723
7935
  allowDuringDrain = false,
7724
7936
  ): Promise<TValue> {
7725
- const operation = state.scopeQueue.then(async () => {
7937
+ const operation = state.scopeQueue.then(() => {
7726
7938
  if (!allowDuringDrain) this.assertStateAcceptingWork(state);
7727
- const beforeRevision = state.revision;
7728
- const beforeSessions = new Map([...state.sessions].map(([id, stored]) => [id, {
7729
- stored,
7730
- metadata: cloneSerializable(stored.session),
7731
- }]));
7732
- const beforeTombstones = new Map([...state.tombstones].map(([id, value]) => [id, cloneSerializable(value)]));
7733
- const beforeRetainedCleanups = new Map(state.retainedSessionCleanups);
7734
- const beforeSnapshot: IFlexScopeSnapshot = {
7735
- schemaVersion: 1,
7736
- revision: beforeRevision,
7737
- sessions: [...beforeSessions.values()]
7738
- .map((entry) => cloneSerializable(entry.metadata))
7739
- .sort((left, right) => left.sessionId.localeCompare(right.sessionId)),
7740
- tombstones: [...beforeTombstones.values()]
7741
- .map((value) => cloneSerializable(value))
7742
- .sort((left, right) => left.sessionId.localeCompare(right.sessionId)),
7743
- };
7744
- const restore = () => {
7745
- state.revision = beforeRevision;
7746
- state.sessions.clear();
7747
- for (const [id, entry] of beforeSessions) {
7748
- entry.stored.session = entry.metadata;
7749
- state.sessions.set(id, entry.stored);
7750
- }
7751
- state.tombstones.clear();
7752
- for (const [id, value] of beforeTombstones) state.tombstones.set(id, value);
7753
- state.retainedSessionCleanups.clear();
7754
- for (const [id, retained] of beforeRetainedCleanups) {
7755
- state.retainedSessionCleanups.set(id, retained);
7756
- }
7757
- };
7758
- let result: TValue;
7759
- try {
7760
- result = mutation();
7761
- } 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) {
7762
7994
  restore();
7763
7995
  throw error;
7764
7996
  }
7765
- const snapshot = this.createScopeSnapshot(state, beforeRevision + 1);
7766
- try {
7767
- await this.stores.scopes.save(state.storageKey, snapshot, beforeRevision);
7768
- state.revision = snapshot.revision;
7769
- return result;
7770
- } catch (error) {
7771
- if (error instanceof FlexHarnessStoreConflictError) {
7772
- restore();
7773
- throw error;
7774
- }
7775
- if (error instanceof FlexHarnessStoreCommitUncertainError) {
7776
- state.revision = snapshot.revision;
7777
- state.lifecycle = 'fenced';
7778
- throw error;
7779
- }
7780
- let current: IFlexScopeSnapshot | undefined;
7781
- let reconciliationError: unknown;
7782
- try {
7783
- current = await this.stores.scopes.load(state.storageKey);
7784
- } catch (loadError) {
7785
- reconciliationError = loadError;
7786
- }
7787
- if (current && JSON.stringify(current) === JSON.stringify(snapshot)) {
7788
- state.revision = snapshot.revision;
7789
- throw error;
7790
- }
7791
- if (
7792
- (current === undefined && beforeRevision === 0)
7793
- || (current !== undefined && JSON.stringify(current) === JSON.stringify(beforeSnapshot))
7794
- ) {
7795
- restore();
7796
- throw error;
7797
- }
7997
+ if (error instanceof FlexHarnessStoreCommitUncertainError) {
7798
7998
  state.revision = snapshot.revision;
7799
7999
  state.lifecycle = 'fenced';
7800
- throw reconciliationError === undefined
7801
- ? error
7802
- : combineErrors([error, reconciliationError]);
8000
+ throw error;
7803
8001
  }
7804
- });
7805
- state.scopeQueue = operation.then(() => undefined, () => undefined);
7806
- 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
+ }
7807
8026
  }
7808
8027
 
7809
8028
  private mutateProjection<TValue>(
@@ -8237,6 +8456,14 @@ export class FlexHarness<TScope = unknown> {
8237
8456
  || left.sessionId.localeCompare(right.sessionId));
8238
8457
  }
8239
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
+
8240
8467
  private descendantTombstoneRoots(
8241
8468
  state: IStorageState,
8242
8469
  ancestorSessionId: string,
@@ -9417,7 +9644,7 @@ export class FlexHarness<TScope = unknown> {
9417
9644
  part: TFlexMessagePart,
9418
9645
  ): void {
9419
9646
  const { messageIndex, partIndex } = this.partEventCoordinates(run, part.partId);
9420
- this.emitEvent(run.scopeId, run.sessionId, {
9647
+ this.emitEvent(run.scopeId, run.stored.session, {
9421
9648
  type,
9422
9649
  runId: run.runId,
9423
9650
  messageId: run.assistantMessageId,
@@ -9435,7 +9662,7 @@ export class FlexHarness<TScope = unknown> {
9435
9662
  accounting: { baseTextUtf8Bytes: number; textUtf8Bytes: number },
9436
9663
  ): void {
9437
9664
  const { messageIndex, partIndex } = this.partEventCoordinates(run, part.partId);
9438
- this.emitEvent(run.scopeId, run.sessionId, {
9665
+ this.emitEvent(run.scopeId, run.stored.session, {
9439
9666
  type: 'part.delta',
9440
9667
  runId: run.runId,
9441
9668
  messageId: run.assistantMessageId,
@@ -9465,14 +9692,16 @@ export class FlexHarness<TScope = unknown> {
9465
9692
  return { messageIndex, partIndex };
9466
9693
  }
9467
9694
 
9468
- private emitEvent(scopeId: string, sessionId: string, details: TEventDetails): void {
9695
+ private emitEvent(scopeId: string, sourceSession: IFlexSession, details: TEventDetails): void {
9696
+ const generation = requireSessionGeneration(sourceSession);
9469
9697
  const event = deepFreeze(cloneSerializable({
9470
9698
  eventId: plugins.crypto.randomUUID(),
9471
9699
  sequence: ++this.sequence,
9472
9700
  timestamp: new Date().toISOString(),
9473
9701
  scopeId,
9474
- sessionId,
9475
9702
  ...details,
9703
+ sessionId: sourceSession.sessionId,
9704
+ ...generation,
9476
9705
  })) as TFlexHarnessEvent;
9477
9706
  for (const listener of this.listeners) {
9478
9707
  try {