@modelprofile.com/flexharness 3.6.0 → 3.7.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.
@@ -10,11 +10,18 @@ import {
10
10
  FlexHarnessQueueFullError,
11
11
  FlexHarnessRunError,
12
12
  FlexHarnessSessionBusyError,
13
+ FlexHarnessSlashCommandReentryError,
14
+ FlexHarnessSlashCommandUnavailableError,
13
15
  FlexHarnessStoreConflictError,
14
16
  FlexHarnessStoreCommitUncertainError,
15
17
  FlexHarnessValidationError,
16
18
  errorToInfo,
17
19
  } from './errors.js';
20
+ import {
21
+ FLEX_REVERSION_DEFAULT_LIMITS,
22
+ FLEX_REVERSION_MAXIMUM_LIMITS,
23
+ FLEX_REVERSION_REFERENCE_MAX_BYTES,
24
+ } from './interfaces.js';
18
25
  import type {
19
26
  IFlexAgentContextInvocation,
20
27
  IFlexAgentSessionPolicy,
@@ -41,7 +48,11 @@ import type {
41
48
  IFlexPromptQueueLimits,
42
49
  IFlexPromptOptions,
43
50
  IFlexPromptResult,
44
- IFlexProjectionSnapshot,
51
+ IFlexProjectionSnapshotCurrent,
52
+ IFlexReversionLimits,
53
+ IFlexReversionSegment,
54
+ IFlexPendingReversionRelease,
55
+ IFlexRedoSessionResult,
45
56
  IFlexResolvedModel,
46
57
  IFlexResolvedScope,
47
58
  IFlexResourceToolProviderDescriptor,
@@ -51,12 +62,17 @@ import type {
51
62
  IFlexScopeSnapshot,
52
63
  IFlexSession,
53
64
  IFlexSessionTombstone,
65
+ IFlexSlashCommandDescriptor,
66
+ IFlexSlashCommandExecutionOptions,
67
+ IFlexSlashCommandHandlerRegistration,
68
+ IFlexSlashCommandTemplateRegistration,
54
69
  IFlexSubagentDefinition,
55
70
  IFlexTerminalProjection,
56
71
  IFlexToolHandle,
57
72
  IFlexToolMessagePart,
58
73
  IFlexToolProviderContext,
59
74
  IFlexUncertainToolExecution,
75
+ IFlexUndoSessionResult,
60
76
  IFlexUpdateSessionOptions,
61
77
  IFlexUsage,
62
78
  IJsonObject,
@@ -73,7 +89,12 @@ import type {
73
89
  TFlexPrompt,
74
90
  TFlexPromptPart,
75
91
  TFlexPromptQueueStatus,
92
+ TFlexSlashCommandExecutionResult,
93
+ TFlexSlashCommandRegistration,
94
+ TFlexPendingReversion,
95
+ TFlexProjectionSnapshot,
76
96
  TFlexToolExecutionReconciliation,
97
+ TJsonValue,
77
98
  } from './interfaces.js';
78
99
  import { InMemoryFlexHarnessStores } from './classes.stores.js';
79
100
  import {
@@ -88,6 +109,14 @@ import {
88
109
  normalizeFlexPrompt,
89
110
  type INormalizedFlexPrompt,
90
111
  } from './utils.prompt.js';
112
+ import {
113
+ FLEX_SLASH_COMMAND_INITIALIZE_TEMPLATE,
114
+ FLEX_SLASH_COMMAND_MAX_INPUT_BYTES,
115
+ expandSlashCommandTemplate,
116
+ isValidSlashCommandName,
117
+ parseSlashCommand,
118
+ slashCommandTemplateHints,
119
+ } from './utils.slashcommands.js';
91
120
 
92
121
  type TCanonicalOutcome = 'accepted' | 'rejected' | 'interrupted';
93
122
  type TRunPhase =
@@ -104,6 +133,14 @@ interface IStoredSessionState {
104
133
  session: IFlexSession;
105
134
  messages: IFlexMessage[];
106
135
  stagedTerminals: IFlexTerminalProjection[];
136
+ reversionSegments: IFlexReversionSegment[];
137
+ revertCursor: number;
138
+ excludedRunIds: string[];
139
+ pendingReversion?: TFlexPendingReversion;
140
+ pendingReversionReleases: IFlexPendingReversionRelease[];
141
+ projectionSchemaVersion: 1 | 2;
142
+ projectionBaseline?: TFlexProjectionSnapshot;
143
+ projectionReconciliationRequired: boolean;
107
144
  projectionRevision: number;
108
145
  projectionQueue: Promise<void>;
109
146
  rememberedPermissionKeys: Set<string>;
@@ -151,6 +188,13 @@ interface IOrphanedTombstoneCleanup {
151
188
  completion: Promise<void>;
152
189
  }
153
190
 
191
+ interface IOrphanedTombstoneOwner<TScope> {
192
+ state: IStorageState;
193
+ rootSessionId: string;
194
+ scopeId: string;
195
+ scope: TScope;
196
+ }
197
+
154
198
  interface IOrphanedExecutionContextOwner {
155
199
  storageKey: string;
156
200
  sessionId: string;
@@ -160,6 +204,7 @@ interface IStorageState {
160
204
  storageKey: string;
161
205
  compactorLifecycleController: AbortController;
162
206
  scopeIdHint: string;
207
+ scopeContext: { scopeId: string; scope: unknown };
163
208
  revision: number;
164
209
  sessions: Map<string, IStoredSessionState>;
165
210
  retainedSessionCleanups: Map<string, IRetainedSessionCleanup>;
@@ -261,6 +306,32 @@ interface IScopeAdmissionState {
261
306
  retiring: boolean;
262
307
  }
263
308
 
309
+ type TRegisteredSlashCommand<TScope> =
310
+ | Readonly<IFlexSlashCommandTemplateRegistration>
311
+ | Readonly<IFlexSlashCommandHandlerRegistration<TScope>>;
312
+
313
+ interface IActiveSlashCommandExecution {
314
+ kind: 'handler' | 'operation' | 'prompt-admission';
315
+ storageKey: string;
316
+ sessionId: string;
317
+ controller: AbortController;
318
+ completion: Promise<unknown>;
319
+ }
320
+
321
+ interface IActiveSlashCommandListing {
322
+ storageKey: string;
323
+ sessionId: string;
324
+ controller: AbortController;
325
+ completion: Promise<unknown>;
326
+ }
327
+
328
+ interface ISlashCommandInvocationOwner {
329
+ scopeId: string;
330
+ storageKey: string;
331
+ sessionId: string;
332
+ sessionKey: string;
333
+ }
334
+
264
335
  interface IRunResultProjection {
265
336
  text: string;
266
337
  steps: number;
@@ -342,9 +413,14 @@ const maximumMaxSubagentCallsPerRun = 128;
342
413
  const maxResourceToolProviders = 128;
343
414
  const maxResourceIdBytes = 512;
344
415
  const maxResourceToolNameBytes = 512;
416
+ const maxSlashCommandRegistrations = 128;
417
+ const maxSlashCommandDescriptionBytes = 2048;
418
+ const maxSlashCommandTemplateBytes = FLEX_SLASH_COMMAND_MAX_INPUT_BYTES;
345
419
  const resourceToolStemLength = 16;
346
420
  const repairCancellationMessage = 'The process stopped before this run completed.';
347
421
  const scopeRetirementMessage = 'The scope is being retired.';
422
+ const slashCommandSessionUnavailableReason = 'Session must be idle with no queued prompts.';
423
+ const reservedSlashCommandNames = new Set(['compact', 'undo', 'redo', 'init']);
348
424
  const externalErrorFallback: IFlexErrorInfo = Object.freeze({
349
425
  name: 'FlexHarnessExternalError',
350
426
  message: 'The model operation failed.',
@@ -559,6 +635,89 @@ function normalizeSubagents(
559
635
  return Object.freeze(normalized);
560
636
  }
561
637
 
638
+ function normalizeSlashCommands<TScope>(
639
+ registrations: readonly TFlexSlashCommandRegistration<TScope>[] | undefined,
640
+ ): ReadonlyMap<string, TRegisteredSlashCommand<TScope>> {
641
+ if (registrations === undefined) return new Map();
642
+ if (!Array.isArray(registrations) || registrations.length > maxSlashCommandRegistrations) {
643
+ throw new FlexHarnessValidationError(
644
+ `slashCommands must be an array with at most ${maxSlashCommandRegistrations} registrations.`,
645
+ );
646
+ }
647
+ const normalized = new Map<string, TRegisteredSlashCommand<TScope>>();
648
+ for (let index = 0; index < registrations.length; index++) {
649
+ const registration = registrations[index];
650
+ if (
651
+ !registration
652
+ || typeof registration !== 'object'
653
+ || Array.isArray(registration)
654
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(registration))
655
+ ) {
656
+ throw new FlexHarnessValidationError(`slashCommands[${index}] must be a plain object.`);
657
+ }
658
+ const keys = Object.keys(registration);
659
+ const hasTemplate = Object.prototype.hasOwnProperty.call(registration, 'template');
660
+ const hasHandler = Object.prototype.hasOwnProperty.call(registration, 'handler');
661
+ const supported = hasTemplate
662
+ ? ['name', 'description', 'template']
663
+ : ['name', 'description', 'handler'];
664
+ const unsupported = keys.find((key) => !supported.includes(key));
665
+ if (unsupported || hasTemplate === hasHandler) {
666
+ throw new FlexHarnessValidationError(
667
+ `slashCommands[${index}] must define exactly one of template or handler.`,
668
+ );
669
+ }
670
+ if (typeof registration.name !== 'string' || !isValidSlashCommandName(registration.name)) {
671
+ throw new FlexHarnessValidationError(`slashCommands[${index}].name is invalid.`);
672
+ }
673
+ if (reservedSlashCommandNames.has(registration.name)) {
674
+ throw new FlexHarnessValidationError(
675
+ `Slash command "${registration.name}" is reserved.`,
676
+ );
677
+ }
678
+ if (normalized.has(registration.name)) {
679
+ throw new FlexHarnessValidationError(`Duplicate slash command "${registration.name}".`);
680
+ }
681
+ if (registration.description !== undefined) {
682
+ validateUtf8String(
683
+ registration.description,
684
+ `slashCommands[${index}].description`,
685
+ maxSlashCommandDescriptionBytes,
686
+ true,
687
+ );
688
+ }
689
+ if (hasTemplate) {
690
+ const template = registration.template;
691
+ validateUtf8String(
692
+ template,
693
+ `slashCommands[${index}].template`,
694
+ maxSlashCommandTemplateBytes,
695
+ true,
696
+ );
697
+ normalized.set(registration.name, Object.freeze({
698
+ name: registration.name,
699
+ ...(registration.description === undefined
700
+ ? {}
701
+ : { description: registration.description }),
702
+ template,
703
+ }));
704
+ } else {
705
+ const handler = registration.handler;
706
+ if (typeof handler !== 'function') {
707
+ throw new FlexHarnessValidationError(`slashCommands[${index}].handler must be a function.`);
708
+ }
709
+ normalized.set(registration.name, Object.freeze({
710
+ name: registration.name,
711
+ ...(registration.description === undefined
712
+ ? {}
713
+ : { description: registration.description }),
714
+ handler,
715
+ }));
716
+ }
717
+ }
718
+ return Object.freeze(normalized);
719
+ }
720
+
562
721
  function resolveBoundedPositiveInteger(
563
722
  value: number | undefined,
564
723
  name: string,
@@ -833,6 +992,31 @@ function validatePromptOptions(options: IFlexPromptOptions, scheduled: boolean):
833
992
  }
834
993
  }
835
994
 
995
+ function validateSlashCommandExecutionOptions(
996
+ options: IFlexSlashCommandExecutionOptions,
997
+ ): IFlexPromptOptions {
998
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
999
+ throw new FlexHarnessValidationError('Slash command execution options must be a plain object.');
1000
+ }
1001
+ const unsupported = Object.keys(options)
1002
+ .find((key) => !['modelHint', 'system', 'maxSteps', 'signal'].includes(key));
1003
+ if (unsupported) {
1004
+ throw new FlexHarnessValidationError(
1005
+ `Slash command execution options do not support "${unsupported}".`,
1006
+ );
1007
+ }
1008
+ if (options.signal !== undefined && !(options.signal instanceof AbortSignal)) {
1009
+ throw new FlexHarnessValidationError('Slash command signal must be an AbortSignal.');
1010
+ }
1011
+ const promptOptions: IFlexPromptOptions = {
1012
+ ...(options.modelHint === undefined ? {} : { modelHint: options.modelHint }),
1013
+ ...(options.system === undefined ? {} : { system: options.system }),
1014
+ ...(options.maxSteps === undefined ? {} : { maxSteps: options.maxSteps }),
1015
+ };
1016
+ validatePromptOptions(promptOptions, false);
1017
+ return promptOptions;
1018
+ }
1019
+
836
1020
  function resolveCallbackLimits(limits: IFlexCallbackLimits = {}): Required<IFlexCallbackLimits> {
837
1021
  const resolved = {
838
1022
  maxEvents: limits.maxEvents ?? DEFAULT_CALLBACK_LIMITS.maxEvents,
@@ -870,6 +1054,27 @@ function resolvePromptQueueLimits(
870
1054
  return resolved;
871
1055
  }
872
1056
 
1057
+ function resolveReversionLimits(
1058
+ limits: IFlexReversionLimits = {},
1059
+ ): Required<IFlexReversionLimits> {
1060
+ const resolved = {
1061
+ maxCompletedTurns: limits.maxCompletedTurns ?? FLEX_REVERSION_DEFAULT_LIMITS.maxCompletedTurns,
1062
+ maxSegments: limits.maxSegments ?? FLEX_REVERSION_DEFAULT_LIMITS.maxSegments,
1063
+ maxExcludedRunIds: limits.maxExcludedRunIds ?? FLEX_REVERSION_DEFAULT_LIMITS.maxExcludedRunIds,
1064
+ maxPendingReversionReleases: limits.maxPendingReversionReleases
1065
+ ?? FLEX_REVERSION_DEFAULT_LIMITS.maxPendingReversionReleases,
1066
+ };
1067
+ for (const [name, value] of Object.entries(resolved)) {
1068
+ const maximum = FLEX_REVERSION_MAXIMUM_LIMITS[name as keyof IFlexReversionLimits];
1069
+ if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
1070
+ throw new FlexHarnessValidationError(
1071
+ `reversionLimits.${name} must be an integer from 1 through ${maximum}.`,
1072
+ );
1073
+ }
1074
+ }
1075
+ return resolved;
1076
+ }
1077
+
873
1078
  function normalizeAgentSessionPolicy<TScope>(
874
1079
  policy: IFlexAgentSessionPolicy<TScope> = {},
875
1080
  ): IFlexAgentSessionPolicy<TScope> {
@@ -906,8 +1111,11 @@ export class FlexHarness<TScope = unknown> {
906
1111
  private readonly toolOutputLimits: Required<NonNullable<IFlexHarnessOptions<TScope>['toolOutputLimits']>>;
907
1112
  private readonly callbackLimits: Required<IFlexCallbackLimits>;
908
1113
  private readonly promptQueueLimits: Required<IFlexPromptQueueLimits>;
1114
+ private readonly reversionLimits: Required<IFlexReversionLimits>;
1115
+ private readonly turnReversionProvider: IFlexHarnessOptions<TScope>['turnReversionProvider'];
909
1116
  private readonly externalErrorProjector?: TFlexExternalErrorProjector;
910
1117
  private readonly subagents: ReadonlyMap<string, Readonly<IFlexSubagentDefinition>>;
1118
+ private readonly slashCommands: ReadonlyMap<string, TRegisteredSlashCommand<TScope>>;
911
1119
  private readonly maxSubagentDepth: number;
912
1120
  private readonly maxSubagentCallsPerRun: number;
913
1121
  private readonly stateLoads = new Map<string, Promise<IStorageState>>();
@@ -925,10 +1133,16 @@ export class FlexHarness<TScope = unknown> {
925
1133
  >();
926
1134
  private readonly orphanedProviderReleases = new Map<string, IOrphanedProviderRelease>();
927
1135
  private readonly orphanedTombstoneCleanups = new Map<string, IOrphanedTombstoneCleanup>();
1136
+ private readonly orphanedTombstoneOwners = new Map<string, IOrphanedTombstoneOwner<TScope>>();
928
1137
  private readonly pendingPromptAdmissionOwners = new Set<IPendingPromptAdmission>();
1138
+ private readonly activeSlashCommandExecutions = new Map<string, IActiveSlashCommandExecution>();
1139
+ private readonly activeSlashCommandListings = new Set<IActiveSlashCommandListing>();
929
1140
  private readonly compactorInvocationContext = new plugins.AsyncLocalStorage<
930
1141
  IFlexAgentContextInvocation<TScope>
931
1142
  >();
1143
+ private readonly slashCommandInvocationContext = new plugins.AsyncLocalStorage<
1144
+ ISlashCommandInvocationOwner
1145
+ >();
932
1146
  private readonly deferredCompactorContexts = new WeakMap<
933
1147
  IFlexAgentContextInvocation<unknown>,
934
1148
  IFlexAgentContextInvocation<unknown>
@@ -954,8 +1168,11 @@ export class FlexHarness<TScope = unknown> {
954
1168
  this.toolOutputLimits = resolveJsonLimits(options.toolOutputLimits);
955
1169
  this.callbackLimits = resolveCallbackLimits(options.callbackLimits);
956
1170
  this.promptQueueLimits = resolvePromptQueueLimits(options.promptQueueLimits);
1171
+ this.reversionLimits = resolveReversionLimits(options.reversionLimits);
1172
+ this.turnReversionProvider = options.turnReversionProvider;
957
1173
  this.externalErrorProjector = options.externalErrorProjector;
958
1174
  this.subagents = normalizeSubagents(options.subagents);
1175
+ this.slashCommands = normalizeSlashCommands(options.slashCommands);
959
1176
  this.maxSubagentDepth = resolveBoundedPositiveInteger(
960
1177
  options.maxSubagentDepth,
961
1178
  'maxSubagentDepth',
@@ -973,6 +1190,7 @@ export class FlexHarness<TScope = unknown> {
973
1190
  public async listSessions(scopeId: string): Promise<IFlexSession[]> {
974
1191
  const { state } = await this.resolveState(scopeId);
975
1192
  await state.scopeQueue;
1193
+ this.assertOpen();
976
1194
  this.assertStateAcceptingWork(state);
977
1195
  return publicSnapshot([...state.sessions.values()]
978
1196
  .filter((stored) => !state.initializingSessions.has(stored.session.sessionId))
@@ -1166,7 +1384,14 @@ export class FlexHarness<TScope = unknown> {
1166
1384
  }
1167
1385
 
1168
1386
  public async deleteSession(scopeId: string, sessionId: string): Promise<void> {
1387
+ const invocationOwner = this.slashCommandInvocationContext.getStore();
1388
+ if (invocationOwner?.scopeId === scopeId && invocationOwner.sessionId === sessionId) {
1389
+ throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(sessionId));
1390
+ }
1169
1391
  const { scope, state } = await this.resolveState(scopeId);
1392
+ if (invocationOwner?.storageKey === state.storageKey) {
1393
+ throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(invocationOwner.sessionId));
1394
+ }
1170
1395
  validateIdentifier(sessionId, 'sessionId');
1171
1396
  await state.scopeQueue;
1172
1397
  const deletionKey = state.tombstones.get(sessionId)?.rootSessionId ?? sessionId;
@@ -1241,6 +1466,21 @@ export class FlexHarness<TScope = unknown> {
1241
1466
  }
1242
1467
  });
1243
1468
  const reason = this.trustInternalError(new FlexHarnessAbortError('The session subtree was deleted.'));
1469
+ const slashCommandErrors: unknown[] = [];
1470
+ for (const deleted of deletedSessions) {
1471
+ const listingSettlement = this.abortSlashCommandListings(
1472
+ state.storageKey,
1473
+ reason,
1474
+ deleted.sessionId,
1475
+ );
1476
+ if (listingSettlement) await listingSettlement;
1477
+ await this.abortSlashCommandExecutions(
1478
+ state.storageKey,
1479
+ reason,
1480
+ slashCommandErrors,
1481
+ deleted.sessionId,
1482
+ );
1483
+ }
1244
1484
  const childFirstDeletedSessions = [...deletedSessions].sort((left, right) =>
1245
1485
  (right.depth ?? 0) - (left.depth ?? 0)
1246
1486
  || left.sessionId.localeCompare(right.sessionId));
@@ -1255,7 +1495,10 @@ export class FlexHarness<TScope = unknown> {
1255
1495
  }
1256
1496
  }
1257
1497
  try {
1258
- const orphanErrors = await this.closeOrphanedResources(state.storageKey);
1498
+ const orphanErrors = [
1499
+ ...slashCommandErrors,
1500
+ ...await this.closeOrphanedResources(state.storageKey),
1501
+ ];
1259
1502
  if (orphanErrors.length > 0) throw combineErrors(orphanErrors);
1260
1503
  for (const descendantRoot of descendantRoots) {
1261
1504
  await this.finishTombstoneCleanup(
@@ -1282,7 +1525,7 @@ export class FlexHarness<TScope = unknown> {
1282
1525
  const stored = this.requireSession(state, sessionId);
1283
1526
  await stored.projectionQueue;
1284
1527
  this.assertStateAcceptingWork(state);
1285
- return publicSnapshot(stored.messages);
1528
+ return publicSnapshot(this.visibleMessages(stored));
1286
1529
  }
1287
1530
 
1288
1531
  public async listMessagePage(
@@ -1312,20 +1555,21 @@ export class FlexHarness<TScope = unknown> {
1312
1555
  await stored.projectionQueue;
1313
1556
  this.assertStateAcceptingWork(state);
1314
1557
  const namespace = this.messageCursorNamespace(state.storageKey);
1315
- let end = stored.messages.length;
1558
+ const visibleMessages = this.visibleMessages(stored);
1559
+ let end = visibleMessages.length;
1316
1560
  if (options.before !== undefined) {
1317
1561
  const cursor = this.parseMessageCursor(options.before);
1318
1562
  if (cursor.namespace !== namespace || cursor.sessionId !== sessionId) {
1319
1563
  throw new FlexHarnessValidationError('Message page cursor is invalid.');
1320
1564
  }
1321
- const anchor = stored.messages.findIndex((message) => message.messageId === cursor.anchorMessageId);
1565
+ const anchor = visibleMessages.findIndex((message) => message.messageId === cursor.anchorMessageId);
1322
1566
  if (anchor < 0) throw new FlexHarnessValidationError('Message page cursor is stale.');
1323
1567
  end = anchor;
1324
1568
  }
1325
1569
  let start = end;
1326
1570
  let messages: IFlexMessage[] = [];
1327
1571
  for (let index = end - 1; index >= 0 && messages.length < limit; index--) {
1328
- const candidate = createBoundedTransferMessage(stored.messages[index]);
1572
+ const candidate = createBoundedTransferMessage(visibleMessages[index]);
1329
1573
  const candidateMessages = [candidate, ...messages];
1330
1574
  const nextCursor = index > 0
1331
1575
  ? this.createMessageCursor(namespace, sessionId, candidate.messageId)
@@ -1357,110 +1601,638 @@ export class FlexHarness<TScope = unknown> {
1357
1601
  return publicSnapshot(createBoundedTransferMessage(this.requireMessage(stored, messageId)));
1358
1602
  }
1359
1603
 
1360
- public async prompt(
1361
- scopeId: string,
1362
- sessionId: string,
1363
- prompt: TFlexPrompt,
1364
- options: IFlexPromptOptions = {},
1365
- ): Promise<IFlexPromptResult> {
1366
- const admission = await this.startPrompt(scopeId, sessionId, prompt, options);
1367
- return admission.completion;
1368
- }
1369
-
1370
- public async startPrompt(
1604
+ public async listSlashCommands(
1371
1605
  scopeId: string,
1372
1606
  sessionId: string,
1373
- prompt: TFlexPrompt,
1374
- options: IFlexPromptOptions = {},
1375
- ): Promise<IFlexPromptAdmission> {
1376
- validatePromptOptions(options, false);
1377
- const queued = await this.enqueuePromptInternal(scopeId, sessionId, prompt, options);
1378
- return queued.started;
1607
+ ): Promise<IFlexSlashCommandDescriptor[]> {
1608
+ const { state } = await this.resolveState(scopeId);
1609
+ return this.trackSlashCommandListing(state, sessionId, async (signal) => {
1610
+ await this.awaitReversionOperation(state.scopeQueue, signal);
1611
+ signal.throwIfAborted();
1612
+ this.assertStateAcceptingWork(state);
1613
+ const stored = this.requireSession(state, sessionId);
1614
+ await this.reconcileContextAvailability(state, stored);
1615
+ signal.throwIfAborted();
1616
+ const sessionAvailable = this.isSessionAvailableForSlashCommand(state, stored);
1617
+ const reversionIdleReason = await this.reversionUnavailableReason(
1618
+ state,
1619
+ stored,
1620
+ false,
1621
+ signal,
1622
+ );
1623
+ signal.throwIfAborted();
1624
+ return this.createSlashCommandDescriptors(
1625
+ state,
1626
+ stored,
1627
+ sessionAvailable,
1628
+ reversionIdleReason,
1629
+ );
1630
+ });
1379
1631
  }
1380
1632
 
1381
- public async enqueuePrompt(
1382
- scopeId: string,
1383
- sessionId: string,
1384
- prompt: TFlexPrompt,
1385
- options: IFlexPromptOptions = {},
1386
- ): Promise<IFlexPromptQueueAdmission> {
1387
- validatePromptOptions(options, false);
1388
- const queued = await this.enqueuePromptInternal(scopeId, sessionId, prompt, options);
1389
- return queued.admission;
1633
+ private createSlashCommandDescriptors(
1634
+ state: IStorageState,
1635
+ stored: IStoredSessionState,
1636
+ sessionAvailable: boolean,
1637
+ reversionIdleReason?: string,
1638
+ ): IFlexSlashCommandDescriptor[] {
1639
+ const sessionAvailability = sessionAvailable
1640
+ ? {}
1641
+ : { available: false, unavailableReason: slashCommandSessionUnavailableReason };
1642
+ const undoUnit = this.reversionUnit(stored, 'undo');
1643
+ const redoUnit = this.reversionUnit(stored, 'redo');
1644
+ const workspaceReversion = (unit: ReturnType<typeof this.reversionUnit>) =>
1645
+ !unit
1646
+ || !this.turnReversionProvider
1647
+ || unit.segments.some((segment) => !segment.workspaceCaptured)
1648
+ ? 'unsupported' as const
1649
+ : 'supported' as const;
1650
+ const reversionAvailability = (unit: ReturnType<typeof this.reversionUnit>) => {
1651
+ if (reversionIdleReason) return {
1652
+ available: false,
1653
+ unavailableReason: reversionIdleReason,
1654
+ };
1655
+ if (!unit) return { available: false, unavailableReason: 'No turn is available.' };
1656
+ if (!unit.target.contextAvailable) return {
1657
+ available: false,
1658
+ unavailableReason: 'The turn is beyond the context archive horizon.',
1659
+ };
1660
+ if (unit.segments.some((segment) =>
1661
+ segment.workspaceCaptured && segment.workspaceReference === undefined)) return {
1662
+ available: false,
1663
+ unavailableReason: 'The turn has no available workspace reversion capture.',
1664
+ };
1665
+ return { available: true };
1666
+ };
1667
+ const compactAvailable = Boolean(this.agentSessionPolicy.contextCompactor) && sessionAvailable;
1668
+ const compactUnavailableReason = !this.agentSessionPolicy.contextCompactor
1669
+ ? 'No context compactor is configured.'
1670
+ : !sessionAvailable
1671
+ ? slashCommandSessionUnavailableReason
1672
+ : undefined;
1673
+ const initPrompt = expandSlashCommandTemplate(FLEX_SLASH_COMMAND_INITIALIZE_TEMPLATE, '', []);
1674
+ const initAvailability = this.slashCommandPromptAvailability(
1675
+ state,
1676
+ stored,
1677
+ this.promptAdmissionByteSize(initPrompt, {}),
1678
+ );
1679
+ const descriptors: IFlexSlashCommandDescriptor[] = [
1680
+ {
1681
+ name: 'compact',
1682
+ description: 'Compact the current session context.',
1683
+ kind: 'builtin',
1684
+ hints: [],
1685
+ available: compactAvailable,
1686
+ ...(compactUnavailableReason === undefined
1687
+ ? {}
1688
+ : { unavailableReason: compactUnavailableReason }),
1689
+ workspaceReversion: 'not-applicable',
1690
+ },
1691
+ {
1692
+ name: 'init',
1693
+ description: 'Create or update AGENTS.md for the active workspace.',
1694
+ kind: 'builtin',
1695
+ hints: ['$ARGUMENTS'],
1696
+ ...initAvailability,
1697
+ workspaceReversion: 'not-applicable',
1698
+ },
1699
+ {
1700
+ name: 'undo',
1701
+ description: 'Undo the most recent session operation.',
1702
+ kind: 'builtin',
1703
+ hints: [],
1704
+ ...reversionAvailability(undoUnit),
1705
+ workspaceReversion: workspaceReversion(undoUnit),
1706
+ },
1707
+ {
1708
+ name: 'redo',
1709
+ description: 'Redo the most recently undone session operation.',
1710
+ kind: 'builtin',
1711
+ hints: [],
1712
+ ...reversionAvailability(redoUnit),
1713
+ workspaceReversion: workspaceReversion(redoUnit),
1714
+ },
1715
+ ];
1716
+ for (const registration of this.slashCommands.values()) {
1717
+ const template = typeof registration.template === 'string';
1718
+ const availability = template
1719
+ ? this.slashCommandPromptAvailability(
1720
+ state,
1721
+ stored,
1722
+ this.promptAdmissionByteSize(
1723
+ expandSlashCommandTemplate(registration.template!, '', []),
1724
+ {},
1725
+ ),
1726
+ )
1727
+ : { available: sessionAvailable, ...sessionAvailability };
1728
+ descriptors.push({
1729
+ name: registration.name,
1730
+ description: registration.description ?? '',
1731
+ kind: template ? 'template' : 'handler',
1732
+ hints: template ? slashCommandTemplateHints(registration.template!) : [],
1733
+ ...availability,
1734
+ workspaceReversion: 'not-applicable',
1735
+ });
1736
+ }
1737
+ return publicSnapshot(descriptors);
1390
1738
  }
1391
1739
 
1392
- public async schedulePrompt(
1740
+ public async executeSlashCommand(
1393
1741
  scopeId: string,
1394
1742
  sessionId: string,
1395
- scheduleKey: string,
1396
- prompt: TFlexPrompt,
1397
- options: IFlexSchedulePromptOptions = {},
1398
- ): Promise<IFlexScheduledPromptAdmission> {
1399
- validateIdentifier(scheduleKey, 'scheduleKey');
1400
- requireTransferIdentifier(scheduleKey, 'scheduleKey');
1401
- validatePromptOptions(options, true);
1402
- const debounceMs = options.debounceMs ?? 50;
1403
- if (!Number.isSafeInteger(debounceMs) || debounceMs < 0 || debounceMs > maxScheduleDebounceMs) {
1404
- throw new FlexHarnessValidationError(
1405
- `debounceMs must be an integer from 0 through ${maxScheduleDebounceMs}.`,
1743
+ untouchedInput: string,
1744
+ options: IFlexSlashCommandExecutionOptions = {},
1745
+ ): Promise<TFlexSlashCommandExecutionResult> {
1746
+ const currentInvocation = this.slashCommandInvocationContext.getStore();
1747
+ if (currentInvocation?.scopeId === scopeId && currentInvocation.sessionId === sessionId) {
1748
+ throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(sessionId));
1749
+ }
1750
+ const parsed = parseSlashCommand(untouchedInput);
1751
+ if (parsed.type !== 'parsed') return parsed;
1752
+ const registration = this.slashCommands.get(parsed.name);
1753
+ if (!reservedSlashCommandNames.has(parsed.name) && !registration) {
1754
+ return Object.freeze({
1755
+ type: 'unknown',
1756
+ input: parsed.input,
1757
+ name: parsed.name,
1758
+ rawArguments: parsed.rawArguments,
1759
+ arguments: parsed.arguments,
1760
+ });
1761
+ }
1762
+ const promptOptions = validateSlashCommandExecutionOptions(options);
1763
+ const { scope, state } = await this.resolveState(scopeId);
1764
+ await state.scopeQueue;
1765
+ this.assertOpen();
1766
+ this.assertStateAcceptingWork(state);
1767
+ const stored = this.requireSession(state, sessionId);
1768
+ const sessionKey = this.slashCommandSessionKey(state.storageKey, sessionId);
1769
+ const invocationOwner = this.slashCommandInvocationContext.getStore();
1770
+ if (invocationOwner?.sessionKey === sessionKey) {
1771
+ throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(sessionId));
1772
+ }
1773
+ if (this.activeSlashCommandExecutions.has(sessionKey)) {
1774
+ throw new FlexHarnessSessionBusyError(
1775
+ sessionId,
1776
+ 'already has an active slash command execution',
1406
1777
  );
1407
1778
  }
1408
- const queued = await this.enqueuePromptInternal(
1779
+ if (parsed.name === 'undo' || parsed.name === 'redo') {
1780
+ if (parsed.arguments.length > 0) {
1781
+ throw new FlexHarnessValidationError(`Slash command "/${parsed.name}" does not accept arguments.`);
1782
+ }
1783
+ await this.changeReversionCursorResolved(
1784
+ scopeId,
1785
+ sessionId,
1786
+ parsed.name,
1787
+ scope.scope,
1788
+ state,
1789
+ options.signal,
1790
+ );
1791
+ return Object.freeze({ type: 'operation', name: parsed.name });
1792
+ }
1793
+ if (parsed.name === 'compact') {
1794
+ if (parsed.arguments.length > 0) {
1795
+ throw new FlexHarnessValidationError('Slash command "/compact" does not accept arguments.');
1796
+ }
1797
+ if (!this.agentSessionPolicy.contextCompactor) {
1798
+ throw new FlexHarnessSlashCommandUnavailableError(
1799
+ parsed.name,
1800
+ 'No context compactor is configured.',
1801
+ );
1802
+ }
1803
+ this.requireSessionAvailableForSlashCommand(state, stored, parsed.name);
1804
+ return this.trackSlashCommandExecution(
1805
+ state,
1806
+ sessionId,
1807
+ 'operation',
1808
+ options.signal,
1809
+ async (signal) => {
1810
+ await this.commitRevertedBranch(state, stored, scopeId, scope.scope);
1811
+ await this.compactStoredSession(scopeId, scope.scope, state, stored, signal);
1812
+ return Object.freeze({ type: 'operation', name: parsed.name });
1813
+ },
1814
+ );
1815
+ }
1816
+ if (options.signal?.aborted) {
1817
+ throw this.trustInternalError(new FlexHarnessAbortError('The slash command was aborted.'));
1818
+ }
1819
+ if (parsed.name === 'init' || typeof registration?.template === 'string') {
1820
+ const template = parsed.name === 'init'
1821
+ ? FLEX_SLASH_COMMAND_INITIALIZE_TEMPLATE
1822
+ : registration!.template!;
1823
+ const expanded = expandSlashCommandTemplate(template, parsed.rawArguments, parsed.arguments);
1824
+ normalizeFlexPrompt(expanded);
1825
+ this.requireSlashCommandPromptAvailable(
1826
+ state,
1827
+ stored,
1828
+ parsed.name,
1829
+ this.promptAdmissionByteSize(expanded, promptOptions),
1830
+ );
1831
+ return this.trackSlashCommandExecution(
1832
+ state,
1833
+ sessionId,
1834
+ 'prompt-admission',
1835
+ options.signal,
1836
+ async (signal) => {
1837
+ const queued = await this.enqueuePromptInternal(
1838
+ scopeId,
1839
+ sessionId,
1840
+ expanded,
1841
+ promptOptions,
1842
+ undefined,
1843
+ undefined,
1844
+ false,
1845
+ signal,
1846
+ true,
1847
+ );
1848
+ const cancellation = this.trustInternalError(
1849
+ new FlexHarnessAbortError('The slash command prompt was aborted.'),
1850
+ );
1851
+ const cancel = () => {
1852
+ const entry = queued.queued;
1853
+ if (!entry) return;
1854
+ this.cancelQueuedPrompt(
1855
+ entry,
1856
+ cancellation,
1857
+ this.createCompactorContext(scopeId, scope.scope, state.storageKey, sessionId),
1858
+ );
1859
+ };
1860
+ const cancellationSignals = options.signal && options.signal !== signal
1861
+ ? [signal, options.signal]
1862
+ : [signal];
1863
+ for (const cancellationSignal of cancellationSignals) {
1864
+ cancellationSignal.addEventListener('abort', cancel, { once: true });
1865
+ }
1866
+ if (cancellationSignals.some((cancellationSignal) => cancellationSignal.aborted)) cancel();
1867
+ let admission: IFlexPromptAdmission;
1868
+ try {
1869
+ admission = await queued.started;
1870
+ if (cancellationSignals.some((cancellationSignal) => cancellationSignal.aborted)) {
1871
+ cancel();
1872
+ throw cancellationSignals.find((cancellationSignal) => cancellationSignal.aborted)?.reason
1873
+ ?? cancellation;
1874
+ }
1875
+ } catch (error) {
1876
+ for (const cancellationSignal of cancellationSignals) {
1877
+ cancellationSignal.removeEventListener('abort', cancel);
1878
+ }
1879
+ throw error;
1880
+ }
1881
+ void admission.completion.finally(() => {
1882
+ for (const cancellationSignal of cancellationSignals) {
1883
+ cancellationSignal.removeEventListener('abort', cancel);
1884
+ }
1885
+ }).catch(() => undefined);
1886
+ return Object.freeze({ type: 'prompt-admission', name: parsed.name, admission });
1887
+ },
1888
+ );
1889
+ }
1890
+ this.requireSessionAvailableForSlashCommand(state, stored, parsed.name);
1891
+ return this.executeSlashCommandHandler(
1409
1892
  scopeId,
1410
- sessionId,
1411
- prompt,
1412
- options,
1413
- scheduleKey,
1414
- debounceMs,
1893
+ scope.scope,
1894
+ state,
1895
+ stored,
1896
+ parsed.name,
1897
+ parsed.rawArguments,
1898
+ parsed.arguments,
1899
+ registration!.handler!,
1900
+ options.signal,
1415
1901
  );
1416
- const admission = await queued.started;
1417
- return Object.freeze({ ...admission, scheduleKey });
1418
1902
  }
1419
1903
 
1420
- public async getPromptQueueEntry(
1421
- scopeId: string,
1422
- sessionId: string,
1423
- queueId: string,
1424
- ): Promise<IFlexPromptQueueEntry> {
1425
- validateIdentifier(queueId, 'queueId');
1426
- requireTransferIdentifier(queueId, 'queueId');
1427
- const { state } = await this.resolveState(scopeId);
1428
- this.assertStateAcceptingWork(state);
1429
- const stored = this.requireSession(state, sessionId);
1430
- const entry = this.promptQueueEntry(stored, queueId);
1431
- if (!entry) throw new FlexHarnessNotFoundError('Prompt queue entry', queueId);
1432
- return publicSnapshot(entry);
1904
+ private isSessionAvailableForSlashCommand(
1905
+ state: IStorageState,
1906
+ stored: IStoredSessionState,
1907
+ allowPendingApply = false,
1908
+ ): boolean {
1909
+ return this.isSessionRuntimeIdle(state, stored)
1910
+ && !this.activeSlashCommandExecutions.has(
1911
+ this.slashCommandSessionKey(state.storageKey, stored.session.sessionId),
1912
+ )
1913
+ && (stored.pendingReversion === undefined
1914
+ || (allowPendingApply && stored.pendingReversion.kind === 'apply'));
1433
1915
  }
1434
1916
 
1435
- public async listPromptQueueEntries(
1436
- scopeId: string,
1437
- sessionId: string,
1438
- ): Promise<IFlexPromptQueueEntry[]> {
1439
- const { state } = await this.resolveState(scopeId);
1440
- this.assertStateAcceptingWork(state);
1441
- const stored = this.requireSession(state, sessionId);
1442
- return publicSnapshot([
1443
- ...[...stored.outstandingPromptsById.values()].map((entry) => this.projectPromptQueueEntry(entry)),
1444
- ...stored.terminalPromptQueueEntries.values(),
1445
- ].sort((left, right) => left.queueSequence - right.queueSequence));
1917
+ private slashCommandPromptUnavailableReason(
1918
+ state: IStorageState,
1919
+ stored: IStoredSessionState,
1920
+ byteSize: number,
1921
+ ): string | undefined {
1922
+ if (state.lifecycle !== 'active') return 'The session namespace is not accepting work.';
1923
+ if (this.activeSlashCommandExecutions.has(
1924
+ this.slashCommandSessionKey(state.storageKey, stored.session.sessionId),
1925
+ )) return 'Session already has an active slash command execution.';
1926
+ if (stored.pendingReversion) return 'Session has a pending reversion operation.';
1927
+ if (stored.session.agent !== undefined) {
1928
+ return 'Subagent sessions can only be prompted through the foreground task tool.';
1929
+ }
1930
+ if (stored.outstandingPromptsById.size >= this.promptQueueLimits.maxOutstandingPromptsPerSession) {
1931
+ return 'Session has reached its outstanding prompt limit.';
1932
+ }
1933
+ if (stored.outstandingPromptBytes + byteSize > this.promptQueueLimits.maxOutstandingBytesPerSession) {
1934
+ return 'Session has reached its outstanding prompt byte limit.';
1935
+ }
1936
+ if (this.pendingPromptAdmissions >= this.promptQueueLimits.maxPendingAdmissions) {
1937
+ return 'FlexHarness has reached its pending prompt admission limit.';
1938
+ }
1939
+ if (this.pendingPromptAdmissionBytes + byteSize > this.promptQueueLimits.maxPendingAdmissionBytes) {
1940
+ return 'FlexHarness has reached its pending prompt admission byte limit.';
1941
+ }
1942
+ return undefined;
1446
1943
  }
1447
1944
 
1448
- public async cancelPrompt(
1449
- scopeId: string,
1450
- sessionId: string,
1451
- queueId: string,
1452
- ): Promise<boolean> {
1453
- validateIdentifier(queueId, 'queueId');
1454
- requireTransferIdentifier(queueId, 'queueId');
1455
- const { scope, state } = await this.resolveState(scopeId);
1456
- const stored = this.requireSession(state, sessionId);
1457
- const queued = stored.outstandingPromptsById.get(queueId);
1458
- if (!queued) {
1459
- if (stored.terminalPromptQueueEntries.has(queueId)) return false;
1460
- throw new FlexHarnessNotFoundError('Prompt queue entry', queueId);
1461
- }
1462
- return this.cancelQueuedPrompt(
1463
- queued,
1945
+ private slashCommandPromptAvailability(
1946
+ state: IStorageState,
1947
+ stored: IStoredSessionState,
1948
+ byteSize: number,
1949
+ ): { available: boolean; unavailableReason?: string } {
1950
+ const unavailableReason = this.slashCommandPromptUnavailableReason(state, stored, byteSize);
1951
+ return unavailableReason === undefined
1952
+ ? { available: true }
1953
+ : { available: false, unavailableReason };
1954
+ }
1955
+
1956
+ private requireSlashCommandPromptAvailable(
1957
+ state: IStorageState,
1958
+ stored: IStoredSessionState,
1959
+ commandName: string,
1960
+ byteSize: number,
1961
+ ): void {
1962
+ const reason = this.slashCommandPromptUnavailableReason(state, stored, byteSize);
1963
+ if (reason) throw new FlexHarnessSlashCommandUnavailableError(commandName, reason);
1964
+ }
1965
+
1966
+ private promptAdmissionByteSize(
1967
+ prompt: TFlexPrompt,
1968
+ options: IFlexPromptOptions,
1969
+ scheduleKey?: string,
1970
+ debounceMs?: number,
1971
+ ): number {
1972
+ return jsonBytes({
1973
+ prompt: normalizeFlexPrompt(prompt),
1974
+ options: cloneSerializable(options),
1975
+ scheduleKey,
1976
+ debounceMs,
1977
+ });
1978
+ }
1979
+
1980
+ private isSessionRuntimeIdle(state: IStorageState, stored: IStoredSessionState): boolean {
1981
+ return stored.session.status === 'idle'
1982
+ && !state.activeRuns.has(stored.session.sessionId)
1983
+ && stored.outstandingPromptsById.size === 0;
1984
+ }
1985
+
1986
+ private slashCommandSessionKey(storageKey: string, sessionId: string): string {
1987
+ return JSON.stringify([storageKey, sessionId]);
1988
+ }
1989
+
1990
+ private requireSessionAvailableForSlashCommand(
1991
+ state: IStorageState,
1992
+ stored: IStoredSessionState,
1993
+ commandName: string,
1994
+ ): void {
1995
+ if (!this.isSessionAvailableForSlashCommand(state, stored)) {
1996
+ throw new FlexHarnessSlashCommandUnavailableError(
1997
+ commandName,
1998
+ slashCommandSessionUnavailableReason,
1999
+ );
2000
+ }
2001
+ }
2002
+
2003
+ private trackSlashCommandExecution<TResult>(
2004
+ state: IStorageState,
2005
+ sessionId: string,
2006
+ kind: IActiveSlashCommandExecution['kind'],
2007
+ externalSignal: AbortSignal | undefined,
2008
+ operation: (signal: AbortSignal) => Promise<TResult>,
2009
+ ): Promise<TResult> {
2010
+ const sessionKey = this.slashCommandSessionKey(state.storageKey, sessionId);
2011
+ if (this.activeSlashCommandExecutions.has(sessionKey)) {
2012
+ throw new FlexHarnessSessionBusyError(
2013
+ sessionId,
2014
+ 'already has an active slash command execution',
2015
+ );
2016
+ }
2017
+ const controller = new AbortController();
2018
+ const abortFromExternalSignal = () => {
2019
+ if (!controller.signal.aborted) {
2020
+ controller.abort(this.trustInternalError(
2021
+ new FlexHarnessAbortError('The slash command was aborted.'),
2022
+ ));
2023
+ }
2024
+ };
2025
+ externalSignal?.addEventListener('abort', abortFromExternalSignal, { once: true });
2026
+ if (externalSignal?.aborted) abortFromExternalSignal();
2027
+ const completion = Promise.resolve().then(() => {
2028
+ if (controller.signal.aborted) throw controller.signal.reason;
2029
+ return operation(controller.signal);
2030
+ });
2031
+ const active: IActiveSlashCommandExecution = {
2032
+ kind,
2033
+ storageKey: state.storageKey,
2034
+ sessionId,
2035
+ controller,
2036
+ completion,
2037
+ };
2038
+ this.activeSlashCommandExecutions.set(sessionKey, active);
2039
+ return completion.finally(() => {
2040
+ externalSignal?.removeEventListener('abort', abortFromExternalSignal);
2041
+ if (this.activeSlashCommandExecutions.get(sessionKey) === active) {
2042
+ this.activeSlashCommandExecutions.delete(sessionKey);
2043
+ }
2044
+ });
2045
+ }
2046
+
2047
+ private trackSlashCommandListing<TResult>(
2048
+ state: IStorageState,
2049
+ sessionId: string,
2050
+ operation: (signal: AbortSignal) => Promise<TResult>,
2051
+ ): Promise<TResult> {
2052
+ const controller = new AbortController();
2053
+ const completion = Promise.resolve().then(() => operation(controller.signal));
2054
+ const active: IActiveSlashCommandListing = {
2055
+ storageKey: state.storageKey,
2056
+ sessionId,
2057
+ controller,
2058
+ completion,
2059
+ };
2060
+ this.activeSlashCommandListings.add(active);
2061
+ return completion.finally(() => {
2062
+ this.activeSlashCommandListings.delete(active);
2063
+ });
2064
+ }
2065
+
2066
+ private async executeSlashCommandHandler(
2067
+ scopeId: string,
2068
+ scope: TScope,
2069
+ state: IStorageState,
2070
+ stored: IStoredSessionState,
2071
+ commandName: string,
2072
+ rawArguments: string,
2073
+ arguments_: readonly string[],
2074
+ handler: IFlexSlashCommandHandlerRegistration<TScope>['handler'],
2075
+ externalSignal?: AbortSignal,
2076
+ ): Promise<TFlexSlashCommandExecutionResult> {
2077
+ const sessionKey = this.slashCommandSessionKey(state.storageKey, stored.session.sessionId);
2078
+ const invocationOwner = this.slashCommandInvocationContext.getStore();
2079
+ if (invocationOwner?.sessionKey === sessionKey) {
2080
+ throw this.trustInternalError(
2081
+ new FlexHarnessSlashCommandReentryError(stored.session.sessionId),
2082
+ );
2083
+ }
2084
+ if (this.activeSlashCommandExecutions.has(sessionKey)) {
2085
+ throw new FlexHarnessSessionBusyError(
2086
+ stored.session.sessionId,
2087
+ 'already has an active slash command execution',
2088
+ );
2089
+ }
2090
+ const owner = Object.freeze({
2091
+ scopeId,
2092
+ storageKey: state.storageKey,
2093
+ sessionId: stored.session.sessionId,
2094
+ sessionKey,
2095
+ });
2096
+ return this.trackSlashCommandExecution(
2097
+ state,
2098
+ stored.session.sessionId,
2099
+ 'handler',
2100
+ externalSignal,
2101
+ (signal) => this.slashCommandInvocationContext.run(owner, async () => {
2102
+ const context = Object.freeze({
2103
+ scopeId,
2104
+ scope,
2105
+ storageKey: state.storageKey,
2106
+ sessionId: stored.session.sessionId,
2107
+ rawArguments,
2108
+ arguments: arguments_,
2109
+ signal,
2110
+ });
2111
+ try {
2112
+ await this.commitRevertedBranch(state, stored, scopeId, scope);
2113
+ const result = await handler(context);
2114
+ return Object.freeze({
2115
+ type: 'handler-result' as const,
2116
+ name: commandName,
2117
+ result: normalizeJsonValue(result === undefined ? null : result, this.toolOutputLimits),
2118
+ });
2119
+ } catch (error) {
2120
+ throw this.projectOperationError(
2121
+ error,
2122
+ 'slashCommand',
2123
+ scopeId,
2124
+ stored.session.sessionId,
2125
+ `slash-command:${commandName}`,
2126
+ );
2127
+ }
2128
+ }),
2129
+ );
2130
+ }
2131
+
2132
+ public async prompt(
2133
+ scopeId: string,
2134
+ sessionId: string,
2135
+ prompt: TFlexPrompt,
2136
+ options: IFlexPromptOptions = {},
2137
+ ): Promise<IFlexPromptResult> {
2138
+ const admission = await this.startPrompt(scopeId, sessionId, prompt, options);
2139
+ return admission.completion;
2140
+ }
2141
+
2142
+ public async startPrompt(
2143
+ scopeId: string,
2144
+ sessionId: string,
2145
+ prompt: TFlexPrompt,
2146
+ options: IFlexPromptOptions = {},
2147
+ ): Promise<IFlexPromptAdmission> {
2148
+ validatePromptOptions(options, false);
2149
+ const queued = await this.enqueuePromptInternal(scopeId, sessionId, prompt, options);
2150
+ return queued.started;
2151
+ }
2152
+
2153
+ public async enqueuePrompt(
2154
+ scopeId: string,
2155
+ sessionId: string,
2156
+ prompt: TFlexPrompt,
2157
+ options: IFlexPromptOptions = {},
2158
+ ): Promise<IFlexPromptQueueAdmission> {
2159
+ validatePromptOptions(options, false);
2160
+ const queued = await this.enqueuePromptInternal(scopeId, sessionId, prompt, options);
2161
+ return queued.admission;
2162
+ }
2163
+
2164
+ public async schedulePrompt(
2165
+ scopeId: string,
2166
+ sessionId: string,
2167
+ scheduleKey: string,
2168
+ prompt: TFlexPrompt,
2169
+ options: IFlexSchedulePromptOptions = {},
2170
+ ): Promise<IFlexScheduledPromptAdmission> {
2171
+ validateIdentifier(scheduleKey, 'scheduleKey');
2172
+ requireTransferIdentifier(scheduleKey, 'scheduleKey');
2173
+ validatePromptOptions(options, true);
2174
+ const debounceMs = options.debounceMs ?? 50;
2175
+ if (!Number.isSafeInteger(debounceMs) || debounceMs < 0 || debounceMs > maxScheduleDebounceMs) {
2176
+ throw new FlexHarnessValidationError(
2177
+ `debounceMs must be an integer from 0 through ${maxScheduleDebounceMs}.`,
2178
+ );
2179
+ }
2180
+ const queued = await this.enqueuePromptInternal(
2181
+ scopeId,
2182
+ sessionId,
2183
+ prompt,
2184
+ options,
2185
+ scheduleKey,
2186
+ debounceMs,
2187
+ );
2188
+ const admission = await queued.started;
2189
+ return Object.freeze({ ...admission, scheduleKey });
2190
+ }
2191
+
2192
+ public async getPromptQueueEntry(
2193
+ scopeId: string,
2194
+ sessionId: string,
2195
+ queueId: string,
2196
+ ): Promise<IFlexPromptQueueEntry> {
2197
+ validateIdentifier(queueId, 'queueId');
2198
+ requireTransferIdentifier(queueId, 'queueId');
2199
+ const { state } = await this.resolveState(scopeId);
2200
+ this.assertStateAcceptingWork(state);
2201
+ const stored = this.requireSession(state, sessionId);
2202
+ const entry = this.promptQueueEntry(stored, queueId);
2203
+ if (!entry) throw new FlexHarnessNotFoundError('Prompt queue entry', queueId);
2204
+ return publicSnapshot(entry);
2205
+ }
2206
+
2207
+ public async listPromptQueueEntries(
2208
+ scopeId: string,
2209
+ sessionId: string,
2210
+ ): Promise<IFlexPromptQueueEntry[]> {
2211
+ const { state } = await this.resolveState(scopeId);
2212
+ this.assertStateAcceptingWork(state);
2213
+ const stored = this.requireSession(state, sessionId);
2214
+ return publicSnapshot([
2215
+ ...[...stored.outstandingPromptsById.values()].map((entry) => this.projectPromptQueueEntry(entry)),
2216
+ ...stored.terminalPromptQueueEntries.values(),
2217
+ ].sort((left, right) => left.queueSequence - right.queueSequence));
2218
+ }
2219
+
2220
+ public async cancelPrompt(
2221
+ scopeId: string,
2222
+ sessionId: string,
2223
+ queueId: string,
2224
+ ): Promise<boolean> {
2225
+ validateIdentifier(queueId, 'queueId');
2226
+ requireTransferIdentifier(queueId, 'queueId');
2227
+ const { scope, state } = await this.resolveState(scopeId);
2228
+ const stored = this.requireSession(state, sessionId);
2229
+ const queued = stored.outstandingPromptsById.get(queueId);
2230
+ if (!queued) {
2231
+ if (stored.terminalPromptQueueEntries.has(queueId)) return false;
2232
+ throw new FlexHarnessNotFoundError('Prompt queue entry', queueId);
2233
+ }
2234
+ return this.cancelQueuedPrompt(
2235
+ queued,
1464
2236
  this.trustInternalError(new FlexHarnessAbortError('The queued prompt was cancelled.')),
1465
2237
  this.createCompactorContext(scopeId, scope.scope, state.storageKey, sessionId),
1466
2238
  );
@@ -1618,86 +2390,606 @@ export class FlexHarness<TScope = unknown> {
1618
2390
  public async compactSession(scopeId: string, sessionId: string): Promise<void> {
1619
2391
  const { scope, state } = await this.resolveState(scopeId);
1620
2392
  const stored = this.requireSession(state, sessionId);
2393
+ const invocationOwner = this.slashCommandInvocationContext.getStore();
2394
+ if (invocationOwner?.sessionKey === this.slashCommandSessionKey(state.storageKey, sessionId)) {
2395
+ throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(sessionId));
2396
+ }
1621
2397
  this.assertStateAcceptingWork(state);
1622
- try {
1623
- await this.withCompactorContext(
1624
- this.createCompactorContext(scopeId, scope.scope, state.storageKey, sessionId),
1625
- () => stored.agentSession.compact(),
2398
+ if (!this.agentSessionPolicy.contextCompactor) {
2399
+ throw new FlexHarnessSlashCommandUnavailableError(
2400
+ 'compact',
2401
+ 'No context compactor is configured.',
1626
2402
  );
1627
- } catch (error) {
1628
- throw this.projectOperationError(error, 'agentSession', scopeId, sessionId, 'compaction');
1629
2403
  }
2404
+ this.requireSessionAvailableForSlashCommand(state, stored, 'compact');
2405
+ await this.trackSlashCommandExecution(
2406
+ state,
2407
+ sessionId,
2408
+ 'operation',
2409
+ undefined,
2410
+ async (signal) => {
2411
+ await this.commitRevertedBranch(state, stored, scopeId, scope.scope);
2412
+ await this.compactStoredSession(scopeId, scope.scope, state, stored, signal);
2413
+ },
2414
+ );
1630
2415
  }
1631
2416
 
1632
- public async archiveSessionEvents(
2417
+ private async compactStoredSession(
1633
2418
  scopeId: string,
1634
- sessionId: string,
1635
- compactionEventId?: string,
1636
- ): Promise<IFlexEventArchiveMetadata | undefined> {
1637
- if (compactionEventId !== undefined) {
1638
- validateIdentifier(compactionEventId, 'compactionEventId');
1639
- requireTransferIdentifier(compactionEventId, 'compactionEventId');
1640
- }
1641
- const { state } = await this.resolveState(scopeId);
1642
- const stored = this.requireSession(state, sessionId);
1643
- this.assertStateAcceptingWork(state);
2419
+ scope: TScope,
2420
+ state: IStorageState,
2421
+ stored: IStoredSessionState,
2422
+ signal?: AbortSignal,
2423
+ ): Promise<void> {
1644
2424
  try {
1645
- const archive = await stored.agentSession.archiveCompactedEvents(compactionEventId);
1646
- if (!archive) return undefined;
1647
- return publicSnapshot({
1648
- archiveId: this.boundedIdentifier(archive.archiveId, 'archiveId'),
1649
- sessionId: this.boundedIdentifier(archive.sessionId, 'sessionId'),
1650
- createdAt: new Date(archive.createdAt).toISOString(),
1651
- eventCount: archive.events.length,
1652
- });
2425
+ await this.withCompactorContext(
2426
+ this.createCompactorContext(
2427
+ scopeId,
2428
+ scope,
2429
+ state.storageKey,
2430
+ stored.session.sessionId,
2431
+ ),
2432
+ () => stored.agentSession.compact(signal === undefined ? {} : { abort: signal }),
2433
+ );
1653
2434
  } catch (error) {
1654
- throw this.projectOperationError(error, 'agentSession', scopeId, sessionId, 'archive');
2435
+ throw this.projectOperationError(
2436
+ error,
2437
+ 'agentSession',
2438
+ scopeId,
2439
+ stored.session.sessionId,
2440
+ 'compaction',
2441
+ );
1655
2442
  }
1656
2443
  }
1657
2444
 
1658
- public async listBackgroundExecutions(
2445
+ public async undoSession(
1659
2446
  scopeId: string,
1660
2447
  sessionId: string,
1661
- ): Promise<IFlexBackgroundExecution[]> {
1662
- const { state } = await this.resolveState(scopeId);
1663
- const stored = this.requireSession(state, sessionId);
1664
- this.assertStateAcceptingWork(state);
1665
- if (!stored.jobs) return [];
1666
- try {
1667
- const jobs = await stored.jobs.list();
1668
- return publicSnapshot(jobs
1669
- .sort((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0))
1670
- .slice(0, maxBackgroundExecutions)
1671
- .map((job) => this.projectBackgroundExecution(job)));
1672
- } catch (error) {
1673
- throw this.projectOperationError(error, 'agentSession', scopeId, sessionId, 'background-list');
1674
- }
2448
+ signal?: AbortSignal,
2449
+ ): Promise<IFlexUndoSessionResult> {
2450
+ const result = await this.changeReversionCursor(scopeId, sessionId, 'undo', signal);
2451
+ return Object.freeze({ revertedRunId: result });
1675
2452
  }
1676
2453
 
1677
- public async getBackgroundExecution(
2454
+ public async redoSession(
1678
2455
  scopeId: string,
1679
2456
  sessionId: string,
1680
- executionId: string,
1681
- ): Promise<IFlexBackgroundExecution> {
1682
- validateIdentifier(executionId, 'executionId');
1683
- requireTransferIdentifier(executionId, 'executionId');
1684
- const { state } = await this.resolveState(scopeId);
1685
- const stored = this.requireSession(state, sessionId);
1686
- this.assertStateAcceptingWork(state);
1687
- try {
1688
- return publicSnapshot(this.projectBackgroundExecution(
1689
- await stored.agentSession.getBackgroundExecution(executionId),
1690
- ));
1691
- } catch (error) {
1692
- throw this.projectOperationError(error, 'agentSession', scopeId, sessionId, 'background-get');
1693
- }
2457
+ signal?: AbortSignal,
2458
+ ): Promise<IFlexRedoSessionResult> {
2459
+ const result = await this.changeReversionCursor(scopeId, sessionId, 'redo', signal);
2460
+ return Object.freeze({ restoredRunId: result });
1694
2461
  }
1695
2462
 
1696
- public async abortBackgroundExecution(
1697
- scopeId: string,
1698
- sessionId: string,
1699
- executionId: string,
1700
- ): Promise<void> {
2463
+ private reversionUnit(
2464
+ stored: IStoredSessionState,
2465
+ direction: 'undo' | 'redo',
2466
+ ): { target: IFlexReversionSegment; segments: IFlexReversionSegment[]; toCursor: number } | undefined {
2467
+ const candidates = this.completedReversionCandidates(stored);
2468
+ const candidate = direction === 'undo'
2469
+ ? candidates[stored.revertCursor - 1]
2470
+ : candidates[stored.revertCursor];
2471
+ if (!candidate) return undefined;
2472
+ const candidateIndex = stored.reversionSegments.findIndex((segment) => segment.runId === candidate.runId);
2473
+ const start = candidate === candidates[0] ? 0 : candidateIndex;
2474
+ const next = candidates[direction === 'undo' ? stored.revertCursor : stored.revertCursor + 1];
2475
+ const end = next
2476
+ ? stored.reversionSegments.findIndex((segment) => segment.runId === next.runId)
2477
+ : stored.reversionSegments.length;
2478
+ return {
2479
+ target: candidate,
2480
+ segments: stored.reversionSegments.slice(start, end),
2481
+ toCursor: stored.revertCursor + (direction === 'undo' ? -1 : 1),
2482
+ };
2483
+ }
2484
+
2485
+ private async requireReversionIdle(
2486
+ state: IStorageState,
2487
+ stored: IStoredSessionState,
2488
+ sessionId: string,
2489
+ allowPendingApply = false,
2490
+ operationSignal?: AbortSignal,
2491
+ ): Promise<void> {
2492
+ const reason = await this.reversionUnavailableReason(
2493
+ state,
2494
+ stored,
2495
+ allowPendingApply,
2496
+ operationSignal,
2497
+ );
2498
+ if (reason) throw new FlexHarnessSessionBusyError(sessionId, reason.toLowerCase().replace(/\.$/u, ''));
2499
+ }
2500
+
2501
+ private async reversionUnavailableReason(
2502
+ state: IStorageState,
2503
+ stored: IStoredSessionState,
2504
+ allowPendingApply = false,
2505
+ operationSignal?: AbortSignal,
2506
+ ): Promise<string | undefined> {
2507
+ if (stored.pendingReversion && !(allowPendingApply && stored.pendingReversion.kind === 'apply')) {
2508
+ return 'Session has a pending reversion operation.';
2509
+ }
2510
+ if (!this.isSessionRuntimeIdle(state, stored)) {
2511
+ return slashCommandSessionUnavailableReason;
2512
+ }
2513
+ const activeOperation = this.activeSlashCommandExecutions.get(
2514
+ this.slashCommandSessionKey(state.storageKey, stored.session.sessionId),
2515
+ );
2516
+ if (activeOperation && activeOperation.controller.signal !== operationSignal) {
2517
+ return 'Session already has an active slash command execution.';
2518
+ }
2519
+ if ([...state.pendingPermissions.values()].some((pending) =>
2520
+ !pending.settled && pending.request.sessionId === stored.session.sessionId)) {
2521
+ return 'Session has a pending permission.';
2522
+ }
2523
+ if (stored.agentSession.listUncertainToolExecutions().length > 0) {
2524
+ return 'Session has uncertain tool executions.';
2525
+ }
2526
+ if (stored.jobs) {
2527
+ operationSignal?.throwIfAborted();
2528
+ const jobs = Promise.resolve().then(() => stored.jobs!.list());
2529
+ if ((await this.awaitReversionOperation(jobs, operationSignal))
2530
+ .some((job) => job.state === 'running')) {
2531
+ return 'Session has running background jobs.';
2532
+ }
2533
+ }
2534
+ return undefined;
2535
+ }
2536
+
2537
+ private async awaitReversionOperation<TResult>(
2538
+ operation: Promise<TResult>,
2539
+ signal?: AbortSignal,
2540
+ ): Promise<TResult> {
2541
+ if (!signal) return operation;
2542
+ void operation.catch(() => undefined);
2543
+ let rejectForAbort!: () => void;
2544
+ const aborted = new Promise<never>((_resolve, reject) => {
2545
+ rejectForAbort = () => reject(
2546
+ signal.reason ?? this.trustInternalError(new FlexHarnessAbortError('The slash command was aborted.')),
2547
+ );
2548
+ signal.addEventListener('abort', rejectForAbort, { once: true });
2549
+ if (signal.aborted) rejectForAbort();
2550
+ });
2551
+ try {
2552
+ return await Promise.race([operation, aborted]);
2553
+ } finally {
2554
+ signal.removeEventListener('abort', rejectForAbort);
2555
+ }
2556
+ }
2557
+
2558
+ private async changeReversionCursor(
2559
+ scopeId: string,
2560
+ sessionId: string,
2561
+ direction: 'undo' | 'redo',
2562
+ signal?: AbortSignal,
2563
+ ): Promise<string> {
2564
+ const { scope, state } = await this.resolveState(scopeId);
2565
+ return this.changeReversionCursorResolved(
2566
+ scopeId,
2567
+ sessionId,
2568
+ direction,
2569
+ scope.scope,
2570
+ state,
2571
+ signal,
2572
+ );
2573
+ }
2574
+
2575
+ private async changeReversionCursorResolved(
2576
+ scopeId: string,
2577
+ sessionId: string,
2578
+ direction: 'undo' | 'redo',
2579
+ scope: TScope,
2580
+ state: IStorageState,
2581
+ signal?: AbortSignal,
2582
+ ): Promise<string> {
2583
+ const stored = this.requireSession(state, sessionId);
2584
+ this.assertStateAcceptingWork(state);
2585
+ const invocationOwner = this.slashCommandInvocationContext.getStore();
2586
+ if (invocationOwner?.sessionKey === this.slashCommandSessionKey(state.storageKey, sessionId)) {
2587
+ throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(sessionId));
2588
+ }
2589
+ return this.trackSlashCommandExecution(
2590
+ state,
2591
+ sessionId,
2592
+ 'operation',
2593
+ signal,
2594
+ async (operationSignal) => {
2595
+ await stored.projectionQueue;
2596
+ const resumable = stored.pendingReversion?.kind === 'apply'
2597
+ && stored.pendingReversion.direction === direction;
2598
+ await this.requireReversionIdle(
2599
+ state,
2600
+ stored,
2601
+ sessionId,
2602
+ resumable,
2603
+ operationSignal,
2604
+ );
2605
+ await this.reconcileContextAvailability(state, stored);
2606
+ operationSignal.throwIfAborted();
2607
+ const pending = stored.pendingReversion;
2608
+ if (pending?.kind === 'apply') {
2609
+ if (pending.direction !== direction) {
2610
+ throw new FlexHarnessSessionBusyError(sessionId, 'has a pending opposite reversion operation');
2611
+ }
2612
+ const candidates = this.completedReversionCandidates(stored);
2613
+ const target = candidates[direction === 'undo' ? pending.fromCursor - 1 : pending.fromCursor];
2614
+ if (!target) throw new FlexHarnessValidationError('Pending reversion has no target candidate.');
2615
+ await this.resumePendingApply(state, stored, scopeId, scope, operationSignal);
2616
+ this.emitEvent(scopeId, sessionId, {
2617
+ type: 'session.history.changed',
2618
+ direction,
2619
+ runId: target.runId,
2620
+ });
2621
+ return target.runId;
2622
+ }
2623
+ const unit = this.reversionUnit(stored, direction);
2624
+ if (!unit) {
2625
+ throw new FlexHarnessSlashCommandUnavailableError(
2626
+ direction,
2627
+ direction === 'undo' ? 'No visible turn can be undone.' : 'No hidden turn can be redone.',
2628
+ );
2629
+ }
2630
+ if (!unit.target.contextAvailable) {
2631
+ throw new FlexHarnessSlashCommandUnavailableError(
2632
+ direction,
2633
+ 'The turn is beyond the context archive horizon.',
2634
+ );
2635
+ }
2636
+ if (this.turnReversionProvider && unit.segments.some((segment) =>
2637
+ segment.captureId !== undefined && segment.workspaceReference === undefined)) {
2638
+ throw new FlexHarnessSlashCommandUnavailableError(
2639
+ direction,
2640
+ 'The turn has no available workspace reversion capture.',
2641
+ );
2642
+ }
2643
+ const operationId = this.reversionId(
2644
+ 'apply',
2645
+ state.storageKey,
2646
+ sessionId,
2647
+ JSON.stringify([direction, stored.revertCursor, unit.toCursor, unit.target.runId]),
2648
+ );
2649
+ await this.mutateProjection(state, stored, () => {
2650
+ stored.pendingReversion = {
2651
+ kind: 'apply',
2652
+ operationId,
2653
+ direction,
2654
+ fromCursor: stored.revertCursor,
2655
+ toCursor: unit.toCursor,
2656
+ segmentRunIds: unit.segments.map((segment) => segment.runId),
2657
+ appliedRunIds: [],
2658
+ };
2659
+ }, true);
2660
+ await this.resumePendingApply(
2661
+ state,
2662
+ stored,
2663
+ scopeId,
2664
+ scope,
2665
+ operationSignal,
2666
+ );
2667
+ this.emitEvent(scopeId, sessionId, {
2668
+ type: 'session.history.changed',
2669
+ direction,
2670
+ runId: unit.target.runId,
2671
+ });
2672
+ return unit.target.runId;
2673
+ },
2674
+ );
2675
+ }
2676
+
2677
+ private async reconcileContextAvailability(
2678
+ state: IStorageState,
2679
+ stored: IStoredSessionState,
2680
+ ): Promise<boolean> {
2681
+ const activeEvents = new Set(stored.agentSession.getEvents().map((event) => event.id));
2682
+ if (!stored.reversionSegments.some((segment) =>
2683
+ segment.contextAvailable
2684
+ && segment.eventIds.length > 0
2685
+ && !segment.eventIds.some((eventId) => activeEvents.has(eventId)))) return false;
2686
+ let branchCommitted = false;
2687
+ await this.mutateProjection(state, stored, () => {
2688
+ for (const segment of stored.reversionSegments) {
2689
+ if (
2690
+ segment.contextAvailable
2691
+ && segment.eventIds.length > 0
2692
+ && !segment.eventIds.some((eventId) => activeEvents.has(eventId))
2693
+ ) segment.contextAvailable = false;
2694
+ }
2695
+ branchCommitted = this.commitArchivedHiddenBranchState(stored);
2696
+ this.pruneReversionState(stored, false, true);
2697
+ }, true);
2698
+ const context = stored.compactorContext as IFlexAgentContextInvocation<TScope> | undefined;
2699
+ if (context) {
2700
+ await this.drainReversionReleases(state, stored, context.scopeId, context.scope, false);
2701
+ }
2702
+ return branchCommitted;
2703
+ }
2704
+
2705
+ private async resumePendingApply(
2706
+ state: IStorageState,
2707
+ stored: IStoredSessionState,
2708
+ scopeId: string,
2709
+ scope: TScope,
2710
+ signal?: AbortSignal,
2711
+ ): Promise<void> {
2712
+ const pending = stored.pendingReversion;
2713
+ if (pending?.kind !== 'apply') return;
2714
+ const segmentMap = new Map(stored.reversionSegments.map((segment) => [segment.runId, segment]));
2715
+ if (
2716
+ pending.segmentRunIds.some((runId) => segmentMap.get(runId)?.workspaceCaptured)
2717
+ && !this.turnReversionProvider
2718
+ ) {
2719
+ throw new FlexHarnessValidationError(
2720
+ 'Pending workspace reversion recovery requires its turn reversion provider.',
2721
+ );
2722
+ }
2723
+ const orderedRunIds = pending.direction === 'undo'
2724
+ ? [...pending.segmentRunIds].reverse()
2725
+ : [...pending.segmentRunIds];
2726
+ for (const runId of orderedRunIds) {
2727
+ if (pending.appliedRunIds.includes(runId)) continue;
2728
+ if (signal?.aborted) {
2729
+ await this.persistKnownNotApplied(state, stored, pending.operationId, runId);
2730
+ throw signal.reason;
2731
+ }
2732
+ const segment = segmentMap.get(runId);
2733
+ if (!segment) throw new FlexHarnessValidationError('Pending reversion references a missing segment.');
2734
+ if (segment.workspaceCaptured) {
2735
+ if (!this.turnReversionProvider || !segment.captureId || segment.workspaceReference === undefined) {
2736
+ throw new FlexHarnessValidationError('Pending workspace reversion segment is incomplete.');
2737
+ }
2738
+ const captureId = segment.captureId;
2739
+ const reference = segment.workspaceReference;
2740
+ const childOperationId = `${pending.operationId}:${sha256Hex(runId).slice(0, 16)}`;
2741
+ const createContext = (contextSignal: AbortSignal) => Object.freeze({
2742
+ scopeId,
2743
+ scope,
2744
+ storageKey: state.storageKey,
2745
+ sessionId: stored.session.sessionId,
2746
+ runId,
2747
+ captureId,
2748
+ reference: cloneSerializable(reference),
2749
+ operationId: childOperationId,
2750
+ direction: pending.direction,
2751
+ signal: contextSignal,
2752
+ });
2753
+ let inspection;
2754
+ try {
2755
+ inspection = await this.withReversionMaintenanceSignal((inspectionSignal) =>
2756
+ this.turnReversionProvider!.inspectApply(createContext(inspectionSignal)));
2757
+ } catch (inspectionError) {
2758
+ state.lifecycle = 'fenced';
2759
+ throw this.projectOperationError(
2760
+ inspectionError,
2761
+ 'turnReversion',
2762
+ scopeId,
2763
+ stored.session.sessionId,
2764
+ childOperationId,
2765
+ );
2766
+ }
2767
+ if (inspection.status === 'unknown') {
2768
+ state.lifecycle = 'fenced';
2769
+ throw new FlexHarnessValidationError('Workspace reversion apply outcome is unknown.');
2770
+ }
2771
+ if (inspection.status === 'not-applied') {
2772
+ try {
2773
+ if (signal) {
2774
+ await this.turnReversionProvider.apply(createContext(signal));
2775
+ } else {
2776
+ await this.withReversionMaintenanceSignal((maintenanceSignal) =>
2777
+ this.turnReversionProvider!.apply(createContext(maintenanceSignal)));
2778
+ }
2779
+ } catch (error) {
2780
+ try {
2781
+ inspection = await this.withReversionMaintenanceSignal((inspectionSignal) =>
2782
+ this.turnReversionProvider!.inspectApply(createContext(inspectionSignal)));
2783
+ } catch (inspectionError) {
2784
+ state.lifecycle = 'fenced';
2785
+ throw this.projectOperationError(
2786
+ inspectionError,
2787
+ 'turnReversion',
2788
+ scopeId,
2789
+ stored.session.sessionId,
2790
+ childOperationId,
2791
+ );
2792
+ }
2793
+ if (inspection.status !== 'applied') {
2794
+ if (inspection.status === 'unknown') state.lifecycle = 'fenced';
2795
+ else await this.persistKnownNotApplied(state, stored, pending.operationId, runId);
2796
+ throw this.projectOperationError(
2797
+ error,
2798
+ 'turnReversion',
2799
+ scopeId,
2800
+ stored.session.sessionId,
2801
+ childOperationId,
2802
+ );
2803
+ }
2804
+ }
2805
+ }
2806
+ }
2807
+ await this.mutateProjection(state, stored, () => {
2808
+ const current = stored.pendingReversion;
2809
+ if (current?.kind !== 'apply' || current.operationId !== pending.operationId) {
2810
+ throw new FlexHarnessValidationError('Pending reversion changed while applying.');
2811
+ }
2812
+ if (!current.appliedRunIds.includes(runId)) current.appliedRunIds.push(runId);
2813
+ }, true);
2814
+ }
2815
+ await this.mutateProjection(state, stored, () => {
2816
+ const current = stored.pendingReversion;
2817
+ if (current?.kind !== 'apply' || current.operationId !== pending.operationId) {
2818
+ throw new FlexHarnessValidationError('Pending reversion changed before cursor commit.');
2819
+ }
2820
+ stored.revertCursor = current.toCursor;
2821
+ delete stored.pendingReversion;
2822
+ }, true);
2823
+ }
2824
+
2825
+ private async persistKnownNotApplied(
2826
+ state: IStorageState,
2827
+ stored: IStoredSessionState,
2828
+ operationId: string,
2829
+ runId: string,
2830
+ ): Promise<void> {
2831
+ await this.mutateProjection(state, stored, () => {
2832
+ const current = stored.pendingReversion;
2833
+ if (current?.kind !== 'apply' || current.operationId !== operationId) {
2834
+ throw new FlexHarnessValidationError('Pending reversion changed while recording failed apply.');
2835
+ }
2836
+ current.appliedRunIds = current.appliedRunIds.filter((entry) => entry !== runId);
2837
+ if (current.appliedRunIds.length === 0) {
2838
+ stored.revertCursor = current.fromCursor;
2839
+ delete stored.pendingReversion;
2840
+ }
2841
+ }, true);
2842
+ }
2843
+
2844
+ private async recoverPendingCapture(
2845
+ state: IStorageState,
2846
+ stored: IStoredSessionState,
2847
+ scopeId: string,
2848
+ scope: TScope,
2849
+ outcomes: Map<string, TCanonicalOutcome>,
2850
+ ): Promise<void> {
2851
+ const pending = stored.pendingReversion;
2852
+ if (pending?.kind !== 'capture' || !this.turnReversionProvider) return;
2853
+ const segment = stored.reversionSegments.find((entry) => entry.runId === pending.runId);
2854
+ if (!segment) throw new FlexHarnessValidationError('Pending capture references a missing segment.');
2855
+ const outcome = outcomes.get(pending.runId);
2856
+ const reference = await this.resolveReversionCaptureReference(
2857
+ state,
2858
+ scopeId,
2859
+ scope,
2860
+ stored.session.sessionId,
2861
+ pending.runId,
2862
+ pending.captureId,
2863
+ pending.state,
2864
+ );
2865
+ if (reference === undefined) {
2866
+ await this.mutateProjection(state, stored, () => {
2867
+ stored.reversionSegments = stored.reversionSegments.filter(
2868
+ (entry) => entry.runId !== pending.runId,
2869
+ );
2870
+ delete stored.pendingReversion;
2871
+ }, true);
2872
+ return;
2873
+ }
2874
+ await this.mutateProjection(state, stored, () => {
2875
+ segment.status = outcome === 'accepted'
2876
+ ? 'completed'
2877
+ : outcome === 'rejected'
2878
+ ? 'failed'
2879
+ : 'cancelled';
2880
+ segment.workspaceReference = cloneSerializable(reference);
2881
+ segment.eventIds = stored.agentSession.getEvents()
2882
+ .filter((event) => event.generationId === pending.runId)
2883
+ .map((event) => event.id);
2884
+ if (segment.status === 'completed') stored.revertCursor++;
2885
+ if (outcome === undefined && !stored.excludedRunIds.includes(pending.runId)) {
2886
+ stored.excludedRunIds.push(pending.runId);
2887
+ }
2888
+ delete stored.pendingReversion;
2889
+ this.pruneReversionState(stored, false, true);
2890
+ }, true);
2891
+ await this.drainReversionReleases(state, stored, scopeId, scope, false);
2892
+ }
2893
+
2894
+ public async archiveSessionEvents(
2895
+ scopeId: string,
2896
+ sessionId: string,
2897
+ compactionEventId?: string,
2898
+ ): Promise<IFlexEventArchiveMetadata | undefined> {
2899
+ if (compactionEventId !== undefined) {
2900
+ validateIdentifier(compactionEventId, 'compactionEventId');
2901
+ requireTransferIdentifier(compactionEventId, 'compactionEventId');
2902
+ }
2903
+ const { scope, state } = await this.resolveState(scopeId);
2904
+ const stored = this.requireSession(state, sessionId);
2905
+ this.assertStateAcceptingWork(state);
2906
+ this.requireSessionAvailableForSlashCommand(state, stored, 'archive');
2907
+ const compaction = compactionEventId === undefined
2908
+ ? [...stored.agentSession.getEvents()].reverse().find((event) =>
2909
+ event.type === 'context-compaction')
2910
+ : stored.agentSession.getEvents().find((event) =>
2911
+ event.type === 'context-compaction' && event.id === compactionEventId);
2912
+ if (!compaction || compaction.type !== 'context-compaction') return undefined;
2913
+ let archive;
2914
+ try {
2915
+ archive = await stored.agentSession.archiveCompactedEvents(compaction.id);
2916
+ } catch (error) {
2917
+ throw this.projectOperationError(error, 'agentSession', scopeId, sessionId, 'archive');
2918
+ }
2919
+ if (!archive) return undefined;
2920
+ let branchCommitted = false;
2921
+ try {
2922
+ const archivedIds = new Set(archive.events.map((event) => event.id));
2923
+ await this.mutateProjection(state, stored, () => {
2924
+ branchCommitted = this.commitRevertedBranchState(stored);
2925
+ for (const segment of stored.reversionSegments) {
2926
+ if (segment.eventIds.some((eventId) => archivedIds.has(eventId))) {
2927
+ segment.contextAvailable = false;
2928
+ }
2929
+ }
2930
+ this.pruneReversionState(stored);
2931
+ }, true);
2932
+ } catch (error) {
2933
+ throw this.projectOperationError(error, 'persistence', scopeId, sessionId, 'archive-horizon');
2934
+ }
2935
+ if (branchCommitted) {
2936
+ this.emitEvent(scopeId, sessionId, {
2937
+ type: 'session.history.changed',
2938
+ direction: 'branch',
2939
+ });
2940
+ }
2941
+ await this.drainReversionReleases(state, stored, scopeId, scope.scope, false);
2942
+ return publicSnapshot({
2943
+ archiveId: this.boundedIdentifier(archive.archiveId, 'archiveId'),
2944
+ sessionId: this.boundedIdentifier(archive.sessionId, 'sessionId'),
2945
+ createdAt: new Date(archive.createdAt).toISOString(),
2946
+ eventCount: archive.events.length,
2947
+ });
2948
+ }
2949
+
2950
+ public async listBackgroundExecutions(
2951
+ scopeId: string,
2952
+ sessionId: string,
2953
+ ): Promise<IFlexBackgroundExecution[]> {
2954
+ const { state } = await this.resolveState(scopeId);
2955
+ const stored = this.requireSession(state, sessionId);
2956
+ this.assertStateAcceptingWork(state);
2957
+ if (!stored.jobs) return [];
2958
+ try {
2959
+ const jobs = await stored.jobs.list();
2960
+ return publicSnapshot(jobs
2961
+ .sort((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0))
2962
+ .slice(0, maxBackgroundExecutions)
2963
+ .map((job) => this.projectBackgroundExecution(job)));
2964
+ } catch (error) {
2965
+ throw this.projectOperationError(error, 'agentSession', scopeId, sessionId, 'background-list');
2966
+ }
2967
+ }
2968
+
2969
+ public async getBackgroundExecution(
2970
+ scopeId: string,
2971
+ sessionId: string,
2972
+ executionId: string,
2973
+ ): Promise<IFlexBackgroundExecution> {
2974
+ validateIdentifier(executionId, 'executionId');
2975
+ requireTransferIdentifier(executionId, 'executionId');
2976
+ const { state } = await this.resolveState(scopeId);
2977
+ const stored = this.requireSession(state, sessionId);
2978
+ this.assertStateAcceptingWork(state);
2979
+ try {
2980
+ return publicSnapshot(this.projectBackgroundExecution(
2981
+ await stored.agentSession.getBackgroundExecution(executionId),
2982
+ ));
2983
+ } catch (error) {
2984
+ throw this.projectOperationError(error, 'agentSession', scopeId, sessionId, 'background-get');
2985
+ }
2986
+ }
2987
+
2988
+ public async abortBackgroundExecution(
2989
+ scopeId: string,
2990
+ sessionId: string,
2991
+ executionId: string,
2992
+ ): Promise<void> {
1701
2993
  validateIdentifier(executionId, 'executionId');
1702
2994
  requireTransferIdentifier(executionId, 'executionId');
1703
2995
  const { scope, state } = await this.resolveState(scopeId);
@@ -1725,6 +3017,10 @@ export class FlexHarness<TScope = unknown> {
1725
3017
  }
1726
3018
 
1727
3019
  public retireScope(scopeId: string): Promise<void> {
3020
+ const invocationOwner = this.slashCommandInvocationContext.getStore();
3021
+ if (invocationOwner?.scopeId === scopeId) {
3022
+ throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(invocationOwner.sessionId));
3023
+ }
1728
3024
  this.assertOpen();
1729
3025
  validateIdentifier(scopeId, 'scopeId');
1730
3026
  const existing = this.scopeRetirements.get(scopeId);
@@ -1744,6 +3040,12 @@ export class FlexHarness<TScope = unknown> {
1744
3040
 
1745
3041
  public async dispose(): Promise<void> {
1746
3042
  if (this.disposePromise) return this.disposePromise;
3043
+ const invocationOwner = this.slashCommandInvocationContext.getStore();
3044
+ if (invocationOwner) {
3045
+ throw this.trustInternalError(
3046
+ new FlexHarnessSlashCommandReentryError(invocationOwner.sessionId),
3047
+ );
3048
+ }
1747
3049
  this.closed = true;
1748
3050
  let disposal!: Promise<void>;
1749
3051
  disposal = this.disposeInternal().catch((error) => {
@@ -1763,16 +3065,16 @@ export class FlexHarness<TScope = unknown> {
1763
3065
  debounceMs?: number,
1764
3066
  subagentAdmission = false,
1765
3067
  admissionSignal?: AbortSignal,
1766
- ): Promise<{ admission: IFlexPromptQueueAdmission; started: Promise<IFlexPromptAdmission> }> {
3068
+ slashCommandAdmission = false,
3069
+ ): Promise<{
3070
+ admission: IFlexPromptQueueAdmission;
3071
+ started: Promise<IFlexPromptAdmission>;
3072
+ queued?: IQueuedPrompt;
3073
+ }> {
1767
3074
  this.assertOpen();
1768
3075
  const normalizedPrompt = normalizeFlexPrompt(prompt);
1769
3076
  const normalizedOptions = cloneSerializable(options);
1770
- const byteSize = jsonBytes({
1771
- prompt: normalizedPrompt,
1772
- options: normalizedOptions,
1773
- scheduleKey,
1774
- debounceMs,
1775
- });
3077
+ const byteSize = this.promptAdmissionByteSize(prompt, options, scheduleKey, debounceMs);
1776
3078
  const releasePendingAdmission = this.reservePendingPromptAdmission(byteSize);
1777
3079
  let resolveSettled!: () => void;
1778
3080
  const pendingOwner: IPendingPromptAdmission = {
@@ -1807,6 +3109,20 @@ export class FlexHarness<TScope = unknown> {
1807
3109
  }
1808
3110
  this.assertStateAcceptingWork(state);
1809
3111
  const stored = this.requireSession(state, sessionId);
3112
+ const sessionKey = this.slashCommandSessionKey(state.storageKey, sessionId);
3113
+ const invocationOwner = this.slashCommandInvocationContext.getStore();
3114
+ if (invocationOwner?.sessionKey === sessionKey) {
3115
+ throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(sessionId));
3116
+ }
3117
+ if (!slashCommandAdmission && this.activeSlashCommandExecutions.has(sessionKey)) {
3118
+ throw new FlexHarnessSessionBusyError(
3119
+ sessionId,
3120
+ 'already has an active slash command execution',
3121
+ );
3122
+ }
3123
+ if (stored.pendingReversion) {
3124
+ throw new FlexHarnessSessionBusyError(sessionId, 'has a pending reversion operation');
3125
+ }
1810
3126
  if (stored.session.agent !== undefined && !subagentAdmission) {
1811
3127
  throw new FlexHarnessValidationError(
1812
3128
  'Subagent sessions can only be prompted through the foreground task tool.',
@@ -1875,6 +3191,7 @@ export class FlexHarness<TScope = unknown> {
1875
3191
  return {
1876
3192
  admission: Object.freeze({ queueId: queued.queueId, completion }),
1877
3193
  started,
3194
+ queued,
1878
3195
  };
1879
3196
  } finally {
1880
3197
  for (const signal of admissionSignals) {
@@ -2003,6 +3320,7 @@ export class FlexHarness<TScope = unknown> {
2003
3320
  const options = queued.options!;
2004
3321
  let projectionReserved = false;
2005
3322
  try {
3323
+ await this.commitRevertedBranch(run.state, run.stored, run.scopeId, run.scope as TScope);
2006
3324
  run.transaction = await this.withRunCompactorContext(
2007
3325
  run,
2008
3326
  () => run.stored.agentSession.beginGeneration(
@@ -2016,6 +3334,29 @@ export class FlexHarness<TScope = unknown> {
2016
3334
  const reservation = this.createReservation(run, prompt);
2017
3335
  await this.mutateProjection(run.state, run.stored, () => {
2018
3336
  run.stored.messages.push(reservation.userMessage, reservation.assistantMessage);
3337
+ if (run.stored.session.agent === undefined) {
3338
+ this.pruneReversionState(run.stored, true);
3339
+ const captureId = this.turnReversionProvider
3340
+ ? this.reversionId('capture', run.state.storageKey, run.sessionId, run.runId)
3341
+ : undefined;
3342
+ run.stored.reversionSegments.push({
3343
+ runId: run.runId,
3344
+ userMessageId: reservation.userMessage.messageId,
3345
+ status: 'capturing',
3346
+ contextAvailable: true,
3347
+ eventIds: [],
3348
+ workspaceCaptured: captureId !== undefined,
3349
+ ...(captureId === undefined ? {} : { captureId }),
3350
+ });
3351
+ if (captureId) {
3352
+ run.stored.pendingReversion = {
3353
+ kind: 'capture',
3354
+ runId: run.runId,
3355
+ captureId,
3356
+ state: 'preparing',
3357
+ };
3358
+ }
3359
+ }
2019
3360
  });
2020
3361
  projectionReserved = true;
2021
3362
  run.reservedUserMessage = publicSnapshot(reservation.userMessage);
@@ -2236,6 +3577,7 @@ export class FlexHarness<TScope = unknown> {
2236
3577
  if (run.controller.signal.aborted) throw run.controller.signal.reason;
2237
3578
  const result = normalizeRunResult(rawResult);
2238
3579
  if (!run.modelResolution) throw new FlexHarnessValidationError('Generation completed without a resolved model.');
3580
+ await this.finalizeRunReversion(run, 'completed');
2239
3581
  const terminal = this.buildTerminal(run, 'completed', result);
2240
3582
  try {
2241
3583
  await this.mutateProjection(run.state, run.stored, () => {
@@ -2266,6 +3608,13 @@ export class FlexHarness<TScope = unknown> {
2266
3608
  }
2267
3609
  this.emitTerminalProjection(run, terminal);
2268
3610
  this.emitFinalRunEvent(run, terminal.assistantMessage, undefined, false);
3611
+ await this.drainReversionReleases(
3612
+ run.state,
3613
+ run.stored,
3614
+ run.scopeId,
3615
+ run.scope as TScope,
3616
+ false,
3617
+ );
2269
3618
  return {
2270
3619
  runId: run.runId,
2271
3620
  sessionId: run.sessionId,
@@ -2306,6 +3655,14 @@ export class FlexHarness<TScope = unknown> {
2306
3655
  ? run.ownerCancellation!
2307
3656
  : this.projectExternalError(run, error, generated ? 'persistence' : 'agentSession');
2308
3657
  const errors: unknown[] = [safeError];
3658
+ try {
3659
+ await this.finalizeRunReversion(run, cancelled ? 'cancelled' : 'failed');
3660
+ } catch (reversionError) {
3661
+ errors.push(reversionError);
3662
+ const combined = combineErrors(errors);
3663
+ await this.fenceNamespace(run.state, run, combined);
3664
+ throw combined;
3665
+ }
2309
3666
  if (generated && !this.isTombstoned(run)) {
2310
3667
  const failedStage = this.buildTerminal(run, cancelled ? 'cancelled' : 'failed', undefined, safeError);
2311
3668
  try {
@@ -2360,6 +3717,13 @@ export class FlexHarness<TScope = unknown> {
2360
3717
  this.emitTerminalProjection(run, terminal);
2361
3718
  this.emitFinalRunEvent(run, terminal.assistantMessage, terminalError, publicStatus === 'cancelled');
2362
3719
  }
3720
+ await this.drainReversionReleases(
3721
+ run.state,
3722
+ run.stored,
3723
+ run.scopeId,
3724
+ run.scope as TScope,
3725
+ false,
3726
+ );
2363
3727
  throw errors.length > 1 ? combineErrors(errors) : terminalError;
2364
3728
  }
2365
3729
 
@@ -2369,6 +3733,7 @@ export class FlexHarness<TScope = unknown> {
2369
3733
  signal: AbortSignal,
2370
3734
  ): Promise<plugins.IAgentGenerationLease> {
2371
3735
  signal.throwIfAborted();
3736
+ await this.prepareRunReversion(run, signal);
2372
3737
  run.phase = 'running';
2373
3738
  const queued = run.stored.outstandingPromptsById.get(run.queueId);
2374
3739
  if (queued && (queued.status === 'starting' || queued.status === 'scheduled')) {
@@ -2991,84 +4356,826 @@ export class FlexHarness<TScope = unknown> {
2991
4356
  return { userMessage, assistantMessage };
2992
4357
  }
2993
4358
 
2994
- private buildTerminal(
2995
- run: IActiveRun,
2996
- status: 'completed' | 'failed' | 'cancelled',
2997
- result?: IRunResultProjection,
2998
- error?: Error,
2999
- ): IFlexTerminalProjection {
3000
- const timestamp = new Date().toISOString();
3001
- const userMessage: IFlexMessage = cloneSerializable(
3002
- run.reservedUserMessage
3003
- ?? run.stored.messages.find((message) => message.messageId === run.userMessageId)
3004
- ?? {
3005
- messageId: run.userMessageId,
3006
- sessionId: run.sessionId,
3007
- runId: run.runId,
3008
- role: 'user',
3009
- status: 'streaming',
3010
- createdAt: timestamp,
3011
- parts: [],
3012
- } satisfies IFlexMessage,
3013
- );
3014
- const assistantMessage: IFlexMessage = cloneSerializable(
3015
- run.reservedAssistantMessage
3016
- ?? run.stored.messages.find((message) => message.messageId === run.assistantMessageId)
3017
- ?? {
3018
- messageId: run.assistantMessageId,
3019
- sessionId: run.sessionId,
3020
- runId: run.runId,
3021
- role: 'assistant',
3022
- status: 'streaming',
3023
- createdAt: timestamp,
3024
- parts: [],
3025
- } satisfies IFlexMessage,
3026
- );
3027
- assistantMessage.parts = cloneSerializable(run.callbackParts);
3028
- const terminalParts = assistantMessage.parts;
3029
- for (const part of terminalParts) {
3030
- if (part.type === 'reasoning' && part.status === 'running') {
3031
- part.status = status === 'completed' ? 'completed' : 'cancelled';
3032
- } else if (part.type === 'tool' && part.status === 'running') {
3033
- part.status = status === 'cancelled' ? 'cancelled' : 'failed';
3034
- part.error = status === 'cancelled'
3035
- ? 'Tool execution was cancelled.'
3036
- : 'Tool execution ended without a terminal event.';
4359
+ private buildTerminal(
4360
+ run: IActiveRun,
4361
+ status: 'completed' | 'failed' | 'cancelled',
4362
+ result?: IRunResultProjection,
4363
+ error?: Error,
4364
+ ): IFlexTerminalProjection {
4365
+ const timestamp = new Date().toISOString();
4366
+ const userMessage: IFlexMessage = cloneSerializable(
4367
+ run.reservedUserMessage
4368
+ ?? run.stored.messages.find((message) => message.messageId === run.userMessageId)
4369
+ ?? {
4370
+ messageId: run.userMessageId,
4371
+ sessionId: run.sessionId,
4372
+ runId: run.runId,
4373
+ role: 'user',
4374
+ status: 'streaming',
4375
+ createdAt: timestamp,
4376
+ parts: [],
4377
+ } satisfies IFlexMessage,
4378
+ );
4379
+ const assistantMessage: IFlexMessage = cloneSerializable(
4380
+ run.reservedAssistantMessage
4381
+ ?? run.stored.messages.find((message) => message.messageId === run.assistantMessageId)
4382
+ ?? {
4383
+ messageId: run.assistantMessageId,
4384
+ sessionId: run.sessionId,
4385
+ runId: run.runId,
4386
+ role: 'assistant',
4387
+ status: 'streaming',
4388
+ createdAt: timestamp,
4389
+ parts: [],
4390
+ } satisfies IFlexMessage,
4391
+ );
4392
+ assistantMessage.parts = cloneSerializable(run.callbackParts);
4393
+ const terminalParts = assistantMessage.parts;
4394
+ for (const part of terminalParts) {
4395
+ if (part.type === 'reasoning' && part.status === 'running') {
4396
+ part.status = status === 'completed' ? 'completed' : 'cancelled';
4397
+ } else if (part.type === 'tool' && part.status === 'running') {
4398
+ part.status = status === 'cancelled' ? 'cancelled' : 'failed';
4399
+ part.error = status === 'cancelled'
4400
+ ? 'Tool execution was cancelled.'
4401
+ : 'Tool execution ended without a terminal event.';
4402
+ }
4403
+ }
4404
+ userMessage.status = status;
4405
+ userMessage.completedAt = timestamp;
4406
+ assistantMessage.status = status;
4407
+ assistantMessage.completedAt = timestamp;
4408
+ if (run.modelResolution) assistantMessage.model = cloneSerializable(run.modelResolution.identity);
4409
+ if (status === 'completed' && result) {
4410
+ if (!assistantMessage.parts.some((part) => part.type === 'text')) {
4411
+ assistantMessage.parts.push({
4412
+ partId: plugins.crypto.randomUUID(),
4413
+ type: 'text',
4414
+ text: truncateUtf8(result.text, this.callbackLimits.maxOutputBytes),
4415
+ });
4416
+ }
4417
+ assistantMessage.usage = cloneSerializable(result.usage);
4418
+ } else {
4419
+ const message = truncateUtf8(error?.message ?? externalErrorFallback.message, maxTransferMetadataBytes);
4420
+ userMessage.error = message;
4421
+ assistantMessage.error = message;
4422
+ }
4423
+ return {
4424
+ runId: run.runId,
4425
+ status,
4426
+ userMessage,
4427
+ assistantMessage,
4428
+ ...(run.modelResolution ? { model: cloneSerializable(run.modelResolution.identity) } : {}),
4429
+ ...(status === 'completed' && result
4430
+ ? {
4431
+ usage: cloneSerializable(result.usage),
4432
+ finishReason: result.finishReason,
4433
+ steps: result.steps,
4434
+ }
4435
+ : {}),
4436
+ };
4437
+ }
4438
+
4439
+ private reversionId(
4440
+ kind: 'capture' | 'apply',
4441
+ storageKey: string,
4442
+ sessionId: string,
4443
+ value: string,
4444
+ ): string {
4445
+ return `${kind}_${sha256Hex(JSON.stringify([storageKey, sessionId, value]))}`;
4446
+ }
4447
+
4448
+ private reversionCaptureContext(
4449
+ run: IActiveRun,
4450
+ captureId: string,
4451
+ signal: AbortSignal,
4452
+ ) {
4453
+ return Object.freeze({
4454
+ scopeId: run.scopeId,
4455
+ scope: run.scope as TScope,
4456
+ storageKey: run.state.storageKey,
4457
+ sessionId: run.sessionId,
4458
+ runId: run.runId,
4459
+ captureId,
4460
+ signal,
4461
+ });
4462
+ }
4463
+
4464
+ private async withReversionMaintenanceSignal<TResult>(
4465
+ operation: (signal: AbortSignal) => Promise<TResult> | TResult,
4466
+ ): Promise<TResult> {
4467
+ const controller = new AbortController();
4468
+ const timeoutMs = this.agentSessionPolicy.generationLeaseCleanupTimeoutMs ?? 30_000;
4469
+ const timeoutError = new Error(`Turn reversion maintenance timed out after ${timeoutMs}ms.`);
4470
+ let rejectTimeout!: (error: Error) => void;
4471
+ const timeout = new Promise<never>((_resolve, reject) => {
4472
+ rejectTimeout = reject;
4473
+ });
4474
+ const timer = setTimeout(() => {
4475
+ controller.abort(timeoutError);
4476
+ rejectTimeout(timeoutError);
4477
+ }, timeoutMs);
4478
+ const completion = Promise.resolve().then(() => operation(controller.signal));
4479
+ void completion.catch(() => undefined);
4480
+ try {
4481
+ return await Promise.race([completion, timeout]);
4482
+ } finally {
4483
+ clearTimeout(timer);
4484
+ }
4485
+ }
4486
+
4487
+ private inspectReversionCapture(
4488
+ scopeId: string,
4489
+ scope: TScope,
4490
+ storageKey: string,
4491
+ sessionId: string,
4492
+ runId: string,
4493
+ captureId: string,
4494
+ ) {
4495
+ return this.withReversionMaintenanceSignal((signal) =>
4496
+ this.turnReversionProvider!.inspectCapture(Object.freeze({
4497
+ scopeId,
4498
+ scope,
4499
+ storageKey,
4500
+ sessionId,
4501
+ runId,
4502
+ captureId,
4503
+ signal,
4504
+ })));
4505
+ }
4506
+
4507
+ private finalizeReversionCapture(
4508
+ scopeId: string,
4509
+ scope: TScope,
4510
+ storageKey: string,
4511
+ sessionId: string,
4512
+ runId: string,
4513
+ captureId: string,
4514
+ ) {
4515
+ return this.withReversionMaintenanceSignal((signal) =>
4516
+ this.turnReversionProvider!.finalize(Object.freeze({
4517
+ scopeId,
4518
+ scope,
4519
+ storageKey,
4520
+ sessionId,
4521
+ runId,
4522
+ captureId,
4523
+ signal,
4524
+ })));
4525
+ }
4526
+
4527
+ private async resolveReversionCaptureReference(
4528
+ state: IStorageState,
4529
+ scopeId: string,
4530
+ scope: TScope,
4531
+ sessionId: string,
4532
+ runId: string,
4533
+ captureId: string,
4534
+ durableState: 'preparing' | 'prepared' | 'finalizing',
4535
+ ): Promise<TJsonValue | undefined> {
4536
+ let inspection;
4537
+ try {
4538
+ inspection = await this.inspectReversionCapture(
4539
+ scopeId,
4540
+ scope,
4541
+ state.storageKey,
4542
+ sessionId,
4543
+ runId,
4544
+ captureId,
4545
+ );
4546
+ } catch (error) {
4547
+ state.lifecycle = 'fenced';
4548
+ throw this.projectOperationError(error, 'turnReversion', scopeId, sessionId, captureId);
4549
+ }
4550
+ if (inspection.status === 'unknown') {
4551
+ state.lifecycle = 'fenced';
4552
+ throw new FlexHarnessValidationError('Reversion capture outcome is unknown.');
4553
+ }
4554
+ if (inspection.status === 'missing') {
4555
+ if (durableState === 'preparing') return undefined;
4556
+ state.lifecycle = 'fenced';
4557
+ throw new FlexHarnessValidationError('Prepared reversion capture is missing.');
4558
+ }
4559
+ let reference = inspection.reference;
4560
+ if (inspection.status === 'prepared') {
4561
+ try {
4562
+ reference = await this.finalizeReversionCapture(
4563
+ scopeId,
4564
+ scope,
4565
+ state.storageKey,
4566
+ sessionId,
4567
+ runId,
4568
+ captureId,
4569
+ );
4570
+ } catch (error) {
4571
+ const finalInspection = await this.inspectReversionCapture(
4572
+ scopeId,
4573
+ scope,
4574
+ state.storageKey,
4575
+ sessionId,
4576
+ runId,
4577
+ captureId,
4578
+ );
4579
+ if (finalInspection.status !== 'finalized' || finalInspection.reference === undefined) {
4580
+ if (finalInspection.status === 'unknown') state.lifecycle = 'fenced';
4581
+ throw this.projectOperationError(error, 'turnReversion', scopeId, sessionId, captureId);
4582
+ }
4583
+ reference = finalInspection.reference;
4584
+ }
4585
+ }
4586
+ if (reference === undefined) {
4587
+ state.lifecycle = 'fenced';
4588
+ throw new FlexHarnessValidationError('Finalized reversion capture has no reference.');
4589
+ }
4590
+ return this.normalizeReversionReference(reference);
4591
+ }
4592
+
4593
+ private normalizeReversionReference(reference: unknown): TJsonValue {
4594
+ return normalizeJsonValue(reference, {
4595
+ maxDepth: this.toolOutputLimits.maxDepth,
4596
+ maxBytes: Math.min(
4597
+ this.toolOutputLimits.maxBytes,
4598
+ FLEX_REVERSION_REFERENCE_MAX_BYTES,
4599
+ ),
4600
+ });
4601
+ }
4602
+
4603
+ private async prepareRunReversion(run: IActiveRun, signal: AbortSignal): Promise<void> {
4604
+ if (run.stored.session.agent !== undefined || !this.turnReversionProvider) return;
4605
+ const segment = run.stored.reversionSegments.find((entry) => entry.runId === run.runId);
4606
+ const captureId = segment?.captureId;
4607
+ if (!segment || !captureId) {
4608
+ throw this.projectExternalError(
4609
+ run,
4610
+ new Error('Root generation is missing its durable reversion capture intent.'),
4611
+ 'turnReversion',
4612
+ );
4613
+ }
4614
+ const context = this.reversionCaptureContext(run, captureId, signal);
4615
+ try {
4616
+ await this.turnReversionProvider.prepare(context);
4617
+ } catch (error) {
4618
+ let inspection;
4619
+ try {
4620
+ inspection = await this.inspectReversionCapture(
4621
+ run.scopeId,
4622
+ run.scope as TScope,
4623
+ run.state.storageKey,
4624
+ run.sessionId,
4625
+ run.runId,
4626
+ captureId,
4627
+ );
4628
+ } catch (inspectionError) {
4629
+ run.state.lifecycle = 'fenced';
4630
+ throw this.projectExternalError(run, inspectionError, 'turnReversion');
4631
+ }
4632
+ if (inspection.status !== 'prepared' && inspection.status !== 'finalized') {
4633
+ if (inspection.status === 'unknown') run.state.lifecycle = 'fenced';
4634
+ throw this.projectExternalError(run, error, 'turnReversion');
4635
+ }
4636
+ }
4637
+ await this.mutateProjection(run.state, run.stored, () => {
4638
+ const pending = run.stored.pendingReversion;
4639
+ if (pending?.kind !== 'capture' || pending.runId !== run.runId) {
4640
+ throw new FlexHarnessValidationError('Reversion capture intent changed during preparation.');
4641
+ }
4642
+ pending.state = 'prepared';
4643
+ }, true);
4644
+ }
4645
+
4646
+ private async finalizeRunReversion(
4647
+ run: IActiveRun,
4648
+ status: 'completed' | 'failed' | 'cancelled',
4649
+ ): Promise<void> {
4650
+ if (run.stored.session.agent !== undefined) return;
4651
+ const segment = run.stored.reversionSegments.find((entry) => entry.runId === run.runId);
4652
+ if (!segment) return;
4653
+ const eventIds = run.stored.agentSession.getEvents()
4654
+ .filter((event) => event.generationId === run.runId)
4655
+ .map((event) => event.id);
4656
+ if (!this.turnReversionProvider || !segment.captureId) {
4657
+ await this.mutateProjection(run.state, run.stored, () => {
4658
+ const wasCompleted = segment.status === 'completed';
4659
+ segment.status = status;
4660
+ segment.eventIds = eventIds;
4661
+ if (!wasCompleted && status === 'completed') run.stored.revertCursor++;
4662
+ else if (wasCompleted && status !== 'completed') run.stored.revertCursor--;
4663
+ delete run.stored.pendingReversion;
4664
+ this.pruneReversionState(run.stored);
4665
+ }, true);
4666
+ return;
4667
+ }
4668
+ const captureId = segment.captureId;
4669
+ if (segment.workspaceReference !== undefined) {
4670
+ await this.mutateProjection(run.state, run.stored, () => {
4671
+ const wasCompleted = segment.status === 'completed';
4672
+ segment.status = status;
4673
+ segment.eventIds = eventIds;
4674
+ if (!wasCompleted && status === 'completed') run.stored.revertCursor++;
4675
+ else if (wasCompleted && status !== 'completed') run.stored.revertCursor--;
4676
+ delete run.stored.pendingReversion;
4677
+ }, true);
4678
+ return;
4679
+ }
4680
+ const pending = run.stored.pendingReversion;
4681
+ if (pending?.kind !== 'capture' || pending.runId !== run.runId) {
4682
+ throw new FlexHarnessValidationError('Reversion capture intent changed before finalization.');
4683
+ }
4684
+ const reference = await this.resolveReversionCaptureReference(
4685
+ run.state,
4686
+ run.scopeId,
4687
+ run.scope as TScope,
4688
+ run.sessionId,
4689
+ run.runId,
4690
+ captureId,
4691
+ pending.state,
4692
+ );
4693
+ if (reference === undefined) {
4694
+ await this.mutateProjection(run.state, run.stored, () => {
4695
+ run.stored.reversionSegments = run.stored.reversionSegments.filter(
4696
+ (entry) => entry.runId !== run.runId,
4697
+ );
4698
+ delete run.stored.pendingReversion;
4699
+ }, true);
4700
+ return;
4701
+ }
4702
+ await this.mutateProjection(run.state, run.stored, () => {
4703
+ const current = run.stored.pendingReversion;
4704
+ if (current?.kind !== 'capture' || current.runId !== run.runId) {
4705
+ throw new FlexHarnessValidationError('Reversion capture intent changed before finalization.');
4706
+ }
4707
+ const wasCompleted = segment.status === 'completed';
4708
+ segment.status = status;
4709
+ segment.eventIds = eventIds;
4710
+ segment.workspaceReference = reference;
4711
+ if (!wasCompleted && status === 'completed') run.stored.revertCursor++;
4712
+ else if (wasCompleted && status !== 'completed') run.stored.revertCursor--;
4713
+ delete run.stored.pendingReversion;
4714
+ this.pruneReversionState(run.stored);
4715
+ }, true);
4716
+ }
4717
+
4718
+ private hiddenReversionSegments(stored: IStoredSessionState): IFlexReversionSegment[] {
4719
+ const candidates = this.completedReversionCandidates(stored);
4720
+ const firstHidden = candidates[stored.revertCursor];
4721
+ if (!firstHidden) return [];
4722
+ const index = stored.reversionSegments.findIndex((segment) => segment.runId === firstHidden.runId);
4723
+ if (index < 0) return [];
4724
+ return stored.reversionSegments.slice(firstHidden === candidates[0] ? 0 : index);
4725
+ }
4726
+
4727
+ private enqueueReversionRelease(
4728
+ stored: IStoredSessionState,
4729
+ segment: IFlexReversionSegment,
4730
+ ): void {
4731
+ if (!segment.workspaceCaptured) return;
4732
+ if (segment.captureId === undefined || segment.workspaceReference === undefined) {
4733
+ throw new FlexHarnessValidationError('Capture-backed segment has no releasable workspace reference.');
4734
+ }
4735
+ if (stored.pendingReversionReleases.some((release) => release.captureId === segment.captureId)) {
4736
+ return;
4737
+ }
4738
+ if (stored.pendingReversionReleases.length >= this.reversionLimits.maxPendingReversionReleases) {
4739
+ throw new FlexHarnessValidationError('Pending reversion release limit was reached.');
4740
+ }
4741
+ stored.pendingReversionReleases.push({
4742
+ runId: segment.runId,
4743
+ captureId: segment.captureId,
4744
+ reference: cloneSerializable(segment.workspaceReference),
4745
+ });
4746
+ }
4747
+
4748
+ private pruneReversionState(
4749
+ stored: IStoredSessionState,
4750
+ reserveSegment = false,
4751
+ dropNonUndoablePrefix = false,
4752
+ ): void {
4753
+ if (stored.pendingReversion || stored.revertCursor !== this.completedReversionCandidates(stored).length) {
4754
+ return;
4755
+ }
4756
+ while (stored.reversionSegments.length > 0) {
4757
+ const candidates = this.completedReversionCandidates(stored);
4758
+ const overLimit = candidates.length + (reserveSegment ? 1 : 0)
4759
+ > this.reversionLimits.maxCompletedTurns
4760
+ || stored.reversionSegments.length + (reserveSegment ? 1 : 0)
4761
+ > this.reversionLimits.maxSegments;
4762
+ const firstCandidate = candidates[0];
4763
+ const nextCandidate = candidates[1];
4764
+ let end = 0;
4765
+ if (!firstCandidate) {
4766
+ if (dropNonUndoablePrefix) end = stored.reversionSegments.length;
4767
+ else if (overLimit) {
4768
+ end = Math.max(
4769
+ 1,
4770
+ stored.reversionSegments.length + (reserveSegment ? 1 : 0)
4771
+ - this.reversionLimits.maxSegments,
4772
+ );
4773
+ } else break;
4774
+ } else if (!firstCandidate.contextAvailable) {
4775
+ end = nextCandidate
4776
+ ? stored.reversionSegments.findIndex((segment) => segment.runId === nextCandidate.runId)
4777
+ : stored.reversionSegments.length;
4778
+ } else if (overLimit) {
4779
+ end = nextCandidate
4780
+ ? stored.reversionSegments.findIndex((segment) => segment.runId === nextCandidate.runId)
4781
+ : stored.reversionSegments.length;
4782
+ } else {
4783
+ break;
4784
+ }
4785
+ if (end <= 0) break;
4786
+ const prefix = stored.reversionSegments.slice(0, end);
4787
+ if (prefix.some((segment) => segment.workspaceCaptured && segment.workspaceReference === undefined)) break;
4788
+ for (const segment of prefix) this.enqueueReversionRelease(stored, segment);
4789
+ stored.reversionSegments.splice(0, end);
4790
+ stored.revertCursor = Math.max(
4791
+ 0,
4792
+ stored.revertCursor - prefix.filter((segment) => segment.status === 'completed').length,
4793
+ );
4794
+ }
4795
+ if (stored.excludedRunIds.length > this.reversionLimits.maxExcludedRunIds) {
4796
+ throw new FlexHarnessValidationError('Excluded reversion run limit was reached.');
4797
+ }
4798
+ }
4799
+
4800
+ private async commitRevertedBranch(
4801
+ state: IStorageState,
4802
+ stored: IStoredSessionState,
4803
+ scopeId: string,
4804
+ scope: TScope,
4805
+ ): Promise<void> {
4806
+ if (stored.pendingReversionReleases.length > 0) {
4807
+ await this.drainReversionReleases(state, stored, scopeId, scope, false);
4808
+ this.assertStateAcceptingWork(state);
4809
+ }
4810
+ if (this.hiddenReversionSegments(stored).length === 0) {
4811
+ return;
4812
+ }
4813
+ await this.mutateProjection(state, stored, () => this.commitRevertedBranchState(stored), true);
4814
+ this.emitEvent(scopeId, stored.session.sessionId, {
4815
+ type: 'session.history.changed',
4816
+ direction: 'branch',
4817
+ });
4818
+ await this.drainReversionReleases(state, stored, scopeId, scope, false);
4819
+ }
4820
+
4821
+ private commitRevertedBranchState(stored: IStoredSessionState): boolean {
4822
+ const hidden = this.hiddenReversionSegments(stored);
4823
+ if (hidden.length === 0) return false;
4824
+ const hiddenRunIds = new Set(hidden.map((segment) => segment.runId));
4825
+ stored.messages = stored.messages.filter((message) => !hiddenRunIds.has(message.runId));
4826
+ stored.stagedTerminals = stored.stagedTerminals.filter(
4827
+ (terminal) => !hiddenRunIds.has(terminal.runId),
4828
+ );
4829
+ for (const segment of hidden) {
4830
+ this.enqueueReversionRelease(stored, segment);
4831
+ if (!stored.excludedRunIds.includes(segment.runId)) stored.excludedRunIds.push(segment.runId);
4832
+ }
4833
+ stored.reversionSegments = stored.reversionSegments.filter(
4834
+ (segment) => !hiddenRunIds.has(segment.runId),
4835
+ );
4836
+ stored.revertCursor = this.completedReversionCandidates(stored).length;
4837
+ this.pruneReversionState(stored);
4838
+ return true;
4839
+ }
4840
+
4841
+ private commitArchivedHiddenBranchState(stored: IStoredSessionState): boolean {
4842
+ const hidden = this.hiddenReversionSegments(stored);
4843
+ return hidden.some((segment) => !segment.contextAvailable)
4844
+ ? this.commitRevertedBranchState(stored)
4845
+ : false;
4846
+ }
4847
+
4848
+ private filterReversionContextEvents(
4849
+ stored: IStoredSessionState,
4850
+ events: readonly plugins.TAgentEvent[],
4851
+ ): plugins.TAgentEvent[] {
4852
+ const hiddenRunIds = new Set([
4853
+ ...this.hiddenReversionSegments(stored).map((segment) => segment.runId),
4854
+ ...stored.excludedRunIds,
4855
+ ]);
4856
+ const hiddenRawIds = new Set([
4857
+ ...this.hiddenReversionSegments(stored).flatMap((segment) => segment.eventIds),
4858
+ ...events.filter((event) => event.generationId && hiddenRunIds.has(event.generationId))
4859
+ .map((event) => event.id),
4860
+ ]);
4861
+ const taintedCompactionIds = new Set<string>();
4862
+ let changed = true;
4863
+ while (changed) {
4864
+ changed = false;
4865
+ for (const event of events) {
4866
+ if (event.type !== 'context-compaction' || taintedCompactionIds.has(event.id)) continue;
4867
+ if (
4868
+ event.coveredEventIds.some((id) => hiddenRawIds.has(id) || taintedCompactionIds.has(id))
4869
+ || event.archivedTransactions?.some((transaction) =>
4870
+ hiddenRunIds.has(transaction.generationId))
4871
+ ) {
4872
+ taintedCompactionIds.add(event.id);
4873
+ changed = true;
4874
+ }
4875
+ }
4876
+ }
4877
+ return events.filter((event) =>
4878
+ !(event.generationId && hiddenRunIds.has(event.generationId))
4879
+ && !taintedCompactionIds.has(event.id));
4880
+ }
4881
+
4882
+ private async drainReversionReleases(
4883
+ state: IStorageState,
4884
+ stored: IStoredSessionState,
4885
+ scopeId: string,
4886
+ scope: TScope,
4887
+ throwOnFailure: boolean,
4888
+ ): Promise<void> {
4889
+ await this.reconcileProjectionFromStore(stored);
4890
+ if (!this.turnReversionProvider) {
4891
+ if (stored.pendingReversionReleases.length > 0) {
4892
+ throw new FlexHarnessValidationError(
4893
+ 'Pending workspace capture releases require their turn reversion provider.',
4894
+ );
4895
+ }
4896
+ return;
4897
+ }
4898
+ while (stored.pendingReversionReleases.length > 0) {
4899
+ const release = stored.pendingReversionReleases[0];
4900
+ try {
4901
+ await this.withReversionMaintenanceSignal((signal) =>
4902
+ this.turnReversionProvider!.release(Object.freeze({
4903
+ scopeId,
4904
+ scope,
4905
+ storageKey: state.storageKey,
4906
+ sessionId: stored.session.sessionId,
4907
+ runId: release.runId,
4908
+ captureId: release.captureId,
4909
+ reference: cloneSerializable(release.reference),
4910
+ signal,
4911
+ })));
4912
+ } catch (error) {
4913
+ if (throwOnFailure) {
4914
+ throw this.projectOperationError(
4915
+ error,
4916
+ 'turnReversion',
4917
+ scopeId,
4918
+ stored.session.sessionId,
4919
+ `release:${release.captureId}`,
4920
+ );
4921
+ }
4922
+ return;
4923
+ }
4924
+ try {
4925
+ await this.mutateProjection(state, stored, () => {
4926
+ if (stored.pendingReversionReleases[0]?.captureId === release.captureId) {
4927
+ stored.pendingReversionReleases.shift();
4928
+ }
4929
+ }, true);
4930
+ } catch (error) {
4931
+ if (!throwOnFailure && state.lifecycle === 'fenced') {
4932
+ this.deferReversionReleaseDrain(state, stored, scopeId, scope);
4933
+ }
4934
+ if (throwOnFailure) throw error;
4935
+ return;
4936
+ }
4937
+ if (stored.projectionReconciliationRequired) return;
4938
+ }
4939
+ }
4940
+
4941
+ private async releaseDeletedSessionReversions(
4942
+ state: IStorageState,
4943
+ storageKey: string,
4944
+ sessionId: string,
4945
+ scopeId: string,
4946
+ scope: TScope,
4947
+ stored?: IStoredSessionState,
4948
+ ): Promise<void> {
4949
+ if (stored) await stored.projectionQueue;
4950
+ const loadedProjection = await this.stores.projections.load(storageKey, sessionId);
4951
+ if (loadedProjection?.schemaVersion !== 2) return;
4952
+ let projection: IFlexProjectionSnapshotCurrent = loadedProjection;
4953
+ if (projection.pendingReversion?.kind === 'apply') {
4954
+ if (stored) {
4955
+ await this.resumePendingApply(state, stored, scopeId, scope);
4956
+ } else {
4957
+ const pending = projection.pendingReversion;
4958
+ const orderedRunIds = pending.direction === 'undo'
4959
+ ? [...pending.segmentRunIds].reverse()
4960
+ : [...pending.segmentRunIds];
4961
+ for (const runId of orderedRunIds) {
4962
+ if (pending.appliedRunIds.includes(runId)) continue;
4963
+ const segment = projection.reversionSegments.find((entry) => entry.runId === runId)!;
4964
+ if (segment.workspaceCaptured) {
4965
+ if (!this.turnReversionProvider || !segment.captureId || segment.workspaceReference === undefined) {
4966
+ throw new FlexHarnessValidationError(
4967
+ 'Pending workspace reversion deletion requires its turn reversion provider.',
4968
+ );
4969
+ }
4970
+ const captureId = segment.captureId;
4971
+ const reference = segment.workspaceReference;
4972
+ const operationId = `${pending.operationId}:${sha256Hex(runId).slice(0, 16)}`;
4973
+ const createContext = (signal: AbortSignal) => Object.freeze({
4974
+ scopeId,
4975
+ scope,
4976
+ storageKey,
4977
+ sessionId,
4978
+ runId,
4979
+ captureId,
4980
+ reference: cloneSerializable(reference),
4981
+ operationId,
4982
+ direction: pending.direction,
4983
+ signal,
4984
+ });
4985
+ let inspection = await this.withReversionMaintenanceSignal((signal) =>
4986
+ this.turnReversionProvider!.inspectApply(createContext(signal)));
4987
+ if (inspection.status === 'unknown') {
4988
+ throw new FlexHarnessValidationError('Workspace reversion apply outcome is unknown.');
4989
+ }
4990
+ if (inspection.status === 'not-applied') {
4991
+ try {
4992
+ await this.withReversionMaintenanceSignal((signal) =>
4993
+ this.turnReversionProvider!.apply(createContext(signal)));
4994
+ } catch (error) {
4995
+ inspection = await this.withReversionMaintenanceSignal((signal) =>
4996
+ this.turnReversionProvider!.inspectApply(createContext(signal)));
4997
+ if (inspection.status !== 'applied') throw error;
4998
+ }
4999
+ }
5000
+ }
5001
+ pending.appliedRunIds.push(runId);
5002
+ const next = { ...projection, revision: projection.revision + 1 };
5003
+ await this.stores.projections.save(storageKey, sessionId, next, projection.revision);
5004
+ projection = next;
5005
+ }
5006
+ const { pendingReversion: _pendingReversion, ...completedProjection } = projection;
5007
+ const completed: IFlexProjectionSnapshotCurrent = {
5008
+ ...completedProjection,
5009
+ revision: projection.revision + 1,
5010
+ revertCursor: pending.toCursor,
5011
+ };
5012
+ await this.stores.projections.save(storageKey, sessionId, completed, projection.revision);
5013
+ projection = completed;
5014
+ }
5015
+ const recoveredProjection = await this.stores.projections.load(storageKey, sessionId);
5016
+ if (recoveredProjection?.schemaVersion !== 2) return;
5017
+ projection = recoveredProjection;
5018
+ }
5019
+ const hasWorkspaceCaptures = projection.reversionSegments.some((segment) => segment.captureId)
5020
+ || projection.pendingReversionReleases.length > 0
5021
+ || projection.pendingReversion?.kind === 'capture';
5022
+ if (!this.turnReversionProvider) {
5023
+ if (hasWorkspaceCaptures) {
5024
+ throw new FlexHarnessValidationError(
5025
+ 'Capture-backed session deletion requires its turn reversion provider.',
5026
+ );
3037
5027
  }
5028
+ return;
3038
5029
  }
3039
- userMessage.status = status;
3040
- userMessage.completedAt = timestamp;
3041
- assistantMessage.status = status;
3042
- assistantMessage.completedAt = timestamp;
3043
- if (run.modelResolution) assistantMessage.model = cloneSerializable(run.modelResolution.identity);
3044
- if (status === 'completed' && result) {
3045
- if (!assistantMessage.parts.some((part) => part.type === 'text')) {
3046
- assistantMessage.parts.push({
3047
- partId: plugins.crypto.randomUUID(),
3048
- type: 'text',
3049
- text: truncateUtf8(result.text, this.callbackLimits.maxOutputBytes),
5030
+ const releases = new Map<string, IFlexPendingReversionRelease>();
5031
+ for (const release of projection.pendingReversionReleases) {
5032
+ releases.set(release.captureId, cloneSerializable(release));
5033
+ }
5034
+ for (const segment of projection.reversionSegments) {
5035
+ if (segment.captureId && segment.workspaceReference !== undefined) {
5036
+ releases.set(segment.captureId, {
5037
+ runId: segment.runId,
5038
+ captureId: segment.captureId,
5039
+ reference: cloneSerializable(segment.workspaceReference),
3050
5040
  });
3051
5041
  }
3052
- assistantMessage.usage = cloneSerializable(result.usage);
3053
- } else {
3054
- const message = truncateUtf8(error?.message ?? externalErrorFallback.message, maxTransferMetadataBytes);
3055
- userMessage.error = message;
3056
- assistantMessage.error = message;
3057
5042
  }
3058
- return {
3059
- runId: run.runId,
3060
- status,
3061
- userMessage,
3062
- assistantMessage,
3063
- ...(run.modelResolution ? { model: cloneSerializable(run.modelResolution.identity) } : {}),
3064
- ...(status === 'completed' && result
3065
- ? {
3066
- usage: cloneSerializable(result.usage),
3067
- finishReason: result.finishReason,
3068
- steps: result.steps,
5043
+ const unresolvedCaptures = new Map<string, {
5044
+ runId: string;
5045
+ captureId: string;
5046
+ durableState?: 'preparing' | 'prepared' | 'finalizing';
5047
+ }>();
5048
+ for (const segment of projection.reversionSegments) {
5049
+ if (segment.captureId && !releases.has(segment.captureId)) {
5050
+ unresolvedCaptures.set(segment.captureId, {
5051
+ runId: segment.runId,
5052
+ captureId: segment.captureId,
5053
+ });
5054
+ }
5055
+ }
5056
+ if (projection.pendingReversion?.kind === 'capture') {
5057
+ unresolvedCaptures.set(projection.pendingReversion.captureId, {
5058
+ runId: projection.pendingReversion.runId,
5059
+ captureId: projection.pendingReversion.captureId,
5060
+ durableState: projection.pendingReversion.state,
5061
+ });
5062
+ }
5063
+ for (const capture of unresolvedCaptures.values()) {
5064
+ let inspection;
5065
+ try {
5066
+ inspection = await this.inspectReversionCapture(
5067
+ scopeId,
5068
+ scope,
5069
+ storageKey,
5070
+ sessionId,
5071
+ capture.runId,
5072
+ capture.captureId,
5073
+ );
5074
+ } catch (error) {
5075
+ throw this.projectOperationError(
5076
+ error,
5077
+ 'turnReversion',
5078
+ scopeId,
5079
+ sessionId,
5080
+ `release:${capture.captureId}`,
5081
+ );
5082
+ }
5083
+ if (inspection.status === 'unknown') {
5084
+ throw new FlexHarnessValidationError('Deleted session capture outcome is unknown.');
5085
+ }
5086
+ if (inspection.status === 'missing') {
5087
+ if (capture.durableState === 'preparing') continue;
5088
+ state.lifecycle = 'fenced';
5089
+ throw new FlexHarnessValidationError('Prepared deleted-session capture is missing.');
5090
+ }
5091
+ let reference = inspection.reference;
5092
+ if (inspection.status === 'prepared') {
5093
+ try {
5094
+ reference = await this.finalizeReversionCapture(
5095
+ scopeId,
5096
+ scope,
5097
+ storageKey,
5098
+ sessionId,
5099
+ capture.runId,
5100
+ capture.captureId,
5101
+ );
5102
+ } catch (error) {
5103
+ const finalInspection = await this.inspectReversionCapture(
5104
+ scopeId,
5105
+ scope,
5106
+ storageKey,
5107
+ sessionId,
5108
+ capture.runId,
5109
+ capture.captureId,
5110
+ );
5111
+ if (finalInspection.status !== 'finalized' || finalInspection.reference === undefined) {
5112
+ throw this.projectOperationError(
5113
+ error,
5114
+ 'turnReversion',
5115
+ scopeId,
5116
+ sessionId,
5117
+ `release:${capture.captureId}`,
5118
+ );
3069
5119
  }
3070
- : {}),
3071
- };
5120
+ reference = finalInspection.reference;
5121
+ }
5122
+ }
5123
+ if (reference === undefined) {
5124
+ throw new FlexHarnessValidationError('Deleted session capture has no release reference.');
5125
+ }
5126
+ releases.set(capture.captureId, {
5127
+ runId: capture.runId,
5128
+ captureId: capture.captureId,
5129
+ reference: this.normalizeReversionReference(reference),
5130
+ });
5131
+ }
5132
+ if (stored) {
5133
+ await this.mutateProjection(state, stored, () => {
5134
+ for (const release of releases.values()) {
5135
+ if (!stored.pendingReversionReleases.some((entry) => entry.captureId === release.captureId)) {
5136
+ stored.pendingReversionReleases.push(cloneSerializable(release));
5137
+ }
5138
+ }
5139
+ }, true);
5140
+ } else {
5141
+ const missing = [...releases.values()].filter((release) =>
5142
+ !projection.pendingReversionReleases.some((entry) => entry.captureId === release.captureId));
5143
+ if (missing.length > 0) {
5144
+ const next: IFlexProjectionSnapshotCurrent = {
5145
+ ...projection,
5146
+ revision: projection.revision + 1,
5147
+ pendingReversionReleases: [
5148
+ ...projection.pendingReversionReleases,
5149
+ ...missing.map((release) => cloneSerializable(release)),
5150
+ ],
5151
+ };
5152
+ await this.stores.projections.save(storageKey, sessionId, next, projection.revision);
5153
+ projection = next;
5154
+ }
5155
+ }
5156
+ for (const release of releases.values()) {
5157
+ try {
5158
+ await this.withReversionMaintenanceSignal((signal) =>
5159
+ this.turnReversionProvider!.release(Object.freeze({
5160
+ scopeId,
5161
+ scope,
5162
+ storageKey,
5163
+ sessionId,
5164
+ runId: release.runId,
5165
+ captureId: release.captureId,
5166
+ reference: cloneSerializable(release.reference),
5167
+ signal,
5168
+ })));
5169
+ } catch (error) {
5170
+ throw this.projectOperationError(
5171
+ error,
5172
+ 'turnReversion',
5173
+ scopeId,
5174
+ sessionId,
5175
+ `release:${release.captureId}`,
5176
+ );
5177
+ }
5178
+ }
3072
5179
  }
3073
5180
 
3074
5181
  private async promoteCompletedTerminal(
@@ -3688,6 +5795,13 @@ export class FlexHarness<TScope = unknown> {
3688
5795
  await this.mutateProjection(run.state, run.stored, () => {
3689
5796
  run.stored.messages = run.stored.messages.filter((message) => message.runId !== run.runId);
3690
5797
  run.stored.stagedTerminals = run.stored.stagedTerminals.filter((entry) => entry.runId !== run.runId);
5798
+ run.stored.reversionSegments = run.stored.reversionSegments.filter(
5799
+ (segment) => segment.runId !== run.runId,
5800
+ );
5801
+ if (
5802
+ run.stored.pendingReversion?.kind === 'capture'
5803
+ && run.stored.pendingReversion.runId === run.runId
5804
+ ) delete run.stored.pendingReversion;
3691
5805
  }, true);
3692
5806
  } catch (error) {
3693
5807
  errors.push(this.projectExternalError(run, error, 'persistence'));
@@ -4060,6 +6174,7 @@ export class FlexHarness<TScope = unknown> {
4060
6174
  storageKey,
4061
6175
  compactorLifecycleController,
4062
6176
  scopeIdHint: scopeId,
6177
+ scopeContext: { scopeId, scope },
4063
6178
  revision: snapshot.revision,
4064
6179
  sessions: new Map(),
4065
6180
  retainedSessionCleanups: new Map(),
@@ -4084,7 +6199,14 @@ export class FlexHarness<TScope = unknown> {
4084
6199
  const stored = await this.loadSessionRuntime(state, metadata, scopeId, scope);
4085
6200
  state.sessions.set(metadata.sessionId, stored);
4086
6201
  loadedSessions.push(stored);
4087
- scopeChanged = (await this.repairLoadedSession(state.storageKey, stored)) || scopeChanged;
6202
+ scopeChanged = (await this.repairLoadedSession(
6203
+ state.storageKey,
6204
+ stored,
6205
+ state,
6206
+ scopeId,
6207
+ scope,
6208
+ )) || scopeChanged;
6209
+ await this.drainReversionReleases(state, stored, scopeId, scope, false);
4088
6210
  if (stored.projectionRevision < 0) throw new Error('Invalid projection revision.');
4089
6211
  }
4090
6212
  for (const stored of state.sessions.values()) {
@@ -4115,6 +6237,13 @@ export class FlexHarness<TScope = unknown> {
4115
6237
  try {
4116
6238
  const group = this.tombstoneGroup(state, rootSessionId);
4117
6239
  for (const tombstone of group) {
6240
+ await this.releaseDeletedSessionReversions(
6241
+ state,
6242
+ storageKey,
6243
+ tombstone.sessionId,
6244
+ scopeId,
6245
+ scope,
6246
+ );
4118
6247
  await this.cleanupSessionDomains(storageKey, tombstone.sessionId);
4119
6248
  }
4120
6249
  for (const tombstone of group) state.tombstones.delete(tombstone.sessionId);
@@ -4130,6 +6259,16 @@ export class FlexHarness<TScope = unknown> {
4130
6259
  }
4131
6260
  return state;
4132
6261
  } catch (error) {
6262
+ for (const rootSessionId of new Set([...state.tombstones.values()].map((tombstone) =>
6263
+ tombstone.rootSessionId ?? tombstone.sessionId))) {
6264
+ const key = JSON.stringify([storageKey, rootSessionId]);
6265
+ this.orphanedTombstoneOwners.set(key, {
6266
+ state,
6267
+ rootSessionId,
6268
+ scopeId,
6269
+ scope,
6270
+ });
6271
+ }
4133
6272
  const closeResults = await Promise.allSettled(
4134
6273
  loadedSessions.map((stored) => this.closeStoredSession(stored)),
4135
6274
  );
@@ -4162,6 +6301,12 @@ export class FlexHarness<TScope = unknown> {
4162
6301
  session,
4163
6302
  messages: [],
4164
6303
  stagedTerminals: [],
6304
+ reversionSegments: [],
6305
+ revertCursor: 0,
6306
+ excludedRunIds: [],
6307
+ pendingReversionReleases: [],
6308
+ projectionSchemaVersion: 2,
6309
+ projectionReconciliationRequired: false,
4165
6310
  projectionRevision: 0,
4166
6311
  projectionQueue: Promise.resolve(),
4167
6312
  rememberedPermissionKeys: new Set(),
@@ -4228,6 +6373,7 @@ export class FlexHarness<TScope = unknown> {
4228
6373
  throw combineErrors(acquisitionErrors);
4229
6374
  }
4230
6375
  const projection = projectionResult.value;
6376
+ const projectionV2 = projection?.schemaVersion === 2 ? projection : undefined;
4231
6377
  const permission = permissionResult.value;
4232
6378
  const eventStore = eventStoreResult.value;
4233
6379
  const jobStore = jobStoreResult.value;
@@ -4258,6 +6404,20 @@ export class FlexHarness<TScope = unknown> {
4258
6404
  session: cloneSerializable(metadata),
4259
6405
  messages: cloneSerializable(projection?.messages ?? []),
4260
6406
  stagedTerminals: cloneSerializable(projection?.stagedTerminals ?? []),
6407
+ reversionSegments: cloneSerializable(projectionV2?.reversionSegments ?? []),
6408
+ revertCursor: projectionV2?.revertCursor ?? 0,
6409
+ excludedRunIds: cloneSerializable(projectionV2?.excludedRunIds ?? []),
6410
+ ...(projectionV2?.pendingReversion === undefined
6411
+ ? {}
6412
+ : { pendingReversion: cloneSerializable(projectionV2.pendingReversion) }),
6413
+ pendingReversionReleases: cloneSerializable(
6414
+ projectionV2?.pendingReversionReleases ?? [],
6415
+ ),
6416
+ projectionSchemaVersion: projection?.schemaVersion ?? 2,
6417
+ projectionReconciliationRequired: false,
6418
+ ...(projection?.schemaVersion === 1
6419
+ ? { projectionBaseline: cloneSerializable(projection) }
6420
+ : {}),
4261
6421
  projectionRevision: projection?.revision ?? 0,
4262
6422
  projectionQueue: Promise.resolve(),
4263
6423
  rememberedPermissionKeys: new Set(permission?.rememberedPermissionKeys ?? []),
@@ -4306,7 +6466,9 @@ export class FlexHarness<TScope = unknown> {
4306
6466
  eventStore,
4307
6467
  executionContext: contextualExecutionContext,
4308
6468
  contextBuilder: ({ events }) => hydrateAgentMessages(
4309
- (this.agentSessionPolicy.contextBuilder ?? ((options) => plugins.buildModelMessages(options.events)))({ events }),
6469
+ (this.agentSessionPolicy.contextBuilder ?? ((options) => plugins.buildModelMessages(options.events)))({
6470
+ events: this.filterReversionContextEvents(stored, events),
6471
+ }),
4310
6472
  ),
4311
6473
  ...(contextCompactor
4312
6474
  ? {
@@ -4323,7 +6485,14 @@ export class FlexHarness<TScope = unknown> {
4323
6485
  ) {
4324
6486
  throw new Error('Agent context compaction is missing its exact FlexHarness invocation context.');
4325
6487
  }
4326
- return contextCompactor(messages, events, {
6488
+ const filteredEvents = this.filterReversionContextEvents(stored, events);
6489
+ const filteredMessages = hydrateAgentMessages(
6490
+ (this.agentSessionPolicy.contextBuilder
6491
+ ?? ((contextOptions) => plugins.buildModelMessages(contextOptions.events)))({
6492
+ events: filteredEvents,
6493
+ }),
6494
+ );
6495
+ return contextCompactor(filteredMessages, filteredEvents, {
4327
6496
  ...options,
4328
6497
  ...invocationContext,
4329
6498
  scope: invocationContext.scope as TScope,
@@ -4390,13 +6559,37 @@ export class FlexHarness<TScope = unknown> {
4390
6559
  private async repairLoadedSession(
4391
6560
  storageKey: string,
4392
6561
  stored: IStoredSessionState,
6562
+ state?: IStorageState,
6563
+ scopeId?: string,
6564
+ scope?: TScope,
4393
6565
  ): Promise<boolean> {
4394
6566
  const beforeProjection = JSON.stringify({
4395
6567
  messages: stored.messages,
4396
6568
  stagedTerminals: stored.stagedTerminals,
6569
+ reversionSegments: stored.reversionSegments,
6570
+ revertCursor: stored.revertCursor,
6571
+ excludedRunIds: stored.excludedRunIds,
6572
+ pendingReversion: stored.pendingReversion,
6573
+ pendingReversionReleases: stored.pendingReversionReleases,
4397
6574
  });
4398
6575
  const beforeSession = JSON.stringify(stored.session);
6576
+ if (
6577
+ !this.turnReversionProvider
6578
+ && (
6579
+ stored.reversionSegments.some((segment) => segment.captureId)
6580
+ || stored.pendingReversionReleases.length > 0
6581
+ || stored.pendingReversion?.kind === 'capture'
6582
+ )
6583
+ ) {
6584
+ throw new FlexHarnessValidationError(
6585
+ 'Capture-backed session recovery requires its turn reversion provider.',
6586
+ );
6587
+ }
4399
6588
  const outcomes = this.canonicalOutcomes(stored.agentSession.getEvents());
6589
+ const firstHiddenCandidate = this.completedReversionCandidates(stored)[stored.revertCursor];
6590
+ const reversionVisibilityBoundary = firstHiddenCandidate
6591
+ ? stored.reversionSegments.findIndex((segment) => segment.runId === firstHiddenCandidate.runId)
6592
+ : stored.reversionSegments.length;
4400
6593
  const stages = new Map(stored.stagedTerminals.map((terminal) => [terminal.runId, terminal]));
4401
6594
  const runIds = new Set([
4402
6595
  ...stored.messages.map((message) => message.runId),
@@ -4433,6 +6626,37 @@ export class FlexHarness<TScope = unknown> {
4433
6626
  }
4434
6627
  stored.stagedTerminals = stored.stagedTerminals.filter((terminal) => !outcomes.has(terminal.runId));
4435
6628
  if (stored.stagedTerminals.length > 0) stored.stagedTerminals = [];
6629
+ const activeEvents = stored.agentSession.getEvents();
6630
+ const activeEventIds = new Set(activeEvents.map((event) => event.id));
6631
+ for (let index = 0; index < stored.reversionSegments.length; index++) {
6632
+ const segment = stored.reversionSegments[index];
6633
+ if (segment.eventIds.length > 0 && !segment.eventIds.some((id) => activeEventIds.has(id))) {
6634
+ segment.contextAvailable = false;
6635
+ }
6636
+ if (segment.status !== 'capturing') {
6637
+ const outcome = outcomes.get(segment.runId);
6638
+ if (outcome === 'accepted') segment.status = 'completed';
6639
+ else if (outcome === 'rejected') segment.status = 'failed';
6640
+ else if (outcome === 'interrupted' || segment.contextAvailable) segment.status = 'cancelled';
6641
+ }
6642
+ }
6643
+ stored.revertCursor = stored.reversionSegments
6644
+ .slice(0, reversionVisibilityBoundary)
6645
+ .filter((segment) => segment.status === 'completed')
6646
+ .length;
6647
+ this.commitArchivedHiddenBranchState(stored);
6648
+ this.pruneReversionState(stored, false, true);
6649
+ if (stored.pendingReversion?.kind === 'apply') {
6650
+ if (!state || scopeId === undefined || scope === undefined) {
6651
+ throw new FlexHarnessValidationError('Pending reversion recovery is missing its scope context.');
6652
+ }
6653
+ await this.resumePendingApply(state, stored, scopeId, scope);
6654
+ } else if (stored.pendingReversion?.kind === 'capture') {
6655
+ if (!this.turnReversionProvider || !state || scopeId === undefined || scope === undefined) {
6656
+ throw new FlexHarnessValidationError('Pending capture recovery requires its reversion provider.');
6657
+ }
6658
+ await this.recoverPendingCapture(state, stored, scopeId, scope, outcomes);
6659
+ }
4436
6660
  if (latestTerminal) {
4437
6661
  const completedAt = latestTerminal.assistantMessage.completedAt ?? new Date().toISOString();
4438
6662
  stored.session.status = latestTerminal.status === 'completed' ? 'idle' : latestTerminal.status;
@@ -4459,6 +6683,11 @@ export class FlexHarness<TScope = unknown> {
4459
6683
  const projectionChanged = beforeProjection !== JSON.stringify({
4460
6684
  messages: stored.messages,
4461
6685
  stagedTerminals: stored.stagedTerminals,
6686
+ reversionSegments: stored.reversionSegments,
6687
+ revertCursor: stored.revertCursor,
6688
+ excludedRunIds: stored.excludedRunIds,
6689
+ pendingReversion: stored.pendingReversion,
6690
+ pendingReversionReleases: stored.pendingReversionReleases,
4462
6691
  });
4463
6692
  if (projectionChanged) {
4464
6693
  const expected = stored.projectionRevision;
@@ -4469,6 +6698,9 @@ export class FlexHarness<TScope = unknown> {
4469
6698
  snapshot,
4470
6699
  expected,
4471
6700
  );
6701
+ stored.projectionSchemaVersion = 2;
6702
+ delete stored.projectionBaseline;
6703
+ stored.projectionReconciliationRequired = false;
4472
6704
  stored.projectionRevision = snapshot.revision;
4473
6705
  }
4474
6706
  return beforeSession !== JSON.stringify(stored.session);
@@ -4628,18 +6860,47 @@ export class FlexHarness<TScope = unknown> {
4628
6860
  ): Promise<TValue> {
4629
6861
  const operation = stored.projectionQueue.then(async () => {
4630
6862
  const beforeRevision = stored.projectionRevision;
6863
+ const beforeSchemaVersion = stored.projectionSchemaVersion;
6864
+ const beforeReconciliationRequired = stored.projectionReconciliationRequired;
6865
+ const beforeBaseline = stored.projectionBaseline === undefined
6866
+ ? undefined
6867
+ : cloneSerializable(stored.projectionBaseline);
4631
6868
  const beforeMessages = cloneSerializable(stored.messages);
4632
6869
  const beforeStages = cloneSerializable(stored.stagedTerminals);
4633
- const beforeSnapshot: IFlexProjectionSnapshot = {
4634
- schemaVersion: 1,
6870
+ const beforeSegments = cloneSerializable(stored.reversionSegments);
6871
+ const beforeCursor = stored.revertCursor;
6872
+ const beforeExcludedRunIds = cloneSerializable(stored.excludedRunIds);
6873
+ const beforePendingReversion = stored.pendingReversion === undefined
6874
+ ? undefined
6875
+ : cloneSerializable(stored.pendingReversion);
6876
+ const beforePendingReversionReleases = cloneSerializable(stored.pendingReversionReleases);
6877
+ const beforeSnapshot: TFlexProjectionSnapshot = beforeBaseline ?? {
6878
+ schemaVersion: 2,
4635
6879
  revision: beforeRevision,
4636
6880
  messages: cloneSerializable(beforeMessages),
4637
6881
  stagedTerminals: cloneSerializable(beforeStages),
6882
+ reversionSegments: cloneSerializable(beforeSegments),
6883
+ revertCursor: beforeCursor,
6884
+ excludedRunIds: cloneSerializable(beforeExcludedRunIds),
6885
+ ...(beforePendingReversion === undefined
6886
+ ? {}
6887
+ : { pendingReversion: cloneSerializable(beforePendingReversion) }),
6888
+ pendingReversionReleases: cloneSerializable(beforePendingReversionReleases),
4638
6889
  };
4639
6890
  const restore = () => {
6891
+ stored.projectionSchemaVersion = beforeSchemaVersion;
6892
+ stored.projectionReconciliationRequired = beforeReconciliationRequired;
6893
+ if (beforeBaseline === undefined) delete stored.projectionBaseline;
6894
+ else stored.projectionBaseline = beforeBaseline;
4640
6895
  stored.projectionRevision = beforeRevision;
4641
6896
  stored.messages = beforeMessages;
4642
6897
  stored.stagedTerminals = beforeStages;
6898
+ stored.reversionSegments = beforeSegments;
6899
+ stored.revertCursor = beforeCursor;
6900
+ stored.excludedRunIds = beforeExcludedRunIds;
6901
+ if (beforePendingReversion === undefined) delete stored.pendingReversion;
6902
+ else stored.pendingReversion = beforePendingReversion;
6903
+ stored.pendingReversionReleases = beforePendingReversionReleases;
4643
6904
  };
4644
6905
  let result: TValue;
4645
6906
  try {
@@ -4656,6 +6917,9 @@ export class FlexHarness<TScope = unknown> {
4656
6917
  snapshot,
4657
6918
  beforeRevision,
4658
6919
  );
6920
+ stored.projectionSchemaVersion = 2;
6921
+ delete stored.projectionBaseline;
6922
+ stored.projectionReconciliationRequired = false;
4659
6923
  stored.projectionRevision = snapshot.revision;
4660
6924
  return result;
4661
6925
  } catch (error) {
@@ -4664,11 +6928,13 @@ export class FlexHarness<TScope = unknown> {
4664
6928
  throw error;
4665
6929
  }
4666
6930
  if (error instanceof FlexHarnessStoreCommitUncertainError) {
6931
+ stored.projectionSchemaVersion = 2;
6932
+ stored.projectionReconciliationRequired = true;
4667
6933
  stored.projectionRevision = snapshot.revision;
4668
6934
  state.lifecycle = 'fenced';
4669
6935
  throw error;
4670
6936
  }
4671
- let current: IFlexProjectionSnapshot | undefined;
6937
+ let current: TFlexProjectionSnapshot | undefined;
4672
6938
  let reconciliationError: unknown;
4673
6939
  try {
4674
6940
  current = await this.stores.projections.load(
@@ -4679,6 +6945,9 @@ export class FlexHarness<TScope = unknown> {
4679
6945
  reconciliationError = loadError;
4680
6946
  }
4681
6947
  if (current && JSON.stringify(current) === JSON.stringify(snapshot)) {
6948
+ stored.projectionSchemaVersion = 2;
6949
+ delete stored.projectionBaseline;
6950
+ stored.projectionReconciliationRequired = false;
4682
6951
  stored.projectionRevision = snapshot.revision;
4683
6952
  throw error;
4684
6953
  }
@@ -4689,6 +6958,8 @@ export class FlexHarness<TScope = unknown> {
4689
6958
  restore();
4690
6959
  throw error;
4691
6960
  }
6961
+ stored.projectionSchemaVersion = 2;
6962
+ stored.projectionReconciliationRequired = true;
4692
6963
  stored.projectionRevision = snapshot.revision;
4693
6964
  state.lifecycle = 'fenced';
4694
6965
  throw reconciliationError === undefined
@@ -4797,15 +7068,54 @@ export class FlexHarness<TScope = unknown> {
4797
7068
  private createProjectionSnapshot(
4798
7069
  stored: IStoredSessionState,
4799
7070
  revision: number,
4800
- ): IFlexProjectionSnapshot {
7071
+ ): IFlexProjectionSnapshotCurrent {
4801
7072
  return {
4802
- schemaVersion: 1,
7073
+ schemaVersion: 2,
4803
7074
  revision,
4804
7075
  messages: cloneSerializable(stored.messages),
4805
7076
  stagedTerminals: cloneSerializable(stored.stagedTerminals),
7077
+ reversionSegments: cloneSerializable(stored.reversionSegments),
7078
+ revertCursor: stored.revertCursor,
7079
+ excludedRunIds: cloneSerializable(stored.excludedRunIds),
7080
+ ...(stored.pendingReversion === undefined
7081
+ ? {}
7082
+ : { pendingReversion: cloneSerializable(stored.pendingReversion) }),
7083
+ pendingReversionReleases: cloneSerializable(stored.pendingReversionReleases),
4806
7084
  };
4807
7085
  }
4808
7086
 
7087
+ private async reconcileProjectionFromStore(stored: IStoredSessionState): Promise<void> {
7088
+ if (!stored.projectionReconciliationRequired) return;
7089
+ const projection = await this.stores.projections.load(
7090
+ stored.storageKey,
7091
+ stored.session.sessionId,
7092
+ );
7093
+ if (!projection) {
7094
+ throw new FlexHarnessValidationError('Uncertain projection persistence could not be reloaded.');
7095
+ }
7096
+ stored.messages = cloneSerializable(projection.messages);
7097
+ stored.stagedTerminals = cloneSerializable(projection.stagedTerminals);
7098
+ if (projection.schemaVersion === 2) {
7099
+ stored.reversionSegments = cloneSerializable(projection.reversionSegments);
7100
+ stored.revertCursor = projection.revertCursor;
7101
+ stored.excludedRunIds = cloneSerializable(projection.excludedRunIds);
7102
+ if (projection.pendingReversion === undefined) delete stored.pendingReversion;
7103
+ else stored.pendingReversion = cloneSerializable(projection.pendingReversion);
7104
+ stored.pendingReversionReleases = cloneSerializable(projection.pendingReversionReleases);
7105
+ delete stored.projectionBaseline;
7106
+ } else {
7107
+ stored.reversionSegments = [];
7108
+ stored.revertCursor = 0;
7109
+ stored.excludedRunIds = [];
7110
+ delete stored.pendingReversion;
7111
+ stored.pendingReversionReleases = [];
7112
+ stored.projectionBaseline = cloneSerializable(projection);
7113
+ }
7114
+ stored.projectionSchemaVersion = projection.schemaVersion;
7115
+ stored.projectionRevision = projection.revision;
7116
+ stored.projectionReconciliationRequired = false;
7117
+ }
7118
+
4809
7119
  private async cleanupSessionDomains(storageKey: string, sessionId: string): Promise<void> {
4810
7120
  const results = await Promise.allSettled([
4811
7121
  this.stores.agentEvents.deleteSession(storageKey, sessionId),
@@ -4914,15 +7224,19 @@ export class FlexHarness<TScope = unknown> {
4914
7224
  }
4915
7225
  const group = this.tombstoneGroup(state, rootSessionId);
4916
7226
  if (group.length === 0) return;
7227
+ const exactInvocation = invocation ?? {
7228
+ scopeId: state.scopeContext.scopeId,
7229
+ scope: state.scopeContext.scope as TScope,
7230
+ };
4917
7231
  const groupIds = new Set(group.map((tombstone) => tombstone.sessionId));
4918
7232
  const reason = this.trustInternalError(new FlexHarnessAbortError('The session subtree was deleted.'));
4919
7233
  const contextFor = (
4920
7234
  sessionId: string,
4921
7235
  retained?: IRetainedSessionCleanup,
4922
- ): IFlexAgentContextInvocation<TScope> | undefined => invocation
7236
+ ): IFlexAgentContextInvocation<TScope> | undefined => exactInvocation
4923
7237
  ? this.createCompactorContext(
4924
- invocation.scopeId,
4925
- invocation.scope,
7238
+ exactInvocation.scopeId,
7239
+ exactInvocation.scope,
4926
7240
  state.storageKey,
4927
7241
  sessionId,
4928
7242
  )
@@ -4995,6 +7309,21 @@ export class FlexHarness<TScope = unknown> {
4995
7309
  if (this.hasOrphanedSessionResources(state.storageKey, sessionId)) {
4996
7310
  throw new Error(`Session "${sessionId}" still has runtime cleanup ownership.`);
4997
7311
  }
7312
+ const releaseContext = exactInvocation ?? retained?.stored.compactorContext as
7313
+ IFlexAgentContextInvocation<TScope> | undefined;
7314
+ if (!releaseContext) {
7315
+ throw new FlexHarnessValidationError(
7316
+ 'Tombstone cleanup requires its exact scope context before deleting projection data.',
7317
+ );
7318
+ }
7319
+ await this.releaseDeletedSessionReversions(
7320
+ state,
7321
+ state.storageKey,
7322
+ sessionId,
7323
+ releaseContext.scopeId,
7324
+ releaseContext.scope,
7325
+ retained?.stored,
7326
+ );
4998
7327
  await this.cleanupSessionDomains(state.storageKey, sessionId);
4999
7328
  if (retained) retained.domainsCompleted = true;
5000
7329
  }
@@ -5182,6 +7511,26 @@ export class FlexHarness<TScope = unknown> {
5182
7511
  void drain.catch(() => undefined);
5183
7512
  }
5184
7513
 
7514
+ private deferReversionReleaseDrain(
7515
+ state: IStorageState,
7516
+ stored: IStoredSessionState,
7517
+ scopeId: string,
7518
+ scope: TScope,
7519
+ ): void {
7520
+ state.lifecycle = 'fenced';
7521
+ const reason = this.trustInternalError(new FlexHarnessAbortError('The session namespace was fenced.'));
7522
+ this.abortCompactorLifecycle(stored, reason);
7523
+ const stateLoad = this.stateLoads.get(state.storageKey);
7524
+ if (!stateLoad) return;
7525
+ const drain = this.drainStorage(
7526
+ state.storageKey,
7527
+ stateLoad,
7528
+ reason,
7529
+ { scopeId, scope },
7530
+ );
7531
+ void drain.catch(() => undefined);
7532
+ }
7533
+
5185
7534
  private sessionsAreDependencyRelated(
5186
7535
  state: IStorageState,
5187
7536
  leftSessionId: string,
@@ -5387,6 +7736,20 @@ export class FlexHarness<TScope = unknown> {
5387
7736
 
5388
7737
  private async closeOrphanedResourcesInternal(storageKey?: string): Promise<unknown[]> {
5389
7738
  const errors: unknown[] = [];
7739
+ for (const [key, owner] of [...this.orphanedTombstoneOwners]) {
7740
+ if (storageKey !== undefined && owner.state.storageKey !== storageKey) continue;
7741
+ try {
7742
+ await this.finishTombstoneCleanup(
7743
+ owner.state,
7744
+ owner.rootSessionId,
7745
+ owner.scopeId,
7746
+ { scopeId: owner.scopeId, scope: owner.scope },
7747
+ );
7748
+ this.orphanedTombstoneOwners.delete(key);
7749
+ } catch (error) {
7750
+ errors.push(error);
7751
+ }
7752
+ }
5390
7753
  const tombstoneCleanups = [...this.orphanedTombstoneCleanups.values()]
5391
7754
  .filter((retained) => storageKey === undefined || retained.storageKey === storageKey);
5392
7755
  const tombstoneResults = await Promise.allSettled(
@@ -5436,6 +7799,7 @@ export class FlexHarness<TScope = unknown> {
5436
7799
  ...[...this.orphanedExecutionContexts.values()].map((owner) => owner.storageKey),
5437
7800
  ...[...this.orphanedProviderReleases.values()].map((retained) => retained.storageKey),
5438
7801
  ...[...this.orphanedTombstoneCleanups.values()].map((retained) => retained.storageKey),
7802
+ ...[...this.orphanedTombstoneOwners.values()].map((owner) => owner.state.storageKey),
5439
7803
  ]);
5440
7804
  }
5441
7805
 
@@ -5443,7 +7807,23 @@ export class FlexHarness<TScope = unknown> {
5443
7807
  const scope = await this.scopeResolver.resolveScope(scopeId);
5444
7808
  this.assertOpen();
5445
7809
  validateIdentifier(scope.storageKey, 'resolved storageKey');
7810
+ const invocationOwner = this.slashCommandInvocationContext.getStore();
7811
+ if (invocationOwner?.storageKey === scope.storageKey) {
7812
+ throw this.trustInternalError(
7813
+ new FlexHarnessSlashCommandReentryError(invocationOwner.sessionId),
7814
+ );
7815
+ }
5446
7816
  const errors: unknown[] = [];
7817
+ const listingSettlement = this.abortSlashCommandListings(
7818
+ scope.storageKey,
7819
+ this.trustInternalError(new FlexHarnessAbortError(scopeRetirementMessage)),
7820
+ );
7821
+ if (listingSettlement) await listingSettlement;
7822
+ await this.abortSlashCommandExecutions(
7823
+ scope.storageKey,
7824
+ this.trustInternalError(new FlexHarnessAbortError(scopeRetirementMessage)),
7825
+ errors,
7826
+ );
5447
7827
  const stateLoad = this.stateLoads.get(scope.storageKey);
5448
7828
  if (!stateLoad) {
5449
7829
  const drain = this.storageDrains.get(scope.storageKey);
@@ -5585,6 +7965,21 @@ export class FlexHarness<TScope = unknown> {
5585
7965
  'lifecycle-close',
5586
7966
  ));
5587
7967
  }
7968
+ const releaseContext = invocation ?? stored.compactorContext as
7969
+ IFlexAgentContextInvocation<TScope> | undefined;
7970
+ if (releaseContext) {
7971
+ try {
7972
+ await this.drainReversionReleases(
7973
+ state,
7974
+ stored,
7975
+ releaseContext.scopeId,
7976
+ releaseContext.scope,
7977
+ true,
7978
+ );
7979
+ } catch (error) {
7980
+ errors.push(error);
7981
+ }
7982
+ }
5588
7983
  this.purgeStoredPromptQueue(stored);
5589
7984
  }
5590
7985
  const roots = this.orderTombstoneRootsChildFirst(
@@ -5629,6 +8024,21 @@ export class FlexHarness<TScope = unknown> {
5629
8024
  if (!pending.controller.signal.aborted) pending.controller.abort(pendingAdmissionReason);
5630
8025
  }
5631
8026
  await Promise.all(pendingAdmissions.map((pending) => pending.settled));
8027
+ const errors: unknown[] = [];
8028
+ const listingSettlement = this.abortSlashCommandListings(
8029
+ undefined,
8030
+ this.trustInternalError(new FlexHarnessAbortError(
8031
+ 'The slash command listing was aborted because FlexHarness was disposed.',
8032
+ )),
8033
+ );
8034
+ if (listingSettlement) await listingSettlement;
8035
+ await this.abortSlashCommandExecutions(
8036
+ undefined,
8037
+ this.trustInternalError(new FlexHarnessAbortError(
8038
+ 'The slash command was aborted because FlexHarness was disposed.',
8039
+ )),
8040
+ errors,
8041
+ );
5632
8042
  const loads = [...this.stateLoads.entries()];
5633
8043
  const results = await Promise.allSettled(loads.map(([storageKey, stateLoad]) =>
5634
8044
  this.drainStorage(
@@ -5638,7 +8048,6 @@ export class FlexHarness<TScope = unknown> {
5638
8048
  'The run was aborted because FlexHarness was disposed.',
5639
8049
  )),
5640
8050
  )));
5641
- const errors: unknown[] = [];
5642
8051
  for (const result of results) {
5643
8052
  if (result.status === 'rejected') this.appendUnexpectedErrors(errors, result.reason);
5644
8053
  }
@@ -5651,6 +8060,7 @@ export class FlexHarness<TScope = unknown> {
5651
8060
  this.listeners.clear();
5652
8061
  if (errors.length > 0) throw combineErrors(errors);
5653
8062
  this.compactorInvocationContext.disable();
8063
+ this.slashCommandInvocationContext.disable();
5654
8064
  this.stateLoads.clear();
5655
8065
  this.scopeAdmissions.clear();
5656
8066
  this.scopeRetirements.clear();
@@ -5658,6 +8068,45 @@ export class FlexHarness<TScope = unknown> {
5658
8068
  this.storageCompactorLifecycleControllers.clear();
5659
8069
  }
5660
8070
 
8071
+ private async abortSlashCommandExecutions(
8072
+ storageKey: string | undefined,
8073
+ reason: FlexHarnessAbortError,
8074
+ errors: unknown[],
8075
+ sessionId?: string,
8076
+ ): Promise<void> {
8077
+ const active = [...this.activeSlashCommandExecutions.values()]
8078
+ .filter((execution) =>
8079
+ (storageKey === undefined || execution.storageKey === storageKey)
8080
+ && (sessionId === undefined || execution.sessionId === sessionId));
8081
+ for (const execution of active) {
8082
+ if (!execution.controller.signal.aborted) execution.controller.abort(reason);
8083
+ }
8084
+ const awaited = active.filter((execution) => execution.kind !== 'prompt-admission');
8085
+ const results = await Promise.allSettled(awaited.map((execution) => execution.completion));
8086
+ for (let index = 0; index < results.length; index++) {
8087
+ const result = results[index];
8088
+ if (result.status === 'rejected' && awaited[index].kind === 'handler') {
8089
+ this.appendUnexpectedErrors(errors, result.reason);
8090
+ }
8091
+ }
8092
+ }
8093
+
8094
+ private abortSlashCommandListings(
8095
+ storageKey: string | undefined,
8096
+ reason: FlexHarnessAbortError,
8097
+ sessionId?: string,
8098
+ ): Promise<void> | undefined {
8099
+ const active = [...this.activeSlashCommandListings]
8100
+ .filter((listing) =>
8101
+ (storageKey === undefined || listing.storageKey === storageKey)
8102
+ && (sessionId === undefined || listing.sessionId === sessionId));
8103
+ if (active.length === 0) return undefined;
8104
+ for (const listing of active) {
8105
+ if (!listing.controller.signal.aborted) listing.controller.abort(reason);
8106
+ }
8107
+ return Promise.allSettled(active.map((listing) => listing.completion)).then(() => undefined);
8108
+ }
8109
+
5661
8110
  private appendUnexpectedErrors(target: unknown[], error: unknown): void {
5662
8111
  if (error instanceof FlexHarnessRunError) {
5663
8112
  for (const nested of error.errors) this.appendUnexpectedErrors(target, nested);
@@ -5805,6 +8254,18 @@ export class FlexHarness<TScope = unknown> {
5805
8254
  return stored;
5806
8255
  }
5807
8256
 
8257
+ private completedReversionCandidates(stored: IStoredSessionState): IFlexReversionSegment[] {
8258
+ return stored.reversionSegments.filter((segment) => segment.status === 'completed');
8259
+ }
8260
+
8261
+ private visibleMessages(stored: IStoredSessionState): IFlexMessage[] {
8262
+ const firstHiddenUnit = this.reversionUnit(stored, 'redo');
8263
+ const firstHiddenSegment = firstHiddenUnit?.segments[0];
8264
+ if (!firstHiddenSegment) return stored.messages;
8265
+ const boundary = stored.messages.findIndex((message) => message.runId === firstHiddenSegment.runId);
8266
+ return boundary < 0 ? stored.messages : stored.messages.slice(0, boundary);
8267
+ }
8268
+
5808
8269
  private requireMutableSession(state: IStorageState, sessionId: string): IStoredSessionState {
5809
8270
  const stored = this.requireSession(state, sessionId);
5810
8271
  const pending = [...state.pendingPermissions.values()].some((entry) =>
@@ -5819,7 +8280,7 @@ export class FlexHarness<TScope = unknown> {
5819
8280
  }
5820
8281
 
5821
8282
  private requireMessage(stored: IStoredSessionState, messageId: string): IFlexMessage {
5822
- const message = stored.messages.find((entry) => entry.messageId === messageId);
8283
+ const message = this.visibleMessages(stored).find((entry) => entry.messageId === messageId);
5823
8284
  if (!message) throw new FlexHarnessNotFoundError('Message', messageId);
5824
8285
  return message;
5825
8286
  }