@modelprofile.com/flexharness 3.6.0 → 3.8.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,14 +10,26 @@ 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_MAX_AFFECTED_WORKSPACES,
22
+ FLEX_REVERSION_DEFAULT_LIMITS,
23
+ FLEX_REVERSION_MAXIMUM_LIMITS,
24
+ FLEX_REVERSION_REASON_CODE_MAX_BYTES,
25
+ FLEX_REVERSION_REFERENCE_MAX_BYTES,
26
+ FLEX_REVERSION_WORKSPACE_ID_MAX_BYTES,
27
+ FLEX_REVERSION_WORKSPACE_LABEL_MAX_BYTES,
28
+ } from './interfaces.js';
18
29
  import type {
19
30
  IFlexAgentContextInvocation,
20
31
  IFlexAgentSessionPolicy,
32
+ IFlexAffectedWorkspace,
21
33
  IFlexAttachmentMessagePart,
22
34
  IFlexBackgroundExecution,
23
35
  IFlexCallbackLimits,
@@ -41,7 +53,11 @@ import type {
41
53
  IFlexPromptQueueLimits,
42
54
  IFlexPromptOptions,
43
55
  IFlexPromptResult,
44
- IFlexProjectionSnapshot,
56
+ IFlexProjectionSnapshotCurrent,
57
+ IFlexReversionLimits,
58
+ IFlexReversionSegment,
59
+ IFlexPendingReversionRelease,
60
+ IFlexRedoSessionResult,
45
61
  IFlexResolvedModel,
46
62
  IFlexResolvedScope,
47
63
  IFlexResourceToolProviderDescriptor,
@@ -50,13 +66,20 @@ import type {
50
66
  IFlexSchedulePromptOptions,
51
67
  IFlexScopeSnapshot,
52
68
  IFlexSession,
69
+ IFlexSessionReversionGroup,
70
+ IFlexSessionReversionInfo,
53
71
  IFlexSessionTombstone,
72
+ IFlexSlashCommandDescriptor,
73
+ IFlexSlashCommandExecutionOptions,
74
+ IFlexSlashCommandHandlerRegistration,
75
+ IFlexSlashCommandTemplateRegistration,
54
76
  IFlexSubagentDefinition,
55
77
  IFlexTerminalProjection,
56
78
  IFlexToolHandle,
57
79
  IFlexToolMessagePart,
58
80
  IFlexToolProviderContext,
59
81
  IFlexUncertainToolExecution,
82
+ IFlexUndoSessionResult,
60
83
  IFlexUpdateSessionOptions,
61
84
  IFlexUsage,
62
85
  IJsonObject,
@@ -73,7 +96,15 @@ import type {
73
96
  TFlexPrompt,
74
97
  TFlexPromptPart,
75
98
  TFlexPromptQueueStatus,
99
+ TFlexSlashCommandExecutionResult,
100
+ TFlexSlashCommandRegistration,
101
+ TFlexPendingReversion,
102
+ TFlexProjectionSnapshot,
103
+ TFlexReversionPolicy,
104
+ TFlexSessionReversionGroupKind,
105
+ TFlexTurnReversionFinalizedOutcome,
76
106
  TFlexToolExecutionReconciliation,
107
+ TJsonValue,
77
108
  } from './interfaces.js';
78
109
  import { InMemoryFlexHarnessStores } from './classes.stores.js';
79
110
  import {
@@ -88,6 +119,14 @@ import {
88
119
  normalizeFlexPrompt,
89
120
  type INormalizedFlexPrompt,
90
121
  } from './utils.prompt.js';
122
+ import {
123
+ FLEX_SLASH_COMMAND_INITIALIZE_TEMPLATE,
124
+ FLEX_SLASH_COMMAND_MAX_INPUT_BYTES,
125
+ expandSlashCommandTemplate,
126
+ isValidSlashCommandName,
127
+ parseSlashCommand,
128
+ slashCommandTemplateHints,
129
+ } from './utils.slashcommands.js';
91
130
 
92
131
  type TCanonicalOutcome = 'accepted' | 'rejected' | 'interrupted';
93
132
  type TRunPhase =
@@ -104,6 +143,14 @@ interface IStoredSessionState {
104
143
  session: IFlexSession;
105
144
  messages: IFlexMessage[];
106
145
  stagedTerminals: IFlexTerminalProjection[];
146
+ reversionSegments: IFlexReversionSegment[];
147
+ revertCursor: number;
148
+ excludedRunIds: string[];
149
+ pendingReversion?: TFlexPendingReversion;
150
+ pendingReversionReleases: IFlexPendingReversionRelease[];
151
+ projectionSchemaVersion: 1 | 2 | 3;
152
+ projectionBaseline?: TFlexProjectionSnapshot;
153
+ projectionReconciliationRequired: boolean;
107
154
  projectionRevision: number;
108
155
  projectionQueue: Promise<void>;
109
156
  rememberedPermissionKeys: Set<string>;
@@ -131,6 +178,18 @@ interface IRetainedSessionCleanup {
131
178
  domainsCompleted: boolean;
132
179
  }
133
180
 
181
+ interface IReversionGroup {
182
+ target: IFlexReversionSegment;
183
+ segments: IFlexReversionSegment[];
184
+ kind: TFlexSessionReversionGroupKind;
185
+ affectedWorkspaces: IFlexAffectedWorkspace[];
186
+ affectedWorkspacesTruncated: boolean;
187
+ }
188
+
189
+ type TNormalizedCaptureInspection =
190
+ | { status: 'missing' | 'prepared' | 'unknown' }
191
+ | { status: 'finalized'; outcome: TFlexTurnReversionFinalizedOutcome };
192
+
134
193
  interface IDetachedCleanup {
135
194
  cleanup: () => Promise<void> | void;
136
195
  projectError: (error: unknown) => Error;
@@ -151,6 +210,13 @@ interface IOrphanedTombstoneCleanup {
151
210
  completion: Promise<void>;
152
211
  }
153
212
 
213
+ interface IOrphanedTombstoneOwner<TScope> {
214
+ state: IStorageState;
215
+ rootSessionId: string;
216
+ scopeId: string;
217
+ scope: TScope;
218
+ }
219
+
154
220
  interface IOrphanedExecutionContextOwner {
155
221
  storageKey: string;
156
222
  sessionId: string;
@@ -160,6 +226,7 @@ interface IStorageState {
160
226
  storageKey: string;
161
227
  compactorLifecycleController: AbortController;
162
228
  scopeIdHint: string;
229
+ scopeContext: { scopeId: string; scope: unknown };
163
230
  revision: number;
164
231
  sessions: Map<string, IStoredSessionState>;
165
232
  retainedSessionCleanups: Map<string, IRetainedSessionCleanup>;
@@ -261,6 +328,38 @@ interface IScopeAdmissionState {
261
328
  retiring: boolean;
262
329
  }
263
330
 
331
+ type TRegisteredSlashCommand<TScope> =
332
+ | Readonly<IFlexSlashCommandTemplateRegistration>
333
+ | Readonly<IFlexSlashCommandHandlerRegistration<TScope>>;
334
+
335
+ interface IActiveSlashCommandExecution {
336
+ kind: 'handler' | 'operation' | 'prompt-admission';
337
+ storageKey: string;
338
+ sessionId: string;
339
+ controller: AbortController;
340
+ completion: Promise<unknown>;
341
+ }
342
+
343
+ interface IActiveSlashCommandListing {
344
+ storageKey: string;
345
+ sessionId: string;
346
+ controller: AbortController;
347
+ completion: Promise<unknown>;
348
+ }
349
+
350
+ interface ISlashCommandInvocationOwner {
351
+ scopeId: string;
352
+ storageKey: string;
353
+ sessionId: string;
354
+ sessionKey: string;
355
+ }
356
+
357
+ interface ISlashCommandActivityOwner {
358
+ scopeId: string;
359
+ storageKey: string;
360
+ sessionId: string;
361
+ }
362
+
264
363
  interface IRunResultProjection {
265
364
  text: string;
266
365
  steps: number;
@@ -342,9 +441,14 @@ const maximumMaxSubagentCallsPerRun = 128;
342
441
  const maxResourceToolProviders = 128;
343
442
  const maxResourceIdBytes = 512;
344
443
  const maxResourceToolNameBytes = 512;
444
+ const maxSlashCommandRegistrations = 128;
445
+ const maxSlashCommandDescriptionBytes = 2048;
446
+ const maxSlashCommandTemplateBytes = FLEX_SLASH_COMMAND_MAX_INPUT_BYTES;
345
447
  const resourceToolStemLength = 16;
346
448
  const repairCancellationMessage = 'The process stopped before this run completed.';
347
449
  const scopeRetirementMessage = 'The scope is being retired.';
450
+ const slashCommandSessionUnavailableReason = 'Session must be idle with no queued prompts.';
451
+ const reservedSlashCommandNames = new Set(['compact', 'undo', 'redo', 'init']);
348
452
  const externalErrorFallback: IFlexErrorInfo = Object.freeze({
349
453
  name: 'FlexHarnessExternalError',
350
454
  message: 'The model operation failed.',
@@ -559,6 +663,89 @@ function normalizeSubagents(
559
663
  return Object.freeze(normalized);
560
664
  }
561
665
 
666
+ function normalizeSlashCommands<TScope>(
667
+ registrations: readonly TFlexSlashCommandRegistration<TScope>[] | undefined,
668
+ ): ReadonlyMap<string, TRegisteredSlashCommand<TScope>> {
669
+ if (registrations === undefined) return new Map();
670
+ if (!Array.isArray(registrations) || registrations.length > maxSlashCommandRegistrations) {
671
+ throw new FlexHarnessValidationError(
672
+ `slashCommands must be an array with at most ${maxSlashCommandRegistrations} registrations.`,
673
+ );
674
+ }
675
+ const normalized = new Map<string, TRegisteredSlashCommand<TScope>>();
676
+ for (let index = 0; index < registrations.length; index++) {
677
+ const registration = registrations[index];
678
+ if (
679
+ !registration
680
+ || typeof registration !== 'object'
681
+ || Array.isArray(registration)
682
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(registration))
683
+ ) {
684
+ throw new FlexHarnessValidationError(`slashCommands[${index}] must be a plain object.`);
685
+ }
686
+ const keys = Object.keys(registration);
687
+ const hasTemplate = Object.prototype.hasOwnProperty.call(registration, 'template');
688
+ const hasHandler = Object.prototype.hasOwnProperty.call(registration, 'handler');
689
+ const supported = hasTemplate
690
+ ? ['name', 'description', 'template']
691
+ : ['name', 'description', 'handler'];
692
+ const unsupported = keys.find((key) => !supported.includes(key));
693
+ if (unsupported || hasTemplate === hasHandler) {
694
+ throw new FlexHarnessValidationError(
695
+ `slashCommands[${index}] must define exactly one of template or handler.`,
696
+ );
697
+ }
698
+ if (typeof registration.name !== 'string' || !isValidSlashCommandName(registration.name)) {
699
+ throw new FlexHarnessValidationError(`slashCommands[${index}].name is invalid.`);
700
+ }
701
+ if (reservedSlashCommandNames.has(registration.name)) {
702
+ throw new FlexHarnessValidationError(
703
+ `Slash command "${registration.name}" is reserved.`,
704
+ );
705
+ }
706
+ if (normalized.has(registration.name)) {
707
+ throw new FlexHarnessValidationError(`Duplicate slash command "${registration.name}".`);
708
+ }
709
+ if (registration.description !== undefined) {
710
+ validateUtf8String(
711
+ registration.description,
712
+ `slashCommands[${index}].description`,
713
+ maxSlashCommandDescriptionBytes,
714
+ true,
715
+ );
716
+ }
717
+ if (hasTemplate) {
718
+ const template = registration.template;
719
+ validateUtf8String(
720
+ template,
721
+ `slashCommands[${index}].template`,
722
+ maxSlashCommandTemplateBytes,
723
+ true,
724
+ );
725
+ normalized.set(registration.name, Object.freeze({
726
+ name: registration.name,
727
+ ...(registration.description === undefined
728
+ ? {}
729
+ : { description: registration.description }),
730
+ template,
731
+ }));
732
+ } else {
733
+ const handler = registration.handler;
734
+ if (typeof handler !== 'function') {
735
+ throw new FlexHarnessValidationError(`slashCommands[${index}].handler must be a function.`);
736
+ }
737
+ normalized.set(registration.name, Object.freeze({
738
+ name: registration.name,
739
+ ...(registration.description === undefined
740
+ ? {}
741
+ : { description: registration.description }),
742
+ handler,
743
+ }));
744
+ }
745
+ }
746
+ return Object.freeze(normalized);
747
+ }
748
+
562
749
  function resolveBoundedPositiveInteger(
563
750
  value: number | undefined,
564
751
  name: string,
@@ -833,6 +1020,31 @@ function validatePromptOptions(options: IFlexPromptOptions, scheduled: boolean):
833
1020
  }
834
1021
  }
835
1022
 
1023
+ function validateSlashCommandExecutionOptions(
1024
+ options: IFlexSlashCommandExecutionOptions,
1025
+ ): IFlexPromptOptions {
1026
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
1027
+ throw new FlexHarnessValidationError('Slash command execution options must be a plain object.');
1028
+ }
1029
+ const unsupported = Object.keys(options)
1030
+ .find((key) => !['modelHint', 'system', 'maxSteps', 'signal'].includes(key));
1031
+ if (unsupported) {
1032
+ throw new FlexHarnessValidationError(
1033
+ `Slash command execution options do not support "${unsupported}".`,
1034
+ );
1035
+ }
1036
+ if (options.signal !== undefined && !(options.signal instanceof AbortSignal)) {
1037
+ throw new FlexHarnessValidationError('Slash command signal must be an AbortSignal.');
1038
+ }
1039
+ const promptOptions: IFlexPromptOptions = {
1040
+ ...(options.modelHint === undefined ? {} : { modelHint: options.modelHint }),
1041
+ ...(options.system === undefined ? {} : { system: options.system }),
1042
+ ...(options.maxSteps === undefined ? {} : { maxSteps: options.maxSteps }),
1043
+ };
1044
+ validatePromptOptions(promptOptions, false);
1045
+ return promptOptions;
1046
+ }
1047
+
836
1048
  function resolveCallbackLimits(limits: IFlexCallbackLimits = {}): Required<IFlexCallbackLimits> {
837
1049
  const resolved = {
838
1050
  maxEvents: limits.maxEvents ?? DEFAULT_CALLBACK_LIMITS.maxEvents,
@@ -870,6 +1082,27 @@ function resolvePromptQueueLimits(
870
1082
  return resolved;
871
1083
  }
872
1084
 
1085
+ function resolveReversionLimits(
1086
+ limits: IFlexReversionLimits = {},
1087
+ ): Required<IFlexReversionLimits> {
1088
+ const resolved = {
1089
+ maxCompletedTurns: limits.maxCompletedTurns ?? FLEX_REVERSION_DEFAULT_LIMITS.maxCompletedTurns,
1090
+ maxSegments: limits.maxSegments ?? FLEX_REVERSION_DEFAULT_LIMITS.maxSegments,
1091
+ maxExcludedRunIds: limits.maxExcludedRunIds ?? FLEX_REVERSION_DEFAULT_LIMITS.maxExcludedRunIds,
1092
+ maxPendingReversionReleases: limits.maxPendingReversionReleases
1093
+ ?? FLEX_REVERSION_DEFAULT_LIMITS.maxPendingReversionReleases,
1094
+ };
1095
+ for (const [name, value] of Object.entries(resolved)) {
1096
+ const maximum = FLEX_REVERSION_MAXIMUM_LIMITS[name as keyof IFlexReversionLimits];
1097
+ if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
1098
+ throw new FlexHarnessValidationError(
1099
+ `reversionLimits.${name} must be an integer from 1 through ${maximum}.`,
1100
+ );
1101
+ }
1102
+ }
1103
+ return resolved;
1104
+ }
1105
+
873
1106
  function normalizeAgentSessionPolicy<TScope>(
874
1107
  policy: IFlexAgentSessionPolicy<TScope> = {},
875
1108
  ): IFlexAgentSessionPolicy<TScope> {
@@ -906,8 +1139,12 @@ export class FlexHarness<TScope = unknown> {
906
1139
  private readonly toolOutputLimits: Required<NonNullable<IFlexHarnessOptions<TScope>['toolOutputLimits']>>;
907
1140
  private readonly callbackLimits: Required<IFlexCallbackLimits>;
908
1141
  private readonly promptQueueLimits: Required<IFlexPromptQueueLimits>;
1142
+ private readonly reversionLimits: Required<IFlexReversionLimits>;
1143
+ private readonly turnReversionProvider: IFlexHarnessOptions<TScope>['turnReversionProvider'];
1144
+ private readonly reversionPolicy: TFlexReversionPolicy;
909
1145
  private readonly externalErrorProjector?: TFlexExternalErrorProjector;
910
1146
  private readonly subagents: ReadonlyMap<string, Readonly<IFlexSubagentDefinition>>;
1147
+ private readonly slashCommands: ReadonlyMap<string, TRegisteredSlashCommand<TScope>>;
911
1148
  private readonly maxSubagentDepth: number;
912
1149
  private readonly maxSubagentCallsPerRun: number;
913
1150
  private readonly stateLoads = new Map<string, Promise<IStorageState>>();
@@ -925,10 +1162,19 @@ export class FlexHarness<TScope = unknown> {
925
1162
  >();
926
1163
  private readonly orphanedProviderReleases = new Map<string, IOrphanedProviderRelease>();
927
1164
  private readonly orphanedTombstoneCleanups = new Map<string, IOrphanedTombstoneCleanup>();
1165
+ private readonly orphanedTombstoneOwners = new Map<string, IOrphanedTombstoneOwner<TScope>>();
928
1166
  private readonly pendingPromptAdmissionOwners = new Set<IPendingPromptAdmission>();
1167
+ private readonly activeSlashCommandExecutions = new Map<string, IActiveSlashCommandExecution>();
1168
+ private readonly activeSlashCommandListings = new Set<IActiveSlashCommandListing>();
929
1169
  private readonly compactorInvocationContext = new plugins.AsyncLocalStorage<
930
1170
  IFlexAgentContextInvocation<TScope>
931
1171
  >();
1172
+ private readonly slashCommandInvocationContext = new plugins.AsyncLocalStorage<
1173
+ ISlashCommandInvocationOwner
1174
+ >();
1175
+ private readonly slashCommandActivityContext = new plugins.AsyncLocalStorage<
1176
+ ISlashCommandActivityOwner
1177
+ >();
932
1178
  private readonly deferredCompactorContexts = new WeakMap<
933
1179
  IFlexAgentContextInvocation<unknown>,
934
1180
  IFlexAgentContextInvocation<unknown>
@@ -954,8 +1200,29 @@ export class FlexHarness<TScope = unknown> {
954
1200
  this.toolOutputLimits = resolveJsonLimits(options.toolOutputLimits);
955
1201
  this.callbackLimits = resolveCallbackLimits(options.callbackLimits);
956
1202
  this.promptQueueLimits = resolvePromptQueueLimits(options.promptQueueLimits);
1203
+ this.reversionLimits = resolveReversionLimits(options.reversionLimits);
1204
+ this.turnReversionProvider = options.turnReversionProvider;
1205
+ this.reversionPolicy = options.reversionPolicy ?? 'transcript-optional';
1206
+ if (!['transcript-optional', 'workspace-required'].includes(this.reversionPolicy)) {
1207
+ throw new FlexHarnessValidationError(
1208
+ 'reversionPolicy must be "transcript-optional" or "workspace-required".',
1209
+ );
1210
+ }
1211
+ const protocolVersion = this.turnReversionProvider
1212
+ && 'protocolVersion' in this.turnReversionProvider
1213
+ ? this.turnReversionProvider.protocolVersion
1214
+ : 1;
1215
+ if (protocolVersion !== 1 && protocolVersion !== 2) {
1216
+ throw new FlexHarnessValidationError('Turn reversion provider protocolVersion is invalid.');
1217
+ }
1218
+ if (this.reversionPolicy === 'workspace-required' && protocolVersion !== 2) {
1219
+ throw new FlexHarnessValidationError(
1220
+ 'workspace-required reversion policy requires a protocolVersion 2 turn reversion provider.',
1221
+ );
1222
+ }
957
1223
  this.externalErrorProjector = options.externalErrorProjector;
958
1224
  this.subagents = normalizeSubagents(options.subagents);
1225
+ this.slashCommands = normalizeSlashCommands(options.slashCommands);
959
1226
  this.maxSubagentDepth = resolveBoundedPositiveInteger(
960
1227
  options.maxSubagentDepth,
961
1228
  'maxSubagentDepth',
@@ -973,6 +1240,7 @@ export class FlexHarness<TScope = unknown> {
973
1240
  public async listSessions(scopeId: string): Promise<IFlexSession[]> {
974
1241
  const { state } = await this.resolveState(scopeId);
975
1242
  await state.scopeQueue;
1243
+ this.assertOpen();
976
1244
  this.assertStateAcceptingWork(state);
977
1245
  return publicSnapshot([...state.sessions.values()]
978
1246
  .filter((stored) => !state.initializingSessions.has(stored.session.sessionId))
@@ -1139,6 +1407,41 @@ export class FlexHarness<TScope = unknown> {
1139
1407
  return publicSnapshot(this.requireSession(state, sessionId).session);
1140
1408
  }
1141
1409
 
1410
+ public async getSessionReversionInfo(
1411
+ scopeId: string,
1412
+ sessionId: string,
1413
+ ): Promise<IFlexSessionReversionInfo> {
1414
+ const { state } = await this.resolveState(scopeId);
1415
+ const stored = this.requireSession(state, sessionId);
1416
+ await stored.projectionQueue;
1417
+ this.assertStateAcceptingWork(state);
1418
+ await this.reconcileContextAvailability(state, stored);
1419
+ const unavailableReason = await this.reversionUnavailableReason(state, stored);
1420
+ const groups = this.reversionGroups(stored);
1421
+ const undo = this.reversionUnit(stored, 'undo');
1422
+ const redo = this.reversionUnit(stored, 'redo');
1423
+ const isAvailable = (unit: ReturnType<typeof this.reversionUnit>) => Boolean(
1424
+ !unavailableReason
1425
+ && unit
1426
+ && unit.target.contextAvailable
1427
+ && !unit.segments.some((segment) =>
1428
+ segment.provenance === 'workspace'
1429
+ && segment.disposition === 'revertible'
1430
+ && segment.workspaceReference === undefined),
1431
+ );
1432
+ return publicSnapshot({
1433
+ undoAvailable: isAvailable(undo),
1434
+ redoAvailable: isAvailable(redo),
1435
+ groups: groups.map((group, index): IFlexSessionReversionGroup => ({
1436
+ runId: group.target.runId,
1437
+ kind: group.kind,
1438
+ visibility: index < stored.revertCursor ? 'visible' : 'hidden',
1439
+ affectedWorkspaces: cloneSerializable(group.affectedWorkspaces),
1440
+ affectedWorkspacesTruncated: group.affectedWorkspacesTruncated,
1441
+ })),
1442
+ });
1443
+ }
1444
+
1142
1445
  public async updateSession(
1143
1446
  scopeId: string,
1144
1447
  sessionId: string,
@@ -1166,7 +1469,9 @@ export class FlexHarness<TScope = unknown> {
1166
1469
  }
1167
1470
 
1168
1471
  public async deleteSession(scopeId: string, sessionId: string): Promise<void> {
1472
+ this.assertNoAmbientSlashCommandTeardown({ scopeId, sessionId });
1169
1473
  const { scope, state } = await this.resolveState(scopeId);
1474
+ this.assertNoAmbientSlashCommandTeardown({ storageKey: state.storageKey });
1170
1475
  validateIdentifier(sessionId, 'sessionId');
1171
1476
  await state.scopeQueue;
1172
1477
  const deletionKey = state.tombstones.get(sessionId)?.rootSessionId ?? sessionId;
@@ -1241,6 +1546,21 @@ export class FlexHarness<TScope = unknown> {
1241
1546
  }
1242
1547
  });
1243
1548
  const reason = this.trustInternalError(new FlexHarnessAbortError('The session subtree was deleted.'));
1549
+ const slashCommandErrors: unknown[] = [];
1550
+ for (const deleted of deletedSessions) {
1551
+ const listingSettlement = this.abortSlashCommandListings(
1552
+ state.storageKey,
1553
+ reason,
1554
+ deleted.sessionId,
1555
+ );
1556
+ if (listingSettlement) await listingSettlement;
1557
+ await this.abortSlashCommandExecutions(
1558
+ state.storageKey,
1559
+ reason,
1560
+ slashCommandErrors,
1561
+ deleted.sessionId,
1562
+ );
1563
+ }
1244
1564
  const childFirstDeletedSessions = [...deletedSessions].sort((left, right) =>
1245
1565
  (right.depth ?? 0) - (left.depth ?? 0)
1246
1566
  || left.sessionId.localeCompare(right.sessionId));
@@ -1255,7 +1575,10 @@ export class FlexHarness<TScope = unknown> {
1255
1575
  }
1256
1576
  }
1257
1577
  try {
1258
- const orphanErrors = await this.closeOrphanedResources(state.storageKey);
1578
+ const orphanErrors = [
1579
+ ...slashCommandErrors,
1580
+ ...await this.closeOrphanedResources(state.storageKey),
1581
+ ];
1259
1582
  if (orphanErrors.length > 0) throw combineErrors(orphanErrors);
1260
1583
  for (const descendantRoot of descendantRoots) {
1261
1584
  await this.finishTombstoneCleanup(
@@ -1282,7 +1605,7 @@ export class FlexHarness<TScope = unknown> {
1282
1605
  const stored = this.requireSession(state, sessionId);
1283
1606
  await stored.projectionQueue;
1284
1607
  this.assertStateAcceptingWork(state);
1285
- return publicSnapshot(stored.messages);
1608
+ return publicSnapshot(this.visibleMessages(stored));
1286
1609
  }
1287
1610
 
1288
1611
  public async listMessagePage(
@@ -1312,20 +1635,21 @@ export class FlexHarness<TScope = unknown> {
1312
1635
  await stored.projectionQueue;
1313
1636
  this.assertStateAcceptingWork(state);
1314
1637
  const namespace = this.messageCursorNamespace(state.storageKey);
1315
- let end = stored.messages.length;
1638
+ const visibleMessages = this.visibleMessages(stored);
1639
+ let end = visibleMessages.length;
1316
1640
  if (options.before !== undefined) {
1317
1641
  const cursor = this.parseMessageCursor(options.before);
1318
1642
  if (cursor.namespace !== namespace || cursor.sessionId !== sessionId) {
1319
1643
  throw new FlexHarnessValidationError('Message page cursor is invalid.');
1320
1644
  }
1321
- const anchor = stored.messages.findIndex((message) => message.messageId === cursor.anchorMessageId);
1645
+ const anchor = visibleMessages.findIndex((message) => message.messageId === cursor.anchorMessageId);
1322
1646
  if (anchor < 0) throw new FlexHarnessValidationError('Message page cursor is stale.');
1323
1647
  end = anchor;
1324
1648
  }
1325
1649
  let start = end;
1326
1650
  let messages: IFlexMessage[] = [];
1327
1651
  for (let index = end - 1; index >= 0 && messages.length < limit; index--) {
1328
- const candidate = createBoundedTransferMessage(stored.messages[index]);
1652
+ const candidate = createBoundedTransferMessage(visibleMessages[index]);
1329
1653
  const candidateMessages = [candidate, ...messages];
1330
1654
  const nextCursor = index > 0
1331
1655
  ? this.createMessageCursor(namespace, sessionId, candidate.messageId)
@@ -1357,88 +1681,624 @@ export class FlexHarness<TScope = unknown> {
1357
1681
  return publicSnapshot(createBoundedTransferMessage(this.requireMessage(stored, messageId)));
1358
1682
  }
1359
1683
 
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(
1684
+ public async listSlashCommands(
1371
1685
  scopeId: string,
1372
1686
  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;
1687
+ ): Promise<IFlexSlashCommandDescriptor[]> {
1688
+ const { state } = await this.resolveState(scopeId);
1689
+ return this.trackSlashCommandListing(scopeId, state, sessionId, async (signal) => {
1690
+ await this.awaitReversionOperation(state.scopeQueue, signal);
1691
+ signal.throwIfAborted();
1692
+ this.assertStateAcceptingWork(state);
1693
+ const stored = this.requireSession(state, sessionId);
1694
+ await this.reconcileContextAvailability(state, stored);
1695
+ signal.throwIfAborted();
1696
+ const sessionAvailable = this.isSessionAvailableForSlashCommand(state, stored);
1697
+ const reversionIdleReason = await this.reversionUnavailableReason(
1698
+ state,
1699
+ stored,
1700
+ false,
1701
+ signal,
1702
+ );
1703
+ signal.throwIfAborted();
1704
+ return this.createSlashCommandDescriptors(
1705
+ state,
1706
+ stored,
1707
+ sessionAvailable,
1708
+ reversionIdleReason,
1709
+ );
1710
+ });
1379
1711
  }
1380
1712
 
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;
1713
+ private createSlashCommandDescriptors(
1714
+ state: IStorageState,
1715
+ stored: IStoredSessionState,
1716
+ sessionAvailable: boolean,
1717
+ reversionIdleReason?: string,
1718
+ ): IFlexSlashCommandDescriptor[] {
1719
+ const sessionAvailability = sessionAvailable
1720
+ ? {}
1721
+ : { available: false, unavailableReason: slashCommandSessionUnavailableReason };
1722
+ const undoUnit = this.reversionUnit(stored, 'undo');
1723
+ const redoUnit = this.reversionUnit(stored, 'redo');
1724
+ const workspaceReversion = (unit: ReturnType<typeof this.reversionUnit>) =>
1725
+ !unit
1726
+ || !this.turnReversionProvider
1727
+ || unit.segments.some((segment) => segment.provenance !== 'workspace')
1728
+ ? 'unsupported' as const
1729
+ : 'supported' as const;
1730
+ const reversionAvailability = (unit: ReturnType<typeof this.reversionUnit>) => {
1731
+ if (reversionIdleReason) return {
1732
+ available: false,
1733
+ unavailableReason: reversionIdleReason,
1734
+ };
1735
+ if (!unit) return { available: false, unavailableReason: 'No turn is available.' };
1736
+ if (!unit.target.contextAvailable) return {
1737
+ available: false,
1738
+ unavailableReason: 'The turn is beyond the context archive horizon.',
1739
+ };
1740
+ if (unit.segments.some((segment) =>
1741
+ segment.disposition === 'revertible' && segment.workspaceReference === undefined)) return {
1742
+ available: false,
1743
+ unavailableReason: 'The turn has no available workspace reversion capture.',
1744
+ };
1745
+ return { available: true };
1746
+ };
1747
+ const compactAvailable = Boolean(this.agentSessionPolicy.contextCompactor) && sessionAvailable;
1748
+ const compactUnavailableReason = !this.agentSessionPolicy.contextCompactor
1749
+ ? 'No context compactor is configured.'
1750
+ : !sessionAvailable
1751
+ ? slashCommandSessionUnavailableReason
1752
+ : undefined;
1753
+ const initPrompt = expandSlashCommandTemplate(FLEX_SLASH_COMMAND_INITIALIZE_TEMPLATE, '', []);
1754
+ const initAvailability = this.slashCommandPromptAvailability(
1755
+ state,
1756
+ stored,
1757
+ this.promptAdmissionByteSize(initPrompt, {}),
1758
+ );
1759
+ const descriptors: IFlexSlashCommandDescriptor[] = [
1760
+ {
1761
+ name: 'compact',
1762
+ description: 'Compact the current session context.',
1763
+ kind: 'builtin',
1764
+ hints: [],
1765
+ available: compactAvailable,
1766
+ ...(compactUnavailableReason === undefined
1767
+ ? {}
1768
+ : { unavailableReason: compactUnavailableReason }),
1769
+ workspaceReversion: 'not-applicable',
1770
+ },
1771
+ {
1772
+ name: 'init',
1773
+ description: 'Create or update AGENTS.md for the active workspace.',
1774
+ kind: 'builtin',
1775
+ hints: ['$ARGUMENTS'],
1776
+ ...initAvailability,
1777
+ workspaceReversion: 'not-applicable',
1778
+ },
1779
+ {
1780
+ name: 'undo',
1781
+ description: 'Undo the most recent session operation.',
1782
+ kind: 'builtin',
1783
+ hints: [],
1784
+ ...reversionAvailability(undoUnit),
1785
+ workspaceReversion: workspaceReversion(undoUnit),
1786
+ },
1787
+ {
1788
+ name: 'redo',
1789
+ description: 'Redo the most recently undone session operation.',
1790
+ kind: 'builtin',
1791
+ hints: [],
1792
+ ...reversionAvailability(redoUnit),
1793
+ workspaceReversion: workspaceReversion(redoUnit),
1794
+ },
1795
+ ];
1796
+ for (const registration of this.slashCommands.values()) {
1797
+ const template = typeof registration.template === 'string';
1798
+ const availability = template
1799
+ ? this.slashCommandPromptAvailability(
1800
+ state,
1801
+ stored,
1802
+ this.promptAdmissionByteSize(
1803
+ expandSlashCommandTemplate(registration.template!, '', []),
1804
+ {},
1805
+ ),
1806
+ )
1807
+ : { available: sessionAvailable, ...sessionAvailability };
1808
+ descriptors.push({
1809
+ name: registration.name,
1810
+ description: registration.description ?? '',
1811
+ kind: template ? 'template' : 'handler',
1812
+ hints: template ? slashCommandTemplateHints(registration.template!) : [],
1813
+ ...availability,
1814
+ workspaceReversion: 'not-applicable',
1815
+ });
1816
+ }
1817
+ return publicSnapshot(descriptors);
1390
1818
  }
1391
1819
 
1392
- public async schedulePrompt(
1820
+ public async executeSlashCommand(
1393
1821
  scopeId: string,
1394
1822
  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}.`,
1823
+ untouchedInput: string,
1824
+ options: IFlexSlashCommandExecutionOptions = {},
1825
+ ): Promise<TFlexSlashCommandExecutionResult> {
1826
+ const currentInvocation = this.slashCommandInvocationContext.getStore();
1827
+ if (currentInvocation?.scopeId === scopeId && currentInvocation.sessionId === sessionId) {
1828
+ throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(sessionId));
1829
+ }
1830
+ const parsed = parseSlashCommand(untouchedInput);
1831
+ if (parsed.type !== 'parsed') return parsed;
1832
+ const registration = this.slashCommands.get(parsed.name);
1833
+ if (!reservedSlashCommandNames.has(parsed.name) && !registration) {
1834
+ return Object.freeze({
1835
+ type: 'unknown',
1836
+ input: parsed.input,
1837
+ name: parsed.name,
1838
+ rawArguments: parsed.rawArguments,
1839
+ arguments: parsed.arguments,
1840
+ });
1841
+ }
1842
+ const promptOptions = validateSlashCommandExecutionOptions(options);
1843
+ const { scope, state } = await this.resolveState(scopeId);
1844
+ await state.scopeQueue;
1845
+ this.assertOpen();
1846
+ this.assertStateAcceptingWork(state);
1847
+ const stored = this.requireSession(state, sessionId);
1848
+ const sessionKey = this.slashCommandSessionKey(state.storageKey, sessionId);
1849
+ const invocationOwner = this.slashCommandInvocationContext.getStore();
1850
+ if (invocationOwner?.sessionKey === sessionKey) {
1851
+ throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(sessionId));
1852
+ }
1853
+ if (this.activeSlashCommandExecutions.has(sessionKey)) {
1854
+ throw new FlexHarnessSessionBusyError(
1855
+ sessionId,
1856
+ 'already has an active slash command execution',
1406
1857
  );
1407
1858
  }
1408
- const queued = await this.enqueuePromptInternal(
1859
+ if (parsed.name === 'undo' || parsed.name === 'redo') {
1860
+ if (parsed.arguments.length > 0) {
1861
+ throw new FlexHarnessValidationError(`Slash command "/${parsed.name}" does not accept arguments.`);
1862
+ }
1863
+ await this.changeReversionCursorResolved(
1864
+ scopeId,
1865
+ sessionId,
1866
+ parsed.name,
1867
+ scope.scope,
1868
+ state,
1869
+ options.signal,
1870
+ );
1871
+ return Object.freeze({ type: 'operation', name: parsed.name });
1872
+ }
1873
+ if (parsed.name === 'compact') {
1874
+ if (parsed.arguments.length > 0) {
1875
+ throw new FlexHarnessValidationError('Slash command "/compact" does not accept arguments.');
1876
+ }
1877
+ if (!this.agentSessionPolicy.contextCompactor) {
1878
+ throw new FlexHarnessSlashCommandUnavailableError(
1879
+ parsed.name,
1880
+ 'No context compactor is configured.',
1881
+ );
1882
+ }
1883
+ this.requireSessionAvailableForSlashCommand(state, stored, parsed.name);
1884
+ return this.trackSlashCommandExecution(
1885
+ scopeId,
1886
+ state,
1887
+ sessionId,
1888
+ 'operation',
1889
+ options.signal,
1890
+ async (signal) => {
1891
+ await this.commitRevertedBranch(state, stored, scopeId, scope.scope);
1892
+ await this.compactStoredSession(scopeId, scope.scope, state, stored, signal);
1893
+ return Object.freeze({ type: 'operation', name: parsed.name });
1894
+ },
1895
+ );
1896
+ }
1897
+ if (options.signal?.aborted) {
1898
+ throw this.trustInternalError(new FlexHarnessAbortError('The slash command was aborted.'));
1899
+ }
1900
+ if (parsed.name === 'init' || typeof registration?.template === 'string') {
1901
+ const template = parsed.name === 'init'
1902
+ ? FLEX_SLASH_COMMAND_INITIALIZE_TEMPLATE
1903
+ : registration!.template!;
1904
+ const expanded = expandSlashCommandTemplate(template, parsed.rawArguments, parsed.arguments);
1905
+ normalizeFlexPrompt(expanded);
1906
+ this.requireSlashCommandPromptAvailable(
1907
+ state,
1908
+ stored,
1909
+ parsed.name,
1910
+ this.promptAdmissionByteSize(expanded, promptOptions),
1911
+ );
1912
+ return this.trackSlashCommandExecution(
1913
+ scopeId,
1914
+ state,
1915
+ sessionId,
1916
+ 'prompt-admission',
1917
+ options.signal,
1918
+ async (signal) => {
1919
+ const queued = await this.enqueuePromptInternal(
1920
+ scopeId,
1921
+ sessionId,
1922
+ expanded,
1923
+ promptOptions,
1924
+ undefined,
1925
+ undefined,
1926
+ false,
1927
+ signal,
1928
+ true,
1929
+ );
1930
+ const cancellation = this.trustInternalError(
1931
+ new FlexHarnessAbortError('The slash command prompt was aborted.'),
1932
+ );
1933
+ const cancel = () => {
1934
+ const entry = queued.queued;
1935
+ if (!entry) return;
1936
+ this.cancelQueuedPrompt(
1937
+ entry,
1938
+ cancellation,
1939
+ this.createCompactorContext(scopeId, scope.scope, state.storageKey, sessionId),
1940
+ );
1941
+ };
1942
+ const cancellationSignals = options.signal && options.signal !== signal
1943
+ ? [signal, options.signal]
1944
+ : [signal];
1945
+ for (const cancellationSignal of cancellationSignals) {
1946
+ cancellationSignal.addEventListener('abort', cancel, { once: true });
1947
+ }
1948
+ if (cancellationSignals.some((cancellationSignal) => cancellationSignal.aborted)) cancel();
1949
+ let admission: IFlexPromptAdmission;
1950
+ try {
1951
+ admission = await queued.started;
1952
+ if (cancellationSignals.some((cancellationSignal) => cancellationSignal.aborted)) {
1953
+ cancel();
1954
+ throw cancellationSignals.find((cancellationSignal) => cancellationSignal.aborted)?.reason
1955
+ ?? cancellation;
1956
+ }
1957
+ } catch (error) {
1958
+ for (const cancellationSignal of cancellationSignals) {
1959
+ cancellationSignal.removeEventListener('abort', cancel);
1960
+ }
1961
+ throw error;
1962
+ }
1963
+ void admission.completion.finally(() => {
1964
+ for (const cancellationSignal of cancellationSignals) {
1965
+ cancellationSignal.removeEventListener('abort', cancel);
1966
+ }
1967
+ }).catch(() => undefined);
1968
+ return Object.freeze({ type: 'prompt-admission', name: parsed.name, admission });
1969
+ },
1970
+ );
1971
+ }
1972
+ this.requireSessionAvailableForSlashCommand(state, stored, parsed.name);
1973
+ return this.executeSlashCommandHandler(
1409
1974
  scopeId,
1410
- sessionId,
1411
- prompt,
1412
- options,
1413
- scheduleKey,
1414
- debounceMs,
1975
+ scope.scope,
1976
+ state,
1977
+ stored,
1978
+ parsed.name,
1979
+ parsed.rawArguments,
1980
+ parsed.arguments,
1981
+ registration!.handler!,
1982
+ options.signal,
1415
1983
  );
1416
- const admission = await queued.started;
1417
- return Object.freeze({ ...admission, scheduleKey });
1418
1984
  }
1419
1985
 
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);
1986
+ private isSessionAvailableForSlashCommand(
1987
+ state: IStorageState,
1988
+ stored: IStoredSessionState,
1989
+ allowPendingApply = false,
1990
+ ): boolean {
1991
+ return this.isSessionRuntimeIdle(state, stored)
1992
+ && !this.activeSlashCommandExecutions.has(
1993
+ this.slashCommandSessionKey(state.storageKey, stored.session.sessionId),
1994
+ )
1995
+ && (stored.pendingReversion === undefined
1996
+ || (allowPendingApply && stored.pendingReversion.kind === 'apply'));
1433
1997
  }
1434
1998
 
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);
1999
+ private slashCommandPromptUnavailableReason(
2000
+ state: IStorageState,
2001
+ stored: IStoredSessionState,
2002
+ byteSize: number,
2003
+ ): string | undefined {
2004
+ if (state.lifecycle !== 'active') return 'The session namespace is not accepting work.';
2005
+ if (this.activeSlashCommandExecutions.has(
2006
+ this.slashCommandSessionKey(state.storageKey, stored.session.sessionId),
2007
+ )) return 'Session already has an active slash command execution.';
2008
+ if (stored.pendingReversion) return 'Session has a pending reversion operation.';
2009
+ if (stored.session.agent !== undefined) {
2010
+ return 'Subagent sessions can only be prompted through the foreground task tool.';
2011
+ }
2012
+ if (stored.outstandingPromptsById.size >= this.promptQueueLimits.maxOutstandingPromptsPerSession) {
2013
+ return 'Session has reached its outstanding prompt limit.';
2014
+ }
2015
+ if (stored.outstandingPromptBytes + byteSize > this.promptQueueLimits.maxOutstandingBytesPerSession) {
2016
+ return 'Session has reached its outstanding prompt byte limit.';
2017
+ }
2018
+ if (this.pendingPromptAdmissions >= this.promptQueueLimits.maxPendingAdmissions) {
2019
+ return 'FlexHarness has reached its pending prompt admission limit.';
2020
+ }
2021
+ if (this.pendingPromptAdmissionBytes + byteSize > this.promptQueueLimits.maxPendingAdmissionBytes) {
2022
+ return 'FlexHarness has reached its pending prompt admission byte limit.';
2023
+ }
2024
+ return undefined;
2025
+ }
2026
+
2027
+ private slashCommandPromptAvailability(
2028
+ state: IStorageState,
2029
+ stored: IStoredSessionState,
2030
+ byteSize: number,
2031
+ ): { available: boolean; unavailableReason?: string } {
2032
+ const unavailableReason = this.slashCommandPromptUnavailableReason(state, stored, byteSize);
2033
+ return unavailableReason === undefined
2034
+ ? { available: true }
2035
+ : { available: false, unavailableReason };
2036
+ }
2037
+
2038
+ private requireSlashCommandPromptAvailable(
2039
+ state: IStorageState,
2040
+ stored: IStoredSessionState,
2041
+ commandName: string,
2042
+ byteSize: number,
2043
+ ): void {
2044
+ const reason = this.slashCommandPromptUnavailableReason(state, stored, byteSize);
2045
+ if (reason) throw new FlexHarnessSlashCommandUnavailableError(commandName, reason);
2046
+ }
2047
+
2048
+ private promptAdmissionByteSize(
2049
+ prompt: TFlexPrompt,
2050
+ options: IFlexPromptOptions,
2051
+ scheduleKey?: string,
2052
+ debounceMs?: number,
2053
+ ): number {
2054
+ return jsonBytes({
2055
+ prompt: normalizeFlexPrompt(prompt),
2056
+ options: cloneSerializable(options),
2057
+ scheduleKey,
2058
+ debounceMs,
2059
+ });
2060
+ }
2061
+
2062
+ private isSessionRuntimeIdle(state: IStorageState, stored: IStoredSessionState): boolean {
2063
+ return stored.session.status === 'idle'
2064
+ && !state.activeRuns.has(stored.session.sessionId)
2065
+ && stored.outstandingPromptsById.size === 0;
2066
+ }
2067
+
2068
+ private slashCommandSessionKey(storageKey: string, sessionId: string): string {
2069
+ return JSON.stringify([storageKey, sessionId]);
2070
+ }
2071
+
2072
+ private requireSessionAvailableForSlashCommand(
2073
+ state: IStorageState,
2074
+ stored: IStoredSessionState,
2075
+ commandName: string,
2076
+ ): void {
2077
+ if (!this.isSessionAvailableForSlashCommand(state, stored)) {
2078
+ throw new FlexHarnessSlashCommandUnavailableError(
2079
+ commandName,
2080
+ slashCommandSessionUnavailableReason,
2081
+ );
2082
+ }
2083
+ }
2084
+
2085
+ private trackSlashCommandExecution<TResult>(
2086
+ scopeId: string,
2087
+ state: IStorageState,
2088
+ sessionId: string,
2089
+ kind: IActiveSlashCommandExecution['kind'],
2090
+ externalSignal: AbortSignal | undefined,
2091
+ operation: (signal: AbortSignal) => Promise<TResult>,
2092
+ ): Promise<TResult> {
2093
+ const sessionKey = this.slashCommandSessionKey(state.storageKey, sessionId);
2094
+ if (this.activeSlashCommandExecutions.has(sessionKey)) {
2095
+ throw new FlexHarnessSessionBusyError(
2096
+ sessionId,
2097
+ 'already has an active slash command execution',
2098
+ );
2099
+ }
2100
+ const controller = new AbortController();
2101
+ const abortFromExternalSignal = () => {
2102
+ if (!controller.signal.aborted) {
2103
+ controller.abort(this.trustInternalError(
2104
+ new FlexHarnessAbortError('The slash command was aborted.'),
2105
+ ));
2106
+ }
2107
+ };
2108
+ externalSignal?.addEventListener('abort', abortFromExternalSignal, { once: true });
2109
+ if (externalSignal?.aborted) abortFromExternalSignal();
2110
+ const activityOwner = Object.freeze({ scopeId, storageKey: state.storageKey, sessionId });
2111
+ const completion = Promise.resolve().then(() => this.slashCommandActivityContext.run(activityOwner, () => {
2112
+ if (controller.signal.aborted) throw controller.signal.reason;
2113
+ return operation(controller.signal);
2114
+ }));
2115
+ const active: IActiveSlashCommandExecution = {
2116
+ kind,
2117
+ storageKey: state.storageKey,
2118
+ sessionId,
2119
+ controller,
2120
+ completion,
2121
+ };
2122
+ this.activeSlashCommandExecutions.set(sessionKey, active);
2123
+ return completion.finally(() => {
2124
+ externalSignal?.removeEventListener('abort', abortFromExternalSignal);
2125
+ if (this.activeSlashCommandExecutions.get(sessionKey) === active) {
2126
+ this.activeSlashCommandExecutions.delete(sessionKey);
2127
+ }
2128
+ });
2129
+ }
2130
+
2131
+ private trackSlashCommandListing<TResult>(
2132
+ scopeId: string,
2133
+ state: IStorageState,
2134
+ sessionId: string,
2135
+ operation: (signal: AbortSignal) => Promise<TResult>,
2136
+ ): Promise<TResult> {
2137
+ const controller = new AbortController();
2138
+ const activityOwner = Object.freeze({ scopeId, storageKey: state.storageKey, sessionId });
2139
+ const completion = Promise.resolve().then(() =>
2140
+ this.slashCommandActivityContext.run(activityOwner, () => operation(controller.signal)));
2141
+ const active: IActiveSlashCommandListing = {
2142
+ storageKey: state.storageKey,
2143
+ sessionId,
2144
+ controller,
2145
+ completion,
2146
+ };
2147
+ this.activeSlashCommandListings.add(active);
2148
+ return completion.finally(() => {
2149
+ this.activeSlashCommandListings.delete(active);
2150
+ });
2151
+ }
2152
+
2153
+ private async executeSlashCommandHandler(
2154
+ scopeId: string,
2155
+ scope: TScope,
2156
+ state: IStorageState,
2157
+ stored: IStoredSessionState,
2158
+ commandName: string,
2159
+ rawArguments: string,
2160
+ arguments_: readonly string[],
2161
+ handler: IFlexSlashCommandHandlerRegistration<TScope>['handler'],
2162
+ externalSignal?: AbortSignal,
2163
+ ): Promise<TFlexSlashCommandExecutionResult> {
2164
+ const sessionKey = this.slashCommandSessionKey(state.storageKey, stored.session.sessionId);
2165
+ const invocationOwner = this.slashCommandInvocationContext.getStore();
2166
+ if (invocationOwner?.sessionKey === sessionKey) {
2167
+ throw this.trustInternalError(
2168
+ new FlexHarnessSlashCommandReentryError(stored.session.sessionId),
2169
+ );
2170
+ }
2171
+ if (this.activeSlashCommandExecutions.has(sessionKey)) {
2172
+ throw new FlexHarnessSessionBusyError(
2173
+ stored.session.sessionId,
2174
+ 'already has an active slash command execution',
2175
+ );
2176
+ }
2177
+ const owner = Object.freeze({
2178
+ scopeId,
2179
+ storageKey: state.storageKey,
2180
+ sessionId: stored.session.sessionId,
2181
+ sessionKey,
2182
+ });
2183
+ return this.trackSlashCommandExecution(
2184
+ scopeId,
2185
+ state,
2186
+ stored.session.sessionId,
2187
+ 'handler',
2188
+ externalSignal,
2189
+ (signal) => this.slashCommandInvocationContext.run(owner, async () => {
2190
+ const context = Object.freeze({
2191
+ scopeId,
2192
+ scope,
2193
+ storageKey: state.storageKey,
2194
+ sessionId: stored.session.sessionId,
2195
+ rawArguments,
2196
+ arguments: arguments_,
2197
+ signal,
2198
+ });
2199
+ try {
2200
+ await this.commitRevertedBranch(state, stored, scopeId, scope);
2201
+ const result = await handler(context);
2202
+ return Object.freeze({
2203
+ type: 'handler-result' as const,
2204
+ name: commandName,
2205
+ result: normalizeJsonValue(result === undefined ? null : result, this.toolOutputLimits),
2206
+ });
2207
+ } catch (error) {
2208
+ throw this.projectOperationError(
2209
+ error,
2210
+ 'slashCommand',
2211
+ scopeId,
2212
+ stored.session.sessionId,
2213
+ `slash-command:${commandName}`,
2214
+ );
2215
+ }
2216
+ }),
2217
+ );
2218
+ }
2219
+
2220
+ public async prompt(
2221
+ scopeId: string,
2222
+ sessionId: string,
2223
+ prompt: TFlexPrompt,
2224
+ options: IFlexPromptOptions = {},
2225
+ ): Promise<IFlexPromptResult> {
2226
+ const admission = await this.startPrompt(scopeId, sessionId, prompt, options);
2227
+ return admission.completion;
2228
+ }
2229
+
2230
+ public async startPrompt(
2231
+ scopeId: string,
2232
+ sessionId: string,
2233
+ prompt: TFlexPrompt,
2234
+ options: IFlexPromptOptions = {},
2235
+ ): Promise<IFlexPromptAdmission> {
2236
+ validatePromptOptions(options, false);
2237
+ const queued = await this.enqueuePromptInternal(scopeId, sessionId, prompt, options);
2238
+ return queued.started;
2239
+ }
2240
+
2241
+ public async enqueuePrompt(
2242
+ scopeId: string,
2243
+ sessionId: string,
2244
+ prompt: TFlexPrompt,
2245
+ options: IFlexPromptOptions = {},
2246
+ ): Promise<IFlexPromptQueueAdmission> {
2247
+ validatePromptOptions(options, false);
2248
+ const queued = await this.enqueuePromptInternal(scopeId, sessionId, prompt, options);
2249
+ return queued.admission;
2250
+ }
2251
+
2252
+ public async schedulePrompt(
2253
+ scopeId: string,
2254
+ sessionId: string,
2255
+ scheduleKey: string,
2256
+ prompt: TFlexPrompt,
2257
+ options: IFlexSchedulePromptOptions = {},
2258
+ ): Promise<IFlexScheduledPromptAdmission> {
2259
+ validateIdentifier(scheduleKey, 'scheduleKey');
2260
+ requireTransferIdentifier(scheduleKey, 'scheduleKey');
2261
+ validatePromptOptions(options, true);
2262
+ const debounceMs = options.debounceMs ?? 50;
2263
+ if (!Number.isSafeInteger(debounceMs) || debounceMs < 0 || debounceMs > maxScheduleDebounceMs) {
2264
+ throw new FlexHarnessValidationError(
2265
+ `debounceMs must be an integer from 0 through ${maxScheduleDebounceMs}.`,
2266
+ );
2267
+ }
2268
+ const queued = await this.enqueuePromptInternal(
2269
+ scopeId,
2270
+ sessionId,
2271
+ prompt,
2272
+ options,
2273
+ scheduleKey,
2274
+ debounceMs,
2275
+ );
2276
+ const admission = await queued.started;
2277
+ return Object.freeze({ ...admission, scheduleKey });
2278
+ }
2279
+
2280
+ public async getPromptQueueEntry(
2281
+ scopeId: string,
2282
+ sessionId: string,
2283
+ queueId: string,
2284
+ ): Promise<IFlexPromptQueueEntry> {
2285
+ validateIdentifier(queueId, 'queueId');
2286
+ requireTransferIdentifier(queueId, 'queueId');
2287
+ const { state } = await this.resolveState(scopeId);
2288
+ this.assertStateAcceptingWork(state);
2289
+ const stored = this.requireSession(state, sessionId);
2290
+ const entry = this.promptQueueEntry(stored, queueId);
2291
+ if (!entry) throw new FlexHarnessNotFoundError('Prompt queue entry', queueId);
2292
+ return publicSnapshot(entry);
2293
+ }
2294
+
2295
+ public async listPromptQueueEntries(
2296
+ scopeId: string,
2297
+ sessionId: string,
2298
+ ): Promise<IFlexPromptQueueEntry[]> {
2299
+ const { state } = await this.resolveState(scopeId);
2300
+ this.assertStateAcceptingWork(state);
2301
+ const stored = this.requireSession(state, sessionId);
1442
2302
  return publicSnapshot([
1443
2303
  ...[...stored.outstandingPromptsById.values()].map((entry) => this.projectPromptQueueEntry(entry)),
1444
2304
  ...stored.terminalPromptQueueEntries.values(),
@@ -1618,90 +2478,692 @@ export class FlexHarness<TScope = unknown> {
1618
2478
  public async compactSession(scopeId: string, sessionId: string): Promise<void> {
1619
2479
  const { scope, state } = await this.resolveState(scopeId);
1620
2480
  const stored = this.requireSession(state, sessionId);
2481
+ const invocationOwner = this.slashCommandInvocationContext.getStore();
2482
+ if (invocationOwner?.sessionKey === this.slashCommandSessionKey(state.storageKey, sessionId)) {
2483
+ throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(sessionId));
2484
+ }
1621
2485
  this.assertStateAcceptingWork(state);
1622
- try {
1623
- await this.withCompactorContext(
1624
- this.createCompactorContext(scopeId, scope.scope, state.storageKey, sessionId),
1625
- () => stored.agentSession.compact(),
2486
+ if (!this.agentSessionPolicy.contextCompactor) {
2487
+ throw new FlexHarnessSlashCommandUnavailableError(
2488
+ 'compact',
2489
+ 'No context compactor is configured.',
1626
2490
  );
1627
- } catch (error) {
1628
- throw this.projectOperationError(error, 'agentSession', scopeId, sessionId, 'compaction');
1629
2491
  }
2492
+ this.requireSessionAvailableForSlashCommand(state, stored, 'compact');
2493
+ await this.trackSlashCommandExecution(
2494
+ scopeId,
2495
+ state,
2496
+ sessionId,
2497
+ 'operation',
2498
+ undefined,
2499
+ async (signal) => {
2500
+ await this.commitRevertedBranch(state, stored, scopeId, scope.scope);
2501
+ await this.compactStoredSession(scopeId, scope.scope, state, stored, signal);
2502
+ },
2503
+ );
1630
2504
  }
1631
2505
 
1632
- public async archiveSessionEvents(
2506
+ private async compactStoredSession(
1633
2507
  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);
2508
+ scope: TScope,
2509
+ state: IStorageState,
2510
+ stored: IStoredSessionState,
2511
+ signal?: AbortSignal,
2512
+ ): Promise<void> {
1644
2513
  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
- });
2514
+ await this.withCompactorContext(
2515
+ this.createCompactorContext(
2516
+ scopeId,
2517
+ scope,
2518
+ state.storageKey,
2519
+ stored.session.sessionId,
2520
+ ),
2521
+ () => stored.agentSession.compact(signal === undefined ? {} : { abort: signal }),
2522
+ );
1653
2523
  } catch (error) {
1654
- throw this.projectOperationError(error, 'agentSession', scopeId, sessionId, 'archive');
2524
+ throw this.projectOperationError(
2525
+ error,
2526
+ 'agentSession',
2527
+ scopeId,
2528
+ stored.session.sessionId,
2529
+ 'compaction',
2530
+ );
1655
2531
  }
1656
2532
  }
1657
2533
 
1658
- public async listBackgroundExecutions(
2534
+ public async undoSession(
1659
2535
  scopeId: string,
1660
2536
  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
- }
2537
+ signal?: AbortSignal,
2538
+ ): Promise<IFlexUndoSessionResult> {
2539
+ const result = await this.changeReversionCursor(scopeId, sessionId, 'undo', signal);
2540
+ return Object.freeze({ revertedRunId: result });
1675
2541
  }
1676
2542
 
1677
- public async getBackgroundExecution(
2543
+ public async redoSession(
1678
2544
  scopeId: string,
1679
2545
  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');
2546
+ signal?: AbortSignal,
2547
+ ): Promise<IFlexRedoSessionResult> {
2548
+ const result = await this.changeReversionCursor(scopeId, sessionId, 'redo', signal);
2549
+ return Object.freeze({ restoredRunId: result });
2550
+ }
2551
+
2552
+ private reversionUnit(
2553
+ stored: IStoredSessionState,
2554
+ direction: 'undo' | 'redo',
2555
+ ): { target: IFlexReversionSegment; segments: IFlexReversionSegment[]; toCursor: number } | undefined {
2556
+ const groups = this.reversionGroups(stored);
2557
+ if (direction === 'undo') {
2558
+ let start = stored.revertCursor - 1;
2559
+ while (start >= 0 && groups[start]?.kind === 'no-change') start--;
2560
+ const candidate = groups[start];
2561
+ if (!candidate || candidate.kind !== 'candidate') return undefined;
2562
+ return {
2563
+ target: candidate.target,
2564
+ segments: groups.slice(start, stored.revertCursor).flatMap((group) => group.segments),
2565
+ toCursor: start,
2566
+ };
1693
2567
  }
2568
+ const candidate = groups[stored.revertCursor];
2569
+ if (!candidate || candidate.kind !== 'candidate') return undefined;
2570
+ let end = stored.revertCursor + 1;
2571
+ while (groups[end]?.kind === 'no-change') end++;
2572
+ return {
2573
+ target: candidate.target,
2574
+ segments: groups.slice(stored.revertCursor, end).flatMap((group) => group.segments),
2575
+ toCursor: end,
2576
+ };
1694
2577
  }
1695
2578
 
1696
- public async abortBackgroundExecution(
1697
- scopeId: string,
1698
- sessionId: string,
1699
- executionId: string,
1700
- ): Promise<void> {
1701
- validateIdentifier(executionId, 'executionId');
1702
- requireTransferIdentifier(executionId, 'executionId');
1703
- const { scope, state } = await this.resolveState(scopeId);
1704
- const stored = this.requireSession(state, sessionId);
2579
+ private reversionGroups(stored: IStoredSessionState): IReversionGroup[] {
2580
+ const groups: IReversionGroup[] = [];
2581
+ let leading: IFlexReversionSegment[] = [];
2582
+ for (const segment of stored.reversionSegments) {
2583
+ if (segment.status === 'completed') {
2584
+ const segments = groups.length === 0 ? [...leading, segment] : [segment];
2585
+ leading = [];
2586
+ groups.push({
2587
+ target: segment,
2588
+ segments,
2589
+ kind: 'candidate',
2590
+ affectedWorkspaces: [],
2591
+ affectedWorkspacesTruncated: false,
2592
+ });
2593
+ } else if (groups.length > 0) {
2594
+ groups[groups.length - 1].segments.push(segment);
2595
+ } else {
2596
+ leading.push(segment);
2597
+ }
2598
+ }
2599
+ for (const group of groups) {
2600
+ const affected = new Map<string, IFlexAffectedWorkspace>();
2601
+ for (const workspace of group.segments.flatMap((segment) =>
2602
+ segment.affectedWorkspaces ?? [])) {
2603
+ if (affected.has(workspace.id)) continue;
2604
+ if (affected.size < FLEX_REVERSION_MAX_AFFECTED_WORKSPACES) {
2605
+ affected.set(workspace.id, cloneSerializable(workspace));
2606
+ } else {
2607
+ group.affectedWorkspacesTruncated = true;
2608
+ }
2609
+ }
2610
+ group.affectedWorkspaces = [...affected.values()];
2611
+ if (this.reversionPolicy === 'transcript-optional') continue;
2612
+ if (group.segments.some((segment) =>
2613
+ segment.status === 'capturing'
2614
+ || segment.disposition === 'pending'
2615
+ || (segment.protocolVersion === 1 && segment.provenance === 'transcript')
2616
+ || segment.disposition === 'nonrevertible')) {
2617
+ group.kind = 'barrier';
2618
+ } else if (group.segments.some((segment) => segment.disposition === 'revertible')) {
2619
+ group.kind = 'candidate';
2620
+ } else {
2621
+ group.kind = 'no-change';
2622
+ }
2623
+ }
2624
+ return groups;
2625
+ }
2626
+
2627
+ private async requireReversionIdle(
2628
+ state: IStorageState,
2629
+ stored: IStoredSessionState,
2630
+ sessionId: string,
2631
+ allowPendingApply = false,
2632
+ operationSignal?: AbortSignal,
2633
+ ): Promise<void> {
2634
+ const reason = await this.reversionUnavailableReason(
2635
+ state,
2636
+ stored,
2637
+ allowPendingApply,
2638
+ operationSignal,
2639
+ );
2640
+ if (reason) throw new FlexHarnessSessionBusyError(sessionId, reason.toLowerCase().replace(/\.$/u, ''));
2641
+ }
2642
+
2643
+ private async reversionUnavailableReason(
2644
+ state: IStorageState,
2645
+ stored: IStoredSessionState,
2646
+ allowPendingApply = false,
2647
+ operationSignal?: AbortSignal,
2648
+ ): Promise<string | undefined> {
2649
+ if (stored.pendingReversion && !(allowPendingApply && stored.pendingReversion.kind === 'apply')) {
2650
+ return 'Session has a pending reversion operation.';
2651
+ }
2652
+ if (!this.isSessionRuntimeIdle(state, stored)) {
2653
+ return slashCommandSessionUnavailableReason;
2654
+ }
2655
+ const activeOperation = this.activeSlashCommandExecutions.get(
2656
+ this.slashCommandSessionKey(state.storageKey, stored.session.sessionId),
2657
+ );
2658
+ if (activeOperation && activeOperation.controller.signal !== operationSignal) {
2659
+ return 'Session already has an active slash command execution.';
2660
+ }
2661
+ if ([...state.pendingPermissions.values()].some((pending) =>
2662
+ !pending.settled && pending.request.sessionId === stored.session.sessionId)) {
2663
+ return 'Session has a pending permission.';
2664
+ }
2665
+ if (stored.agentSession.listUncertainToolExecutions().length > 0) {
2666
+ return 'Session has uncertain tool executions.';
2667
+ }
2668
+ if (stored.jobs) {
2669
+ operationSignal?.throwIfAborted();
2670
+ const jobs = Promise.resolve().then(() => stored.jobs!.list());
2671
+ if ((await this.awaitReversionOperation(jobs, operationSignal))
2672
+ .some((job) => job.state === 'running')) {
2673
+ return 'Session has running background jobs.';
2674
+ }
2675
+ }
2676
+ return undefined;
2677
+ }
2678
+
2679
+ private async awaitReversionOperation<TResult>(
2680
+ operation: Promise<TResult>,
2681
+ signal?: AbortSignal,
2682
+ ): Promise<TResult> {
2683
+ if (!signal) return operation;
2684
+ void operation.catch(() => undefined);
2685
+ let rejectForAbort!: () => void;
2686
+ const aborted = new Promise<never>((_resolve, reject) => {
2687
+ rejectForAbort = () => reject(
2688
+ signal.reason ?? this.trustInternalError(new FlexHarnessAbortError('The slash command was aborted.')),
2689
+ );
2690
+ signal.addEventListener('abort', rejectForAbort, { once: true });
2691
+ if (signal.aborted) rejectForAbort();
2692
+ });
2693
+ try {
2694
+ return await Promise.race([operation, aborted]);
2695
+ } finally {
2696
+ signal.removeEventListener('abort', rejectForAbort);
2697
+ }
2698
+ }
2699
+
2700
+ private async changeReversionCursor(
2701
+ scopeId: string,
2702
+ sessionId: string,
2703
+ direction: 'undo' | 'redo',
2704
+ signal?: AbortSignal,
2705
+ ): Promise<string> {
2706
+ const { scope, state } = await this.resolveState(scopeId);
2707
+ return this.changeReversionCursorResolved(
2708
+ scopeId,
2709
+ sessionId,
2710
+ direction,
2711
+ scope.scope,
2712
+ state,
2713
+ signal,
2714
+ );
2715
+ }
2716
+
2717
+ private async changeReversionCursorResolved(
2718
+ scopeId: string,
2719
+ sessionId: string,
2720
+ direction: 'undo' | 'redo',
2721
+ scope: TScope,
2722
+ state: IStorageState,
2723
+ signal?: AbortSignal,
2724
+ ): Promise<string> {
2725
+ const stored = this.requireSession(state, sessionId);
2726
+ this.assertStateAcceptingWork(state);
2727
+ const invocationOwner = this.slashCommandInvocationContext.getStore();
2728
+ if (invocationOwner?.sessionKey === this.slashCommandSessionKey(state.storageKey, sessionId)) {
2729
+ throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(sessionId));
2730
+ }
2731
+ return this.trackSlashCommandExecution(
2732
+ scopeId,
2733
+ state,
2734
+ sessionId,
2735
+ 'operation',
2736
+ signal,
2737
+ async (operationSignal) => {
2738
+ await stored.projectionQueue;
2739
+ const resumable = stored.pendingReversion?.kind === 'apply'
2740
+ && stored.pendingReversion.direction === direction;
2741
+ await this.requireReversionIdle(
2742
+ state,
2743
+ stored,
2744
+ sessionId,
2745
+ resumable,
2746
+ operationSignal,
2747
+ );
2748
+ await this.reconcileContextAvailability(state, stored);
2749
+ operationSignal.throwIfAborted();
2750
+ const pending = stored.pendingReversion;
2751
+ if (pending?.kind === 'apply') {
2752
+ if (pending.direction !== direction) {
2753
+ throw new FlexHarnessSessionBusyError(sessionId, 'has a pending opposite reversion operation');
2754
+ }
2755
+ const target = pending.segmentRunIds
2756
+ .map((runId) => stored.reversionSegments.find((segment) => segment.runId === runId))
2757
+ .find((segment) => segment?.status === 'completed');
2758
+ if (!target) throw new FlexHarnessValidationError('Pending reversion has no target candidate.');
2759
+ await this.resumePendingApply(state, stored, scopeId, scope, operationSignal);
2760
+ this.emitEvent(scopeId, sessionId, {
2761
+ type: 'session.history.changed',
2762
+ direction,
2763
+ runId: target.runId,
2764
+ });
2765
+ return target.runId;
2766
+ }
2767
+ const unit = this.reversionUnit(stored, direction);
2768
+ if (!unit) {
2769
+ throw new FlexHarnessSlashCommandUnavailableError(
2770
+ direction,
2771
+ direction === 'undo' ? 'No visible turn can be undone.' : 'No hidden turn can be redone.',
2772
+ );
2773
+ }
2774
+ if (!unit.target.contextAvailable) {
2775
+ throw new FlexHarnessSlashCommandUnavailableError(
2776
+ direction,
2777
+ 'The turn is beyond the context archive horizon.',
2778
+ );
2779
+ }
2780
+ if (this.turnReversionProvider && unit.segments.some((segment) =>
2781
+ segment.disposition === 'revertible' && segment.workspaceReference === undefined)) {
2782
+ throw new FlexHarnessSlashCommandUnavailableError(
2783
+ direction,
2784
+ 'The turn has no available workspace reversion capture.',
2785
+ );
2786
+ }
2787
+ const operationId = this.reversionId(
2788
+ 'apply',
2789
+ state.storageKey,
2790
+ sessionId,
2791
+ JSON.stringify([direction, stored.revertCursor, unit.toCursor, unit.target.runId]),
2792
+ );
2793
+ await this.mutateProjection(state, stored, () => {
2794
+ stored.pendingReversion = {
2795
+ kind: 'apply',
2796
+ operationId,
2797
+ direction,
2798
+ fromCursor: stored.revertCursor,
2799
+ toCursor: unit.toCursor,
2800
+ segmentRunIds: unit.segments.map((segment) => segment.runId),
2801
+ appliedRunIds: [],
2802
+ };
2803
+ }, true);
2804
+ await this.resumePendingApply(
2805
+ state,
2806
+ stored,
2807
+ scopeId,
2808
+ scope,
2809
+ operationSignal,
2810
+ );
2811
+ this.emitEvent(scopeId, sessionId, {
2812
+ type: 'session.history.changed',
2813
+ direction,
2814
+ runId: unit.target.runId,
2815
+ });
2816
+ return unit.target.runId;
2817
+ },
2818
+ );
2819
+ }
2820
+
2821
+ private async reconcileContextAvailability(
2822
+ state: IStorageState,
2823
+ stored: IStoredSessionState,
2824
+ ): Promise<boolean> {
2825
+ const activeEvents = new Set(stored.agentSession.getEvents().map((event) => event.id));
2826
+ if (!stored.reversionSegments.some((segment) =>
2827
+ segment.contextAvailable
2828
+ && segment.eventIds.length > 0
2829
+ && !segment.eventIds.some((eventId) => activeEvents.has(eventId)))) return false;
2830
+ let branchCommitted = false;
2831
+ await this.mutateProjection(state, stored, () => {
2832
+ for (const segment of stored.reversionSegments) {
2833
+ if (
2834
+ segment.contextAvailable
2835
+ && segment.eventIds.length > 0
2836
+ && !segment.eventIds.some((eventId) => activeEvents.has(eventId))
2837
+ ) segment.contextAvailable = false;
2838
+ }
2839
+ branchCommitted = this.commitArchivedHiddenBranchState(stored);
2840
+ this.pruneReversionState(stored, false, true);
2841
+ }, true);
2842
+ const context = stored.compactorContext as IFlexAgentContextInvocation<TScope> | undefined;
2843
+ if (context) {
2844
+ await this.drainReversionReleases(state, stored, context.scopeId, context.scope, false);
2845
+ }
2846
+ return branchCommitted;
2847
+ }
2848
+
2849
+ private async resumePendingApply(
2850
+ state: IStorageState,
2851
+ stored: IStoredSessionState,
2852
+ scopeId: string,
2853
+ scope: TScope,
2854
+ signal?: AbortSignal,
2855
+ ): Promise<void> {
2856
+ const pending = stored.pendingReversion;
2857
+ if (pending?.kind !== 'apply') return;
2858
+ const segmentMap = new Map(stored.reversionSegments.map((segment) => [segment.runId, segment]));
2859
+ if (
2860
+ pending.segmentRunIds.some((runId) =>
2861
+ segmentMap.get(runId)?.disposition === 'revertible')
2862
+ && !this.turnReversionProvider
2863
+ ) {
2864
+ throw new FlexHarnessValidationError(
2865
+ 'Pending workspace reversion recovery requires its turn reversion provider.',
2866
+ );
2867
+ }
2868
+ const orderedRunIds = pending.direction === 'undo'
2869
+ ? [...pending.segmentRunIds].reverse()
2870
+ : [...pending.segmentRunIds];
2871
+ let workspaceProgress = pending.appliedRunIds.some((runId) =>
2872
+ segmentMap.get(runId)?.disposition === 'revertible');
2873
+ for (const runId of orderedRunIds) {
2874
+ if (pending.appliedRunIds.includes(runId)) continue;
2875
+ if (signal?.aborted && !workspaceProgress) {
2876
+ await this.persistKnownNotApplied(state, stored, pending.operationId, runId);
2877
+ throw signal.reason;
2878
+ }
2879
+ const segment = segmentMap.get(runId);
2880
+ if (!segment) throw new FlexHarnessValidationError('Pending reversion references a missing segment.');
2881
+ if (segment.disposition === 'revertible') {
2882
+ if (!this.turnReversionProvider || !segment.captureId || segment.workspaceReference === undefined) {
2883
+ throw new FlexHarnessValidationError('Pending workspace reversion segment is incomplete.');
2884
+ }
2885
+ if (this.reversionProviderProtocolVersion() !== segment.protocolVersion) {
2886
+ throw new FlexHarnessValidationError(
2887
+ `Pending workspace reversion requires protocolVersion ${segment.protocolVersion}.`,
2888
+ );
2889
+ }
2890
+ const captureId = segment.captureId;
2891
+ const reference = segment.workspaceReference;
2892
+ const childOperationId = `${pending.operationId}:${sha256Hex(runId).slice(0, 16)}`;
2893
+ const createContext = (contextSignal: AbortSignal) => Object.freeze({
2894
+ scopeId,
2895
+ scope,
2896
+ storageKey: state.storageKey,
2897
+ sessionId: stored.session.sessionId,
2898
+ runId,
2899
+ captureId,
2900
+ reference: cloneSerializable(reference),
2901
+ operationId: childOperationId,
2902
+ direction: pending.direction,
2903
+ signal: contextSignal,
2904
+ });
2905
+ let inspection;
2906
+ try {
2907
+ inspection = await this.withReversionMaintenanceSignal((inspectionSignal) =>
2908
+ this.turnReversionProvider!.inspectApply(createContext(inspectionSignal)));
2909
+ } catch (inspectionError) {
2910
+ state.lifecycle = 'fenced';
2911
+ throw this.projectOperationError(
2912
+ inspectionError,
2913
+ 'turnReversion',
2914
+ scopeId,
2915
+ stored.session.sessionId,
2916
+ childOperationId,
2917
+ );
2918
+ }
2919
+ if (inspection.status === 'unknown') {
2920
+ state.lifecycle = 'fenced';
2921
+ throw new FlexHarnessValidationError('Workspace reversion apply outcome is unknown.');
2922
+ }
2923
+ if (inspection.status === 'not-applied') {
2924
+ try {
2925
+ if (signal && !workspaceProgress) {
2926
+ await this.turnReversionProvider.apply(createContext(signal));
2927
+ } else {
2928
+ await this.withReversionMaintenanceSignal((maintenanceSignal) =>
2929
+ this.turnReversionProvider!.apply(createContext(maintenanceSignal)));
2930
+ }
2931
+ } catch (error) {
2932
+ try {
2933
+ inspection = await this.withReversionMaintenanceSignal((inspectionSignal) =>
2934
+ this.turnReversionProvider!.inspectApply(createContext(inspectionSignal)));
2935
+ } catch (inspectionError) {
2936
+ state.lifecycle = 'fenced';
2937
+ throw this.projectOperationError(
2938
+ inspectionError,
2939
+ 'turnReversion',
2940
+ scopeId,
2941
+ stored.session.sessionId,
2942
+ childOperationId,
2943
+ );
2944
+ }
2945
+ if (inspection.status !== 'applied') {
2946
+ if (inspection.status === 'unknown') state.lifecycle = 'fenced';
2947
+ else await this.persistKnownNotApplied(state, stored, pending.operationId, runId);
2948
+ throw this.projectOperationError(
2949
+ error,
2950
+ 'turnReversion',
2951
+ scopeId,
2952
+ stored.session.sessionId,
2953
+ childOperationId,
2954
+ );
2955
+ }
2956
+ }
2957
+ }
2958
+ }
2959
+ await this.mutateProjection(state, stored, () => {
2960
+ const current = stored.pendingReversion;
2961
+ if (current?.kind !== 'apply' || current.operationId !== pending.operationId) {
2962
+ throw new FlexHarnessValidationError('Pending reversion changed while applying.');
2963
+ }
2964
+ if (!current.appliedRunIds.includes(runId)) current.appliedRunIds.push(runId);
2965
+ }, true);
2966
+ if (segment.disposition === 'revertible') workspaceProgress = true;
2967
+ }
2968
+ await this.mutateProjection(state, stored, () => {
2969
+ const current = stored.pendingReversion;
2970
+ if (current?.kind !== 'apply' || current.operationId !== pending.operationId) {
2971
+ throw new FlexHarnessValidationError('Pending reversion changed before cursor commit.');
2972
+ }
2973
+ stored.revertCursor = current.toCursor;
2974
+ delete stored.pendingReversion;
2975
+ }, true);
2976
+ }
2977
+
2978
+ private async persistKnownNotApplied(
2979
+ state: IStorageState,
2980
+ stored: IStoredSessionState,
2981
+ operationId: string,
2982
+ runId: string,
2983
+ ): Promise<void> {
2984
+ await this.mutateProjection(state, stored, () => {
2985
+ const current = stored.pendingReversion;
2986
+ if (current?.kind !== 'apply' || current.operationId !== operationId) {
2987
+ throw new FlexHarnessValidationError('Pending reversion changed while recording failed apply.');
2988
+ }
2989
+ current.appliedRunIds = current.appliedRunIds.filter((entry) => entry !== runId);
2990
+ const hasWorkspaceProgress = current.appliedRunIds.some((appliedRunId) =>
2991
+ stored.reversionSegments.some((segment) =>
2992
+ segment.runId === appliedRunId && segment.disposition === 'revertible'));
2993
+ if (!hasWorkspaceProgress) {
2994
+ stored.revertCursor = current.fromCursor;
2995
+ delete stored.pendingReversion;
2996
+ }
2997
+ }, true);
2998
+ }
2999
+
3000
+ private async recoverPendingCapture(
3001
+ state: IStorageState,
3002
+ stored: IStoredSessionState,
3003
+ scopeId: string,
3004
+ scope: TScope,
3005
+ outcomes: Map<string, TCanonicalOutcome>,
3006
+ ): Promise<void> {
3007
+ const pending = stored.pendingReversion;
3008
+ if (pending?.kind !== 'capture' || !this.turnReversionProvider) return;
3009
+ const segment = stored.reversionSegments.find((entry) => entry.runId === pending.runId);
3010
+ if (!segment) throw new FlexHarnessValidationError('Pending capture references a missing segment.');
3011
+ if (this.reversionProviderProtocolVersion() !== pending.protocolVersion) {
3012
+ throw new FlexHarnessValidationError(
3013
+ `Pending capture recovery requires protocolVersion ${pending.protocolVersion}.`,
3014
+ );
3015
+ }
3016
+ const outcome = outcomes.get(pending.runId);
3017
+ if (pending.state === 'prepared') {
3018
+ await this.mutateProjection(state, stored, () => {
3019
+ const current = stored.pendingReversion;
3020
+ if (current?.kind !== 'capture' || current.captureId !== pending.captureId) {
3021
+ throw new FlexHarnessValidationError('Pending capture changed before finalization.');
3022
+ }
3023
+ current.state = 'finalizing';
3024
+ }, true);
3025
+ }
3026
+ const finalizedOutcome = await this.resolveReversionCaptureOutcome(
3027
+ state,
3028
+ scopeId,
3029
+ scope,
3030
+ stored.session.sessionId,
3031
+ pending.runId,
3032
+ pending.captureId,
3033
+ pending.state === 'prepared' ? 'finalizing' : pending.state,
3034
+ );
3035
+ if (finalizedOutcome === undefined) {
3036
+ await this.mutateProjection(state, stored, () => {
3037
+ stored.reversionSegments = stored.reversionSegments.filter(
3038
+ (entry) => entry.runId !== pending.runId,
3039
+ );
3040
+ delete stored.pendingReversion;
3041
+ }, true);
3042
+ return;
3043
+ }
3044
+ await this.mutateProjection(state, stored, () => {
3045
+ segment.status = outcome === 'accepted'
3046
+ ? 'completed'
3047
+ : outcome === 'rejected'
3048
+ ? 'failed'
3049
+ : 'cancelled';
3050
+ this.applyFinalizedReversionOutcome(stored, segment, finalizedOutcome);
3051
+ segment.eventIds = stored.agentSession.getEvents()
3052
+ .filter((event) => event.generationId === pending.runId)
3053
+ .map((event) => event.id);
3054
+ if (segment.status === 'completed') stored.revertCursor++;
3055
+ if (outcome === undefined && !stored.excludedRunIds.includes(pending.runId)) {
3056
+ stored.excludedRunIds.push(pending.runId);
3057
+ }
3058
+ delete stored.pendingReversion;
3059
+ this.pruneReversionState(stored, false, true);
3060
+ }, true);
3061
+ await this.drainReversionReleases(state, stored, scopeId, scope, false);
3062
+ }
3063
+
3064
+ public async archiveSessionEvents(
3065
+ scopeId: string,
3066
+ sessionId: string,
3067
+ compactionEventId?: string,
3068
+ ): Promise<IFlexEventArchiveMetadata | undefined> {
3069
+ if (compactionEventId !== undefined) {
3070
+ validateIdentifier(compactionEventId, 'compactionEventId');
3071
+ requireTransferIdentifier(compactionEventId, 'compactionEventId');
3072
+ }
3073
+ const { scope, state } = await this.resolveState(scopeId);
3074
+ const stored = this.requireSession(state, sessionId);
3075
+ this.assertStateAcceptingWork(state);
3076
+ this.requireSessionAvailableForSlashCommand(state, stored, 'archive');
3077
+ const compaction = compactionEventId === undefined
3078
+ ? [...stored.agentSession.getEvents()].reverse().find((event) =>
3079
+ event.type === 'context-compaction')
3080
+ : stored.agentSession.getEvents().find((event) =>
3081
+ event.type === 'context-compaction' && event.id === compactionEventId);
3082
+ if (!compaction || compaction.type !== 'context-compaction') return undefined;
3083
+ let archive;
3084
+ try {
3085
+ archive = await stored.agentSession.archiveCompactedEvents(compaction.id);
3086
+ } catch (error) {
3087
+ throw this.projectOperationError(error, 'agentSession', scopeId, sessionId, 'archive');
3088
+ }
3089
+ if (!archive) return undefined;
3090
+ let branchCommitted = false;
3091
+ try {
3092
+ const archivedIds = new Set(archive.events.map((event) => event.id));
3093
+ await this.mutateProjection(state, stored, () => {
3094
+ branchCommitted = this.commitRevertedBranchState(stored);
3095
+ for (const segment of stored.reversionSegments) {
3096
+ if (segment.eventIds.some((eventId) => archivedIds.has(eventId))) {
3097
+ segment.contextAvailable = false;
3098
+ }
3099
+ }
3100
+ this.pruneReversionState(stored);
3101
+ }, true);
3102
+ } catch (error) {
3103
+ throw this.projectOperationError(error, 'persistence', scopeId, sessionId, 'archive-horizon');
3104
+ }
3105
+ if (branchCommitted) {
3106
+ this.emitEvent(scopeId, sessionId, {
3107
+ type: 'session.history.changed',
3108
+ direction: 'branch',
3109
+ });
3110
+ }
3111
+ await this.drainReversionReleases(state, stored, scopeId, scope.scope, false);
3112
+ return publicSnapshot({
3113
+ archiveId: this.boundedIdentifier(archive.archiveId, 'archiveId'),
3114
+ sessionId: this.boundedIdentifier(archive.sessionId, 'sessionId'),
3115
+ createdAt: new Date(archive.createdAt).toISOString(),
3116
+ eventCount: archive.events.length,
3117
+ });
3118
+ }
3119
+
3120
+ public async listBackgroundExecutions(
3121
+ scopeId: string,
3122
+ sessionId: string,
3123
+ ): Promise<IFlexBackgroundExecution[]> {
3124
+ const { state } = await this.resolveState(scopeId);
3125
+ const stored = this.requireSession(state, sessionId);
3126
+ this.assertStateAcceptingWork(state);
3127
+ if (!stored.jobs) return [];
3128
+ try {
3129
+ const jobs = await stored.jobs.list();
3130
+ return publicSnapshot(jobs
3131
+ .sort((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0))
3132
+ .slice(0, maxBackgroundExecutions)
3133
+ .map((job) => this.projectBackgroundExecution(job)));
3134
+ } catch (error) {
3135
+ throw this.projectOperationError(error, 'agentSession', scopeId, sessionId, 'background-list');
3136
+ }
3137
+ }
3138
+
3139
+ public async getBackgroundExecution(
3140
+ scopeId: string,
3141
+ sessionId: string,
3142
+ executionId: string,
3143
+ ): Promise<IFlexBackgroundExecution> {
3144
+ validateIdentifier(executionId, 'executionId');
3145
+ requireTransferIdentifier(executionId, 'executionId');
3146
+ const { state } = await this.resolveState(scopeId);
3147
+ const stored = this.requireSession(state, sessionId);
3148
+ this.assertStateAcceptingWork(state);
3149
+ try {
3150
+ return publicSnapshot(this.projectBackgroundExecution(
3151
+ await stored.agentSession.getBackgroundExecution(executionId),
3152
+ ));
3153
+ } catch (error) {
3154
+ throw this.projectOperationError(error, 'agentSession', scopeId, sessionId, 'background-get');
3155
+ }
3156
+ }
3157
+
3158
+ public async abortBackgroundExecution(
3159
+ scopeId: string,
3160
+ sessionId: string,
3161
+ executionId: string,
3162
+ ): Promise<void> {
3163
+ validateIdentifier(executionId, 'executionId');
3164
+ requireTransferIdentifier(executionId, 'executionId');
3165
+ const { scope, state } = await this.resolveState(scopeId);
3166
+ const stored = this.requireSession(state, sessionId);
1705
3167
  this.assertStateAcceptingWork(state);
1706
3168
  try {
1707
3169
  await this.withCompactorContext(
@@ -1725,6 +3187,7 @@ export class FlexHarness<TScope = unknown> {
1725
3187
  }
1726
3188
 
1727
3189
  public retireScope(scopeId: string): Promise<void> {
3190
+ this.assertNoAmbientSlashCommandTeardown({ scopeId });
1728
3191
  this.assertOpen();
1729
3192
  validateIdentifier(scopeId, 'scopeId');
1730
3193
  const existing = this.scopeRetirements.get(scopeId);
@@ -1743,6 +3206,7 @@ export class FlexHarness<TScope = unknown> {
1743
3206
  }
1744
3207
 
1745
3208
  public async dispose(): Promise<void> {
3209
+ this.assertNoAmbientSlashCommandTeardown();
1746
3210
  if (this.disposePromise) return this.disposePromise;
1747
3211
  this.closed = true;
1748
3212
  let disposal!: Promise<void>;
@@ -1763,16 +3227,16 @@ export class FlexHarness<TScope = unknown> {
1763
3227
  debounceMs?: number,
1764
3228
  subagentAdmission = false,
1765
3229
  admissionSignal?: AbortSignal,
1766
- ): Promise<{ admission: IFlexPromptQueueAdmission; started: Promise<IFlexPromptAdmission> }> {
3230
+ slashCommandAdmission = false,
3231
+ ): Promise<{
3232
+ admission: IFlexPromptQueueAdmission;
3233
+ started: Promise<IFlexPromptAdmission>;
3234
+ queued?: IQueuedPrompt;
3235
+ }> {
1767
3236
  this.assertOpen();
1768
3237
  const normalizedPrompt = normalizeFlexPrompt(prompt);
1769
3238
  const normalizedOptions = cloneSerializable(options);
1770
- const byteSize = jsonBytes({
1771
- prompt: normalizedPrompt,
1772
- options: normalizedOptions,
1773
- scheduleKey,
1774
- debounceMs,
1775
- });
3239
+ const byteSize = this.promptAdmissionByteSize(prompt, options, scheduleKey, debounceMs);
1776
3240
  const releasePendingAdmission = this.reservePendingPromptAdmission(byteSize);
1777
3241
  let resolveSettled!: () => void;
1778
3242
  const pendingOwner: IPendingPromptAdmission = {
@@ -1807,6 +3271,20 @@ export class FlexHarness<TScope = unknown> {
1807
3271
  }
1808
3272
  this.assertStateAcceptingWork(state);
1809
3273
  const stored = this.requireSession(state, sessionId);
3274
+ const sessionKey = this.slashCommandSessionKey(state.storageKey, sessionId);
3275
+ const invocationOwner = this.slashCommandInvocationContext.getStore();
3276
+ if (invocationOwner?.sessionKey === sessionKey) {
3277
+ throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(sessionId));
3278
+ }
3279
+ if (!slashCommandAdmission && this.activeSlashCommandExecutions.has(sessionKey)) {
3280
+ throw new FlexHarnessSessionBusyError(
3281
+ sessionId,
3282
+ 'already has an active slash command execution',
3283
+ );
3284
+ }
3285
+ if (stored.pendingReversion) {
3286
+ throw new FlexHarnessSessionBusyError(sessionId, 'has a pending reversion operation');
3287
+ }
1810
3288
  if (stored.session.agent !== undefined && !subagentAdmission) {
1811
3289
  throw new FlexHarnessValidationError(
1812
3290
  'Subagent sessions can only be prompted through the foreground task tool.',
@@ -1875,6 +3353,7 @@ export class FlexHarness<TScope = unknown> {
1875
3353
  return {
1876
3354
  admission: Object.freeze({ queueId: queued.queueId, completion }),
1877
3355
  started,
3356
+ queued,
1878
3357
  };
1879
3358
  } finally {
1880
3359
  for (const signal of admissionSignals) {
@@ -2003,6 +3482,7 @@ export class FlexHarness<TScope = unknown> {
2003
3482
  const options = queued.options!;
2004
3483
  let projectionReserved = false;
2005
3484
  try {
3485
+ await this.commitRevertedBranch(run.state, run.stored, run.scopeId, run.scope as TScope);
2006
3486
  run.transaction = await this.withRunCompactorContext(
2007
3487
  run,
2008
3488
  () => run.stored.agentSession.beginGeneration(
@@ -2016,6 +3496,34 @@ export class FlexHarness<TScope = unknown> {
2016
3496
  const reservation = this.createReservation(run, prompt);
2017
3497
  await this.mutateProjection(run.state, run.stored, () => {
2018
3498
  run.stored.messages.push(reservation.userMessage, reservation.assistantMessage);
3499
+ if (run.stored.session.agent === undefined) {
3500
+ this.pruneReversionState(run.stored, true);
3501
+ const captureId = this.turnReversionProvider
3502
+ ? this.reversionId('capture', run.state.storageKey, run.sessionId, run.runId)
3503
+ : undefined;
3504
+ const protocolVersion = this.reversionProviderProtocolVersion() ?? 1;
3505
+ run.stored.reversionSegments.push({
3506
+ runId: run.runId,
3507
+ userMessageId: reservation.userMessage.messageId,
3508
+ status: 'capturing',
3509
+ contextAvailable: true,
3510
+ eventIds: [],
3511
+ workspaceCaptured: captureId !== undefined,
3512
+ protocolVersion,
3513
+ provenance: captureId === undefined ? 'transcript' : 'workspace',
3514
+ ...(captureId === undefined ? {} : { disposition: 'pending' }),
3515
+ ...(captureId === undefined ? {} : { captureId }),
3516
+ });
3517
+ if (captureId) {
3518
+ run.stored.pendingReversion = {
3519
+ kind: 'capture',
3520
+ runId: run.runId,
3521
+ captureId,
3522
+ state: 'preparing',
3523
+ protocolVersion,
3524
+ };
3525
+ }
3526
+ }
2019
3527
  });
2020
3528
  projectionReserved = true;
2021
3529
  run.reservedUserMessage = publicSnapshot(reservation.userMessage);
@@ -2236,6 +3744,7 @@ export class FlexHarness<TScope = unknown> {
2236
3744
  if (run.controller.signal.aborted) throw run.controller.signal.reason;
2237
3745
  const result = normalizeRunResult(rawResult);
2238
3746
  if (!run.modelResolution) throw new FlexHarnessValidationError('Generation completed without a resolved model.');
3747
+ await this.finalizeRunReversion(run, 'completed');
2239
3748
  const terminal = this.buildTerminal(run, 'completed', result);
2240
3749
  try {
2241
3750
  await this.mutateProjection(run.state, run.stored, () => {
@@ -2266,9 +3775,16 @@ export class FlexHarness<TScope = unknown> {
2266
3775
  }
2267
3776
  this.emitTerminalProjection(run, terminal);
2268
3777
  this.emitFinalRunEvent(run, terminal.assistantMessage, undefined, false);
2269
- return {
2270
- runId: run.runId,
2271
- sessionId: run.sessionId,
3778
+ await this.drainReversionReleases(
3779
+ run.state,
3780
+ run.stored,
3781
+ run.scopeId,
3782
+ run.scope as TScope,
3783
+ false,
3784
+ );
3785
+ return {
3786
+ runId: run.runId,
3787
+ sessionId: run.sessionId,
2272
3788
  userMessage: publicSnapshot(terminal.userMessage),
2273
3789
  assistantMessage: publicSnapshot(terminal.assistantMessage),
2274
3790
  model: publicSnapshot(terminal.model!),
@@ -2306,6 +3822,14 @@ export class FlexHarness<TScope = unknown> {
2306
3822
  ? run.ownerCancellation!
2307
3823
  : this.projectExternalError(run, error, generated ? 'persistence' : 'agentSession');
2308
3824
  const errors: unknown[] = [safeError];
3825
+ try {
3826
+ await this.finalizeRunReversion(run, cancelled ? 'cancelled' : 'failed');
3827
+ } catch (reversionError) {
3828
+ errors.push(reversionError);
3829
+ const combined = combineErrors(errors);
3830
+ await this.fenceNamespace(run.state, run, combined);
3831
+ throw combined;
3832
+ }
2309
3833
  if (generated && !this.isTombstoned(run)) {
2310
3834
  const failedStage = this.buildTerminal(run, cancelled ? 'cancelled' : 'failed', undefined, safeError);
2311
3835
  try {
@@ -2360,6 +3884,13 @@ export class FlexHarness<TScope = unknown> {
2360
3884
  this.emitTerminalProjection(run, terminal);
2361
3885
  this.emitFinalRunEvent(run, terminal.assistantMessage, terminalError, publicStatus === 'cancelled');
2362
3886
  }
3887
+ await this.drainReversionReleases(
3888
+ run.state,
3889
+ run.stored,
3890
+ run.scopeId,
3891
+ run.scope as TScope,
3892
+ false,
3893
+ );
2363
3894
  throw errors.length > 1 ? combineErrors(errors) : terminalError;
2364
3895
  }
2365
3896
 
@@ -2369,6 +3900,7 @@ export class FlexHarness<TScope = unknown> {
2369
3900
  signal: AbortSignal,
2370
3901
  ): Promise<plugins.IAgentGenerationLease> {
2371
3902
  signal.throwIfAborted();
3903
+ await this.prepareRunReversion(run, signal);
2372
3904
  run.phase = 'running';
2373
3905
  const queued = run.stored.outstandingPromptsById.get(run.queueId);
2374
3906
  if (queued && (queued.status === 'starting' || queued.status === 'scheduled')) {
@@ -3036,39 +4568,1047 @@ export class FlexHarness<TScope = unknown> {
3036
4568
  : 'Tool execution ended without a terminal event.';
3037
4569
  }
3038
4570
  }
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),
4571
+ userMessage.status = status;
4572
+ userMessage.completedAt = timestamp;
4573
+ assistantMessage.status = status;
4574
+ assistantMessage.completedAt = timestamp;
4575
+ if (run.modelResolution) assistantMessage.model = cloneSerializable(run.modelResolution.identity);
4576
+ if (status === 'completed' && result) {
4577
+ if (!assistantMessage.parts.some((part) => part.type === 'text')) {
4578
+ assistantMessage.parts.push({
4579
+ partId: plugins.crypto.randomUUID(),
4580
+ type: 'text',
4581
+ text: truncateUtf8(result.text, this.callbackLimits.maxOutputBytes),
4582
+ });
4583
+ }
4584
+ assistantMessage.usage = cloneSerializable(result.usage);
4585
+ } else {
4586
+ const message = truncateUtf8(error?.message ?? externalErrorFallback.message, maxTransferMetadataBytes);
4587
+ userMessage.error = message;
4588
+ assistantMessage.error = message;
4589
+ }
4590
+ return {
4591
+ runId: run.runId,
4592
+ status,
4593
+ userMessage,
4594
+ assistantMessage,
4595
+ ...(run.modelResolution ? { model: cloneSerializable(run.modelResolution.identity) } : {}),
4596
+ ...(status === 'completed' && result
4597
+ ? {
4598
+ usage: cloneSerializable(result.usage),
4599
+ finishReason: result.finishReason,
4600
+ steps: result.steps,
4601
+ }
4602
+ : {}),
4603
+ };
4604
+ }
4605
+
4606
+ private reversionId(
4607
+ kind: 'capture' | 'apply',
4608
+ storageKey: string,
4609
+ sessionId: string,
4610
+ value: string,
4611
+ ): string {
4612
+ return `${kind}_${sha256Hex(JSON.stringify([storageKey, sessionId, value]))}`;
4613
+ }
4614
+
4615
+ private reversionProviderProtocolVersion(): 1 | 2 | undefined {
4616
+ if (!this.turnReversionProvider) return undefined;
4617
+ return 'protocolVersion' in this.turnReversionProvider ? 2 : 1;
4618
+ }
4619
+
4620
+ private reversionCaptureContext(
4621
+ run: IActiveRun,
4622
+ captureId: string,
4623
+ signal: AbortSignal,
4624
+ ) {
4625
+ return Object.freeze({
4626
+ scopeId: run.scopeId,
4627
+ scope: run.scope as TScope,
4628
+ storageKey: run.state.storageKey,
4629
+ sessionId: run.sessionId,
4630
+ runId: run.runId,
4631
+ captureId,
4632
+ signal,
4633
+ });
4634
+ }
4635
+
4636
+ private async withReversionMaintenanceSignal<TResult>(
4637
+ operation: (signal: AbortSignal) => Promise<TResult> | TResult,
4638
+ ): Promise<TResult> {
4639
+ const controller = new AbortController();
4640
+ const timeoutMs = this.agentSessionPolicy.generationLeaseCleanupTimeoutMs ?? 30_000;
4641
+ const timeoutError = new Error(`Turn reversion maintenance timed out after ${timeoutMs}ms.`);
4642
+ let rejectTimeout!: (error: Error) => void;
4643
+ const timeout = new Promise<never>((_resolve, reject) => {
4644
+ rejectTimeout = reject;
4645
+ });
4646
+ const timer = setTimeout(() => {
4647
+ controller.abort(timeoutError);
4648
+ rejectTimeout(timeoutError);
4649
+ }, timeoutMs);
4650
+ const completion = Promise.resolve().then(() => operation(controller.signal));
4651
+ void completion.catch(() => undefined);
4652
+ try {
4653
+ return await Promise.race([completion, timeout]);
4654
+ } finally {
4655
+ clearTimeout(timer);
4656
+ }
4657
+ }
4658
+
4659
+ private inspectReversionCapture(
4660
+ scopeId: string,
4661
+ scope: TScope,
4662
+ storageKey: string,
4663
+ sessionId: string,
4664
+ runId: string,
4665
+ captureId: string,
4666
+ ): Promise<TNormalizedCaptureInspection> {
4667
+ return this.withReversionMaintenanceSignal(async (signal) => {
4668
+ const provider = this.turnReversionProvider!;
4669
+ const inspection = await provider.inspectCapture(Object.freeze({
4670
+ scopeId,
4671
+ scope,
4672
+ storageKey,
4673
+ sessionId,
4674
+ runId,
4675
+ captureId,
4676
+ signal,
4677
+ }));
4678
+ if (inspection.status !== 'finalized') return { status: inspection.status };
4679
+ if (this.reversionProviderProtocolVersion() === 2) {
4680
+ if (!('outcome' in inspection)) {
4681
+ throw new FlexHarnessValidationError('Protocol-2 finalized inspection has no outcome.');
4682
+ }
4683
+ return {
4684
+ status: 'finalized',
4685
+ outcome: this.normalizeReversionOutcome(inspection.outcome),
4686
+ };
4687
+ }
4688
+ if (!('reference' in inspection) || inspection.reference === undefined) {
4689
+ throw new FlexHarnessValidationError('Protocol-1 finalized inspection has no reference.');
4690
+ }
4691
+ return {
4692
+ status: 'finalized',
4693
+ outcome: {
4694
+ disposition: 'revertible',
4695
+ reference: this.normalizeReversionReference(inspection.reference),
4696
+ affectedWorkspaces: [],
4697
+ },
4698
+ };
4699
+ });
4700
+ }
4701
+
4702
+ private finalizeReversionCapture(
4703
+ scopeId: string,
4704
+ scope: TScope,
4705
+ storageKey: string,
4706
+ sessionId: string,
4707
+ runId: string,
4708
+ captureId: string,
4709
+ ): Promise<TFlexTurnReversionFinalizedOutcome> {
4710
+ return this.withReversionMaintenanceSignal(async (signal) => {
4711
+ const result = await this.turnReversionProvider!.finalize(Object.freeze({
4712
+ scopeId,
4713
+ scope,
4714
+ storageKey,
4715
+ sessionId,
4716
+ runId,
4717
+ captureId,
4718
+ signal,
4719
+ }));
4720
+ return this.reversionProviderProtocolVersion() === 2
4721
+ ? this.normalizeReversionOutcome(result)
4722
+ : {
4723
+ disposition: 'revertible',
4724
+ reference: this.normalizeReversionReference(result),
4725
+ affectedWorkspaces: [],
4726
+ };
4727
+ });
4728
+ }
4729
+
4730
+ private async resolveReversionCaptureOutcome(
4731
+ state: IStorageState,
4732
+ scopeId: string,
4733
+ scope: TScope,
4734
+ sessionId: string,
4735
+ runId: string,
4736
+ captureId: string,
4737
+ durableState: 'preparing' | 'prepared' | 'finalizing',
4738
+ ): Promise<TFlexTurnReversionFinalizedOutcome | undefined> {
4739
+ let inspection;
4740
+ try {
4741
+ inspection = await this.inspectReversionCapture(
4742
+ scopeId,
4743
+ scope,
4744
+ state.storageKey,
4745
+ sessionId,
4746
+ runId,
4747
+ captureId,
4748
+ );
4749
+ } catch (error) {
4750
+ state.lifecycle = 'fenced';
4751
+ throw this.projectOperationError(error, 'turnReversion', scopeId, sessionId, captureId);
4752
+ }
4753
+ if (inspection.status === 'unknown') {
4754
+ state.lifecycle = 'fenced';
4755
+ throw new FlexHarnessValidationError('Reversion capture outcome is unknown.');
4756
+ }
4757
+ if (inspection.status === 'missing') {
4758
+ if (durableState === 'preparing') return undefined;
4759
+ state.lifecycle = 'fenced';
4760
+ throw new FlexHarnessValidationError('Prepared reversion capture is missing.');
4761
+ }
4762
+ let outcome = inspection.status === 'finalized' ? inspection.outcome : undefined;
4763
+ if (inspection.status === 'prepared') {
4764
+ try {
4765
+ outcome = await this.finalizeReversionCapture(
4766
+ scopeId,
4767
+ scope,
4768
+ state.storageKey,
4769
+ sessionId,
4770
+ runId,
4771
+ captureId,
4772
+ );
4773
+ } catch (error) {
4774
+ const finalInspection = await this.inspectReversionCapture(
4775
+ scopeId,
4776
+ scope,
4777
+ state.storageKey,
4778
+ sessionId,
4779
+ runId,
4780
+ captureId,
4781
+ );
4782
+ if (finalInspection.status !== 'finalized') {
4783
+ if (finalInspection.status === 'unknown') state.lifecycle = 'fenced';
4784
+ throw this.projectOperationError(error, 'turnReversion', scopeId, sessionId, captureId);
4785
+ }
4786
+ outcome = finalInspection.outcome;
4787
+ }
4788
+ }
4789
+ if (outcome === undefined) {
4790
+ state.lifecycle = 'fenced';
4791
+ throw new FlexHarnessValidationError('Finalized reversion capture has no outcome.');
4792
+ }
4793
+ return outcome;
4794
+ }
4795
+
4796
+ private normalizeReversionReference(reference: unknown): TJsonValue {
4797
+ return normalizeJsonValue(reference, {
4798
+ maxDepth: this.toolOutputLimits.maxDepth,
4799
+ maxBytes: Math.min(
4800
+ this.toolOutputLimits.maxBytes,
4801
+ FLEX_REVERSION_REFERENCE_MAX_BYTES,
4802
+ ),
4803
+ });
4804
+ }
4805
+
4806
+ private normalizeReversionOutcome(value: unknown): TFlexTurnReversionFinalizedOutcome {
4807
+ if (
4808
+ !value
4809
+ || typeof value !== 'object'
4810
+ || Array.isArray(value)
4811
+ || (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
4812
+ ) throw new FlexHarnessValidationError('Turn reversion finalized outcome must be a plain object.');
4813
+ const outcome = value as Record<string, unknown>;
4814
+ const disposition = outcome.disposition;
4815
+ if (!['revertible', 'no-change', 'nonrevertible'].includes(String(disposition))) {
4816
+ throw new FlexHarnessValidationError('Turn reversion finalized outcome disposition is invalid.');
4817
+ }
4818
+ const allowedKeys = disposition === 'nonrevertible'
4819
+ ? ['disposition', 'reference', 'reasonCode', 'affectedWorkspaces']
4820
+ : ['disposition', 'reference', 'affectedWorkspaces'];
4821
+ const unsupported = Object.keys(outcome).find((key) => !allowedKeys.includes(key));
4822
+ if (unsupported) {
4823
+ throw new FlexHarnessValidationError(
4824
+ `Turn reversion finalized outcome does not support "${unsupported}".`,
4825
+ );
4826
+ }
4827
+ if (!Object.prototype.hasOwnProperty.call(outcome, 'reference')) {
4828
+ throw new FlexHarnessValidationError('Turn reversion finalized outcome requires a reference.');
4829
+ }
4830
+ const reference = this.normalizeReversionReference(outcome.reference);
4831
+ const affectedWorkspaces = outcome.affectedWorkspaces === undefined
4832
+ ? undefined
4833
+ : this.normalizeAffectedWorkspaces(outcome.affectedWorkspaces);
4834
+ if (disposition === 'revertible') {
4835
+ if (affectedWorkspaces === undefined) {
4836
+ throw new FlexHarnessValidationError('Revertible outcome requires affectedWorkspaces.');
4837
+ }
4838
+ return { disposition, reference, affectedWorkspaces };
4839
+ }
4840
+ if (disposition === 'no-change') {
4841
+ if (affectedWorkspaces && affectedWorkspaces.length > 0) {
4842
+ throw new FlexHarnessValidationError('No-change outcome cannot affect a workspace.');
4843
+ }
4844
+ return {
4845
+ disposition,
4846
+ reference,
4847
+ ...(affectedWorkspaces === undefined ? {} : { affectedWorkspaces: [] }),
4848
+ };
4849
+ }
4850
+ if (
4851
+ typeof outcome.reasonCode !== 'string'
4852
+ || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(outcome.reasonCode)
4853
+ || Buffer.byteLength(outcome.reasonCode, 'utf8') > FLEX_REVERSION_REASON_CODE_MAX_BYTES
4854
+ ) throw new FlexHarnessValidationError('Nonrevertible outcome reasonCode is invalid.');
4855
+ return {
4856
+ disposition: 'nonrevertible',
4857
+ reference,
4858
+ reasonCode: outcome.reasonCode,
4859
+ ...(affectedWorkspaces === undefined ? {} : { affectedWorkspaces }),
4860
+ };
4861
+ }
4862
+
4863
+ private normalizeAffectedWorkspaces(value: unknown): IFlexAffectedWorkspace[] {
4864
+ if (!Array.isArray(value) || value.length > FLEX_REVERSION_MAX_AFFECTED_WORKSPACES) {
4865
+ throw new FlexHarnessValidationError(
4866
+ `affectedWorkspaces must contain at most ${FLEX_REVERSION_MAX_AFFECTED_WORKSPACES} entries.`,
4867
+ );
4868
+ }
4869
+ const ids = new Set<string>();
4870
+ return value.map((entry, index) => {
4871
+ if (
4872
+ !entry
4873
+ || typeof entry !== 'object'
4874
+ || Array.isArray(entry)
4875
+ || (Object.getPrototypeOf(entry) !== Object.prototype && Object.getPrototypeOf(entry) !== null)
4876
+ ) throw new FlexHarnessValidationError(`affectedWorkspaces[${index}] must be a plain object.`);
4877
+ const descriptor = entry as Record<string, unknown>;
4878
+ const unsupported = Object.keys(descriptor).find((key) => !['id', 'label'].includes(key));
4879
+ if (unsupported) {
4880
+ throw new FlexHarnessValidationError(
4881
+ `affectedWorkspaces[${index}] does not support "${unsupported}".`,
4882
+ );
4883
+ }
4884
+ if (
4885
+ typeof descriptor.id !== 'string'
4886
+ || !descriptor.id.trim()
4887
+ || Buffer.byteLength(descriptor.id, 'utf8') > FLEX_REVERSION_WORKSPACE_ID_MAX_BYTES
4888
+ ) throw new FlexHarnessValidationError(`affectedWorkspaces[${index}].id is invalid.`);
4889
+ if (
4890
+ typeof descriptor.label !== 'string'
4891
+ || !descriptor.label.trim()
4892
+ || Buffer.byteLength(descriptor.label, 'utf8') > FLEX_REVERSION_WORKSPACE_LABEL_MAX_BYTES
4893
+ ) throw new FlexHarnessValidationError(`affectedWorkspaces[${index}].label is invalid.`);
4894
+ if (ids.has(descriptor.id)) {
4895
+ throw new FlexHarnessValidationError(`affectedWorkspaces contains duplicate id "${descriptor.id}".`);
4896
+ }
4897
+ ids.add(descriptor.id);
4898
+ return { id: descriptor.id, label: descriptor.label };
4899
+ });
4900
+ }
4901
+
4902
+ private async prepareRunReversion(run: IActiveRun, signal: AbortSignal): Promise<void> {
4903
+ if (run.stored.session.agent !== undefined || !this.turnReversionProvider) return;
4904
+ const segment = run.stored.reversionSegments.find((entry) => entry.runId === run.runId);
4905
+ const captureId = segment?.captureId;
4906
+ if (!segment || !captureId) {
4907
+ throw this.projectExternalError(
4908
+ run,
4909
+ new Error('Root generation is missing its durable reversion capture intent.'),
4910
+ 'turnReversion',
4911
+ );
4912
+ }
4913
+ const context = this.reversionCaptureContext(run, captureId, signal);
4914
+ if (this.reversionProviderProtocolVersion() !== segment.protocolVersion) {
4915
+ throw this.projectExternalError(
4916
+ run,
4917
+ new Error(`Root generation requires reversion protocolVersion ${segment.protocolVersion}.`),
4918
+ 'turnReversion',
4919
+ );
4920
+ }
4921
+ try {
4922
+ await this.turnReversionProvider.prepare(context);
4923
+ } catch (error) {
4924
+ let inspection;
4925
+ try {
4926
+ inspection = await this.inspectReversionCapture(
4927
+ run.scopeId,
4928
+ run.scope as TScope,
4929
+ run.state.storageKey,
4930
+ run.sessionId,
4931
+ run.runId,
4932
+ captureId,
4933
+ );
4934
+ } catch (inspectionError) {
4935
+ run.state.lifecycle = 'fenced';
4936
+ throw this.projectExternalError(run, inspectionError, 'turnReversion');
4937
+ }
4938
+ if (inspection.status !== 'prepared' && inspection.status !== 'finalized') {
4939
+ if (inspection.status === 'unknown') run.state.lifecycle = 'fenced';
4940
+ throw this.projectExternalError(run, error, 'turnReversion');
4941
+ }
4942
+ }
4943
+ await this.mutateProjection(run.state, run.stored, () => {
4944
+ const pending = run.stored.pendingReversion;
4945
+ if (pending?.kind !== 'capture' || pending.runId !== run.runId) {
4946
+ throw new FlexHarnessValidationError('Reversion capture intent changed during preparation.');
4947
+ }
4948
+ pending.state = 'prepared';
4949
+ }, true);
4950
+ }
4951
+
4952
+ private async finalizeRunReversion(
4953
+ run: IActiveRun,
4954
+ status: 'completed' | 'failed' | 'cancelled',
4955
+ ): Promise<void> {
4956
+ if (run.stored.session.agent !== undefined) return;
4957
+ const segment = run.stored.reversionSegments.find((entry) => entry.runId === run.runId);
4958
+ if (!segment) return;
4959
+ const eventIds = run.stored.agentSession.getEvents()
4960
+ .filter((event) => event.generationId === run.runId)
4961
+ .map((event) => event.id);
4962
+ if (!this.turnReversionProvider || segment.provenance === 'transcript') {
4963
+ await this.mutateProjection(run.state, run.stored, () => {
4964
+ const wasCompleted = segment.status === 'completed';
4965
+ segment.status = status;
4966
+ segment.eventIds = eventIds;
4967
+ if (!wasCompleted && status === 'completed') run.stored.revertCursor++;
4968
+ else if (wasCompleted && status !== 'completed') run.stored.revertCursor--;
4969
+ delete run.stored.pendingReversion;
4970
+ this.pruneReversionState(run.stored);
4971
+ }, true);
4972
+ return;
4973
+ }
4974
+ if (!segment.captureId) {
4975
+ throw new FlexHarnessValidationError('Workspace reversion segment has no capture ownership.');
4976
+ }
4977
+ const captureId = segment.captureId;
4978
+ if (segment.workspaceReference !== undefined) {
4979
+ await this.mutateProjection(run.state, run.stored, () => {
4980
+ const wasCompleted = segment.status === 'completed';
4981
+ segment.status = status;
4982
+ segment.eventIds = eventIds;
4983
+ if (!wasCompleted && status === 'completed') run.stored.revertCursor++;
4984
+ else if (wasCompleted && status !== 'completed') run.stored.revertCursor--;
4985
+ delete run.stored.pendingReversion;
4986
+ }, true);
4987
+ return;
4988
+ }
4989
+ const pending = run.stored.pendingReversion;
4990
+ if (pending?.kind !== 'capture' || pending.runId !== run.runId) {
4991
+ throw new FlexHarnessValidationError('Reversion capture intent changed before finalization.');
4992
+ }
4993
+ if (this.reversionProviderProtocolVersion() !== pending.protocolVersion) {
4994
+ throw new FlexHarnessValidationError(
4995
+ `Reversion capture finalization requires protocolVersion ${pending.protocolVersion}.`,
4996
+ );
4997
+ }
4998
+ if (pending.state === 'prepared') {
4999
+ await this.mutateProjection(run.state, run.stored, () => {
5000
+ const current = run.stored.pendingReversion;
5001
+ if (current?.kind !== 'capture' || current.captureId !== captureId) {
5002
+ throw new FlexHarnessValidationError('Reversion capture changed before finalization.');
5003
+ }
5004
+ current.state = 'finalizing';
5005
+ }, true);
5006
+ }
5007
+ const outcome = await this.resolveReversionCaptureOutcome(
5008
+ run.state,
5009
+ run.scopeId,
5010
+ run.scope as TScope,
5011
+ run.sessionId,
5012
+ run.runId,
5013
+ captureId,
5014
+ pending.state === 'prepared' ? 'finalizing' : pending.state,
5015
+ );
5016
+ if (outcome === undefined) {
5017
+ await this.mutateProjection(run.state, run.stored, () => {
5018
+ run.stored.reversionSegments = run.stored.reversionSegments.filter(
5019
+ (entry) => entry.runId !== run.runId,
5020
+ );
5021
+ delete run.stored.pendingReversion;
5022
+ }, true);
5023
+ return;
5024
+ }
5025
+ await this.mutateProjection(run.state, run.stored, () => {
5026
+ const current = run.stored.pendingReversion;
5027
+ if (current?.kind !== 'capture' || current.runId !== run.runId) {
5028
+ throw new FlexHarnessValidationError('Reversion capture intent changed before finalization.');
5029
+ }
5030
+ const wasCompleted = segment.status === 'completed';
5031
+ segment.status = status;
5032
+ segment.eventIds = eventIds;
5033
+ this.applyFinalizedReversionOutcome(run.stored, segment, outcome);
5034
+ if (!wasCompleted && status === 'completed') run.stored.revertCursor++;
5035
+ else if (wasCompleted && status !== 'completed') run.stored.revertCursor--;
5036
+ delete run.stored.pendingReversion;
5037
+ this.pruneReversionState(run.stored);
5038
+ }, true);
5039
+ }
5040
+
5041
+ private applyFinalizedReversionOutcome(
5042
+ stored: IStoredSessionState,
5043
+ segment: IFlexReversionSegment,
5044
+ outcome: TFlexTurnReversionFinalizedOutcome,
5045
+ ): void {
5046
+ const captureId = segment.captureId;
5047
+ if (!captureId) {
5048
+ throw new FlexHarnessValidationError('Finalized workspace outcome has no capture ownership.');
5049
+ }
5050
+ segment.disposition = outcome.disposition;
5051
+ delete segment.affectedWorkspaces;
5052
+ delete segment.reasonCode;
5053
+ if (segment.protocolVersion === 2) {
5054
+ if (outcome.affectedWorkspaces !== undefined) {
5055
+ segment.affectedWorkspaces = cloneSerializable([...outcome.affectedWorkspaces]);
5056
+ }
5057
+ if (outcome.disposition === 'nonrevertible') segment.reasonCode = outcome.reasonCode;
5058
+ }
5059
+ if (outcome.disposition === 'revertible') {
5060
+ segment.workspaceReference = cloneSerializable(outcome.reference);
5061
+ return;
5062
+ }
5063
+ this.enqueueReversionReleaseReference(stored, {
5064
+ runId: segment.runId,
5065
+ captureId,
5066
+ reference: cloneSerializable(outcome.reference),
5067
+ protocolVersion: segment.protocolVersion,
5068
+ });
5069
+ delete segment.captureId;
5070
+ delete segment.workspaceReference;
5071
+ }
5072
+
5073
+ private hiddenReversionSegments(stored: IStoredSessionState): IFlexReversionSegment[] {
5074
+ const firstHidden = this.reversionGroups(stored)[stored.revertCursor]?.segments[0];
5075
+ if (!firstHidden) return [];
5076
+ const index = stored.reversionSegments.findIndex((segment) => segment.runId === firstHidden.runId);
5077
+ if (index < 0) return [];
5078
+ return stored.reversionSegments.slice(index);
5079
+ }
5080
+
5081
+ private enqueueReversionRelease(
5082
+ stored: IStoredSessionState,
5083
+ segment: IFlexReversionSegment,
5084
+ ): void {
5085
+ if (segment.disposition !== 'revertible') return;
5086
+ if (segment.captureId === undefined || segment.workspaceReference === undefined) {
5087
+ throw new FlexHarnessValidationError('Capture-backed segment has no releasable workspace reference.');
5088
+ }
5089
+ this.enqueueReversionReleaseReference(stored, {
5090
+ runId: segment.runId,
5091
+ captureId: segment.captureId,
5092
+ reference: cloneSerializable(segment.workspaceReference),
5093
+ protocolVersion: segment.protocolVersion,
5094
+ });
5095
+ }
5096
+
5097
+ private enqueueReversionReleaseReference(
5098
+ stored: IStoredSessionState,
5099
+ release: IFlexPendingReversionRelease,
5100
+ ): void {
5101
+ if (stored.pendingReversionReleases.some((entry) => entry.captureId === release.captureId)) {
5102
+ return;
5103
+ }
5104
+ if (stored.pendingReversionReleases.length >= this.reversionLimits.maxPendingReversionReleases) {
5105
+ throw new FlexHarnessValidationError('Pending reversion release limit was reached.');
5106
+ }
5107
+ stored.pendingReversionReleases.push(cloneSerializable(release));
5108
+ }
5109
+
5110
+ private pruneReversionState(
5111
+ stored: IStoredSessionState,
5112
+ reserveSegment = false,
5113
+ dropNonUndoablePrefix = false,
5114
+ ): void {
5115
+ if (stored.pendingReversion || stored.revertCursor !== this.completedReversionCandidates(stored).length) {
5116
+ return;
5117
+ }
5118
+ while (stored.reversionSegments.length > 0) {
5119
+ const candidates = this.completedReversionCandidates(stored);
5120
+ const overLimit = candidates.length + (reserveSegment ? 1 : 0)
5121
+ > this.reversionLimits.maxCompletedTurns
5122
+ || stored.reversionSegments.length + (reserveSegment ? 1 : 0)
5123
+ > this.reversionLimits.maxSegments;
5124
+ const firstCandidate = candidates[0];
5125
+ const nextCandidate = candidates[1];
5126
+ let end = 0;
5127
+ if (!firstCandidate) {
5128
+ if (dropNonUndoablePrefix) end = stored.reversionSegments.length;
5129
+ else if (overLimit) {
5130
+ end = Math.max(
5131
+ 1,
5132
+ stored.reversionSegments.length + (reserveSegment ? 1 : 0)
5133
+ - this.reversionLimits.maxSegments,
5134
+ );
5135
+ } else break;
5136
+ } else if (!firstCandidate.contextAvailable) {
5137
+ end = nextCandidate
5138
+ ? stored.reversionSegments.findIndex((segment) => segment.runId === nextCandidate.runId)
5139
+ : stored.reversionSegments.length;
5140
+ } else if (overLimit) {
5141
+ end = nextCandidate
5142
+ ? stored.reversionSegments.findIndex((segment) => segment.runId === nextCandidate.runId)
5143
+ : stored.reversionSegments.length;
5144
+ } else {
5145
+ break;
5146
+ }
5147
+ if (end <= 0) break;
5148
+ const prefix = stored.reversionSegments.slice(0, end);
5149
+ if (prefix.some((segment) =>
5150
+ segment.disposition === 'revertible' && segment.workspaceReference === undefined)) break;
5151
+ for (const segment of prefix) this.enqueueReversionRelease(stored, segment);
5152
+ stored.reversionSegments.splice(0, end);
5153
+ stored.revertCursor = Math.max(
5154
+ 0,
5155
+ stored.revertCursor - prefix.filter((segment) => segment.status === 'completed').length,
5156
+ );
5157
+ }
5158
+ if (stored.excludedRunIds.length > this.reversionLimits.maxExcludedRunIds) {
5159
+ throw new FlexHarnessValidationError('Excluded reversion run limit was reached.');
5160
+ }
5161
+ }
5162
+
5163
+ private async commitRevertedBranch(
5164
+ state: IStorageState,
5165
+ stored: IStoredSessionState,
5166
+ scopeId: string,
5167
+ scope: TScope,
5168
+ ): Promise<void> {
5169
+ if (stored.pendingReversionReleases.length > 0) {
5170
+ await this.drainReversionReleases(state, stored, scopeId, scope, false);
5171
+ this.assertStateAcceptingWork(state);
5172
+ }
5173
+ if (this.hiddenReversionSegments(stored).length === 0) {
5174
+ return;
5175
+ }
5176
+ await this.mutateProjection(state, stored, () => this.commitRevertedBranchState(stored), true);
5177
+ this.emitEvent(scopeId, stored.session.sessionId, {
5178
+ type: 'session.history.changed',
5179
+ direction: 'branch',
5180
+ });
5181
+ await this.drainReversionReleases(state, stored, scopeId, scope, false);
5182
+ }
5183
+
5184
+ private commitRevertedBranchState(stored: IStoredSessionState): boolean {
5185
+ const hidden = this.hiddenReversionSegments(stored);
5186
+ if (hidden.length === 0) return false;
5187
+ const hiddenRunIds = new Set(hidden.map((segment) => segment.runId));
5188
+ stored.messages = stored.messages.filter((message) => !hiddenRunIds.has(message.runId));
5189
+ stored.stagedTerminals = stored.stagedTerminals.filter(
5190
+ (terminal) => !hiddenRunIds.has(terminal.runId),
5191
+ );
5192
+ for (const segment of hidden) {
5193
+ this.enqueueReversionRelease(stored, segment);
5194
+ if (!stored.excludedRunIds.includes(segment.runId)) stored.excludedRunIds.push(segment.runId);
5195
+ }
5196
+ stored.reversionSegments = stored.reversionSegments.filter(
5197
+ (segment) => !hiddenRunIds.has(segment.runId),
5198
+ );
5199
+ stored.revertCursor = this.completedReversionCandidates(stored).length;
5200
+ this.pruneReversionState(stored);
5201
+ return true;
5202
+ }
5203
+
5204
+ private commitArchivedHiddenBranchState(stored: IStoredSessionState): boolean {
5205
+ const hidden = this.hiddenReversionSegments(stored);
5206
+ return hidden.some((segment) => !segment.contextAvailable)
5207
+ ? this.commitRevertedBranchState(stored)
5208
+ : false;
5209
+ }
5210
+
5211
+ private filterReversionContextEvents(
5212
+ stored: IStoredSessionState,
5213
+ events: readonly plugins.TAgentEvent[],
5214
+ ): plugins.TAgentEvent[] {
5215
+ const hiddenRunIds = new Set([
5216
+ ...this.hiddenReversionSegments(stored).map((segment) => segment.runId),
5217
+ ...stored.excludedRunIds,
5218
+ ]);
5219
+ const hiddenRawIds = new Set([
5220
+ ...this.hiddenReversionSegments(stored).flatMap((segment) => segment.eventIds),
5221
+ ...events.filter((event) => event.generationId && hiddenRunIds.has(event.generationId))
5222
+ .map((event) => event.id),
5223
+ ]);
5224
+ const taintedCompactionIds = new Set<string>();
5225
+ let changed = true;
5226
+ while (changed) {
5227
+ changed = false;
5228
+ for (const event of events) {
5229
+ if (event.type !== 'context-compaction' || taintedCompactionIds.has(event.id)) continue;
5230
+ if (
5231
+ event.coveredEventIds.some((id) => hiddenRawIds.has(id) || taintedCompactionIds.has(id))
5232
+ || event.archivedTransactions?.some((transaction) =>
5233
+ hiddenRunIds.has(transaction.generationId))
5234
+ ) {
5235
+ taintedCompactionIds.add(event.id);
5236
+ changed = true;
5237
+ }
5238
+ }
5239
+ }
5240
+ return events.filter((event) =>
5241
+ !(event.generationId && hiddenRunIds.has(event.generationId))
5242
+ && !taintedCompactionIds.has(event.id));
5243
+ }
5244
+
5245
+ private async drainReversionReleases(
5246
+ state: IStorageState,
5247
+ stored: IStoredSessionState,
5248
+ scopeId: string,
5249
+ scope: TScope,
5250
+ throwOnFailure: boolean,
5251
+ ): Promise<void> {
5252
+ await this.reconcileProjectionFromStore(stored);
5253
+ if (!this.turnReversionProvider) {
5254
+ if (stored.pendingReversionReleases.length > 0) {
5255
+ throw new FlexHarnessValidationError(
5256
+ 'Pending workspace capture releases require their turn reversion provider.',
5257
+ );
5258
+ }
5259
+ return;
5260
+ }
5261
+ while (stored.pendingReversionReleases.length > 0) {
5262
+ const release = stored.pendingReversionReleases[0];
5263
+ if (this.reversionProviderProtocolVersion() !== release.protocolVersion) {
5264
+ throw new FlexHarnessValidationError(
5265
+ `Pending workspace release requires protocolVersion ${release.protocolVersion}.`,
5266
+ );
5267
+ }
5268
+ try {
5269
+ await this.withReversionMaintenanceSignal((signal) =>
5270
+ this.turnReversionProvider!.release(Object.freeze({
5271
+ scopeId,
5272
+ scope,
5273
+ storageKey: state.storageKey,
5274
+ sessionId: stored.session.sessionId,
5275
+ runId: release.runId,
5276
+ captureId: release.captureId,
5277
+ reference: cloneSerializable(release.reference),
5278
+ signal,
5279
+ })));
5280
+ } catch (error) {
5281
+ if (throwOnFailure) {
5282
+ throw this.projectOperationError(
5283
+ error,
5284
+ 'turnReversion',
5285
+ scopeId,
5286
+ stored.session.sessionId,
5287
+ `release:${release.captureId}`,
5288
+ );
5289
+ }
5290
+ return;
5291
+ }
5292
+ try {
5293
+ await this.mutateProjection(state, stored, () => {
5294
+ if (stored.pendingReversionReleases[0]?.captureId === release.captureId) {
5295
+ stored.pendingReversionReleases.shift();
5296
+ }
5297
+ }, true);
5298
+ } catch (error) {
5299
+ if (!throwOnFailure && state.lifecycle === 'fenced') {
5300
+ this.deferReversionReleaseDrain(state, stored, scopeId, scope);
5301
+ }
5302
+ if (throwOnFailure) throw error;
5303
+ return;
5304
+ }
5305
+ if (stored.projectionReconciliationRequired) return;
5306
+ }
5307
+ }
5308
+
5309
+ private async releaseDeletedSessionReversions(
5310
+ state: IStorageState,
5311
+ storageKey: string,
5312
+ sessionId: string,
5313
+ scopeId: string,
5314
+ scope: TScope,
5315
+ stored?: IStoredSessionState,
5316
+ ): Promise<void> {
5317
+ if (stored) await stored.projectionQueue;
5318
+ let durableProjection = await this.stores.projections.load(storageKey, sessionId);
5319
+ if (!durableProjection) return;
5320
+ let projection = this.upgradeProjectionSnapshot(durableProjection);
5321
+ const persistDirect = async (next: IFlexProjectionSnapshotCurrent): Promise<void> => {
5322
+ const before = durableProjection;
5323
+ try {
5324
+ await this.stores.projections.save(storageKey, sessionId, next, projection.revision);
5325
+ durableProjection = next;
5326
+ projection = next;
5327
+ } catch (error) {
5328
+ let current: TFlexProjectionSnapshot | undefined;
5329
+ let reconciliationError: unknown;
5330
+ try {
5331
+ current = await this.stores.projections.load(storageKey, sessionId);
5332
+ } catch (loadError) {
5333
+ reconciliationError = loadError;
5334
+ }
5335
+ if (current && JSON.stringify(current) === JSON.stringify(next)) {
5336
+ durableProjection = current;
5337
+ projection = next;
5338
+ return;
5339
+ }
5340
+ if (current && before && JSON.stringify(current) === JSON.stringify(before)) throw error;
5341
+ state.lifecycle = 'fenced';
5342
+ throw reconciliationError === undefined ? error : combineErrors([error, reconciliationError]);
5343
+ }
5344
+ };
5345
+ if (projection.pendingReversion?.kind === 'apply') {
5346
+ if (stored) {
5347
+ await this.resumePendingApply(state, stored, scopeId, scope);
5348
+ } else {
5349
+ const pending = projection.pendingReversion;
5350
+ const orderedRunIds = pending.direction === 'undo'
5351
+ ? [...pending.segmentRunIds].reverse()
5352
+ : [...pending.segmentRunIds];
5353
+ for (const runId of orderedRunIds) {
5354
+ if (pending.appliedRunIds.includes(runId)) continue;
5355
+ const segment = projection.reversionSegments.find((entry) => entry.runId === runId)!;
5356
+ if (segment.disposition === 'revertible') {
5357
+ if (!this.turnReversionProvider || !segment.captureId || segment.workspaceReference === undefined) {
5358
+ throw new FlexHarnessValidationError(
5359
+ 'Pending workspace reversion deletion requires its turn reversion provider.',
5360
+ );
5361
+ }
5362
+ if (this.reversionProviderProtocolVersion() !== segment.protocolVersion) {
5363
+ throw new FlexHarnessValidationError(
5364
+ `Pending workspace reversion deletion requires protocolVersion ${segment.protocolVersion}.`,
5365
+ );
5366
+ }
5367
+ const captureId = segment.captureId;
5368
+ const reference = segment.workspaceReference;
5369
+ const operationId = `${pending.operationId}:${sha256Hex(runId).slice(0, 16)}`;
5370
+ const createContext = (signal: AbortSignal) => Object.freeze({
5371
+ scopeId,
5372
+ scope,
5373
+ storageKey,
5374
+ sessionId,
5375
+ runId,
5376
+ captureId,
5377
+ reference: cloneSerializable(reference),
5378
+ operationId,
5379
+ direction: pending.direction,
5380
+ signal,
5381
+ });
5382
+ let inspection = await this.withReversionMaintenanceSignal((signal) =>
5383
+ this.turnReversionProvider!.inspectApply(createContext(signal)));
5384
+ if (inspection.status === 'unknown') {
5385
+ state.lifecycle = 'fenced';
5386
+ throw new FlexHarnessValidationError('Workspace reversion apply outcome is unknown.');
5387
+ }
5388
+ if (inspection.status === 'not-applied') {
5389
+ try {
5390
+ await this.withReversionMaintenanceSignal((signal) =>
5391
+ this.turnReversionProvider!.apply(createContext(signal)));
5392
+ } catch (error) {
5393
+ inspection = await this.withReversionMaintenanceSignal((signal) =>
5394
+ this.turnReversionProvider!.inspectApply(createContext(signal)));
5395
+ if (inspection.status !== 'applied') {
5396
+ if (inspection.status === 'unknown') state.lifecycle = 'fenced';
5397
+ throw error;
5398
+ }
5399
+ }
5400
+ }
5401
+ }
5402
+ pending.appliedRunIds.push(runId);
5403
+ await persistDirect({ ...projection, revision: projection.revision + 1 });
5404
+ }
5405
+ const { pendingReversion: _pendingReversion, ...completedProjection } = projection;
5406
+ const completed: IFlexProjectionSnapshotCurrent = {
5407
+ ...completedProjection,
5408
+ revision: projection.revision + 1,
5409
+ revertCursor: pending.toCursor,
5410
+ };
5411
+ await persistDirect(completed);
5412
+ }
5413
+ const recoveredProjection = await this.stores.projections.load(storageKey, sessionId);
5414
+ if (!recoveredProjection) return;
5415
+ durableProjection = recoveredProjection;
5416
+ projection = this.upgradeProjectionSnapshot(recoveredProjection);
5417
+ }
5418
+ const releases = new Map<string, IFlexPendingReversionRelease>();
5419
+ for (const release of projection.pendingReversionReleases) {
5420
+ releases.set(release.captureId, cloneSerializable(release));
5421
+ }
5422
+ for (const segment of projection.reversionSegments) {
5423
+ if (segment.captureId && segment.workspaceReference !== undefined) {
5424
+ releases.set(segment.captureId, {
5425
+ runId: segment.runId,
5426
+ captureId: segment.captureId,
5427
+ reference: cloneSerializable(segment.workspaceReference),
5428
+ protocolVersion: segment.protocolVersion,
5429
+ });
5430
+ }
5431
+ }
5432
+ const unresolvedCaptures = new Map<string, {
5433
+ runId: string;
5434
+ captureId: string;
5435
+ protocolVersion: 1 | 2;
5436
+ durableState?: 'preparing' | 'prepared' | 'finalizing';
5437
+ }>();
5438
+ for (const segment of projection.reversionSegments) {
5439
+ if (segment.captureId && !releases.has(segment.captureId)) {
5440
+ unresolvedCaptures.set(segment.captureId, {
5441
+ runId: segment.runId,
5442
+ captureId: segment.captureId,
5443
+ protocolVersion: segment.protocolVersion,
3050
5444
  });
3051
5445
  }
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
5446
  }
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,
5447
+ if (projection.pendingReversion?.kind === 'capture') {
5448
+ unresolvedCaptures.set(projection.pendingReversion.captureId, {
5449
+ runId: projection.pendingReversion.runId,
5450
+ captureId: projection.pendingReversion.captureId,
5451
+ protocolVersion: projection.pendingReversion.protocolVersion,
5452
+ durableState: projection.pendingReversion.state,
5453
+ });
5454
+ }
5455
+ if (unresolvedCaptures.size > 0 && !this.turnReversionProvider) {
5456
+ throw new FlexHarnessValidationError(
5457
+ 'Capture-backed session deletion requires its turn reversion provider.',
5458
+ );
5459
+ }
5460
+ for (const capture of unresolvedCaptures.values()) {
5461
+ if (this.reversionProviderProtocolVersion() !== capture.protocolVersion) {
5462
+ throw new FlexHarnessValidationError(
5463
+ `Deleted session capture requires protocolVersion ${capture.protocolVersion}.`,
5464
+ );
5465
+ }
5466
+ if (capture.durableState === 'prepared') {
5467
+ if (stored) {
5468
+ await this.mutateProjection(state, stored, () => {
5469
+ const pending = stored.pendingReversion;
5470
+ if (pending?.kind !== 'capture' || pending.captureId !== capture.captureId) {
5471
+ throw new FlexHarnessValidationError('Deleted session capture changed before finalization.');
5472
+ }
5473
+ pending.state = 'finalizing';
5474
+ }, true);
5475
+ } else if (projection.pendingReversion?.kind === 'capture') {
5476
+ projection.pendingReversion.state = 'finalizing';
5477
+ await persistDirect({ ...projection, revision: projection.revision + 1 });
5478
+ }
5479
+ capture.durableState = 'finalizing';
5480
+ }
5481
+ let inspection;
5482
+ try {
5483
+ inspection = await this.inspectReversionCapture(
5484
+ scopeId,
5485
+ scope,
5486
+ storageKey,
5487
+ sessionId,
5488
+ capture.runId,
5489
+ capture.captureId,
5490
+ );
5491
+ } catch (error) {
5492
+ throw this.projectOperationError(
5493
+ error,
5494
+ 'turnReversion',
5495
+ scopeId,
5496
+ sessionId,
5497
+ `release:${capture.captureId}`,
5498
+ );
5499
+ }
5500
+ if (inspection.status === 'unknown') {
5501
+ state.lifecycle = 'fenced';
5502
+ throw new FlexHarnessValidationError('Deleted session capture outcome is unknown.');
5503
+ }
5504
+ if (inspection.status === 'missing') {
5505
+ if (capture.durableState === 'preparing') continue;
5506
+ state.lifecycle = 'fenced';
5507
+ throw new FlexHarnessValidationError('Prepared deleted-session capture is missing.');
5508
+ }
5509
+ let outcome = inspection.status === 'finalized' ? inspection.outcome : undefined;
5510
+ if (inspection.status === 'prepared') {
5511
+ try {
5512
+ outcome = await this.finalizeReversionCapture(
5513
+ scopeId,
5514
+ scope,
5515
+ storageKey,
5516
+ sessionId,
5517
+ capture.runId,
5518
+ capture.captureId,
5519
+ );
5520
+ } catch (error) {
5521
+ const finalInspection = await this.inspectReversionCapture(
5522
+ scopeId,
5523
+ scope,
5524
+ storageKey,
5525
+ sessionId,
5526
+ capture.runId,
5527
+ capture.captureId,
5528
+ );
5529
+ if (finalInspection.status !== 'finalized') {
5530
+ if (finalInspection.status === 'unknown') state.lifecycle = 'fenced';
5531
+ throw this.projectOperationError(
5532
+ error,
5533
+ 'turnReversion',
5534
+ scopeId,
5535
+ sessionId,
5536
+ `release:${capture.captureId}`,
5537
+ );
3069
5538
  }
3070
- : {}),
3071
- };
5539
+ outcome = finalInspection.outcome;
5540
+ }
5541
+ }
5542
+ if (outcome === undefined) {
5543
+ throw new FlexHarnessValidationError('Deleted session capture has no release reference.');
5544
+ }
5545
+ releases.set(capture.captureId, {
5546
+ runId: capture.runId,
5547
+ captureId: capture.captureId,
5548
+ reference: cloneSerializable(outcome.reference),
5549
+ protocolVersion: capture.protocolVersion,
5550
+ });
5551
+ }
5552
+ if (releases.size === 0) return;
5553
+ if (!this.turnReversionProvider) {
5554
+ throw new FlexHarnessValidationError(
5555
+ 'Capture-backed session deletion requires its turn reversion provider.',
5556
+ );
5557
+ }
5558
+ if (stored) {
5559
+ await this.mutateProjection(state, stored, () => {
5560
+ stored.reversionSegments = [];
5561
+ stored.revertCursor = 0;
5562
+ delete stored.pendingReversion;
5563
+ stored.pendingReversionReleases = [...releases.values()].map((release) =>
5564
+ cloneSerializable(release));
5565
+ }, true);
5566
+ await this.drainReversionReleases(state, stored, scopeId, scope, true);
5567
+ } else {
5568
+ const { pendingReversion: _pendingReversion, ...withoutPending } = projection;
5569
+ await persistDirect({
5570
+ ...withoutPending,
5571
+ revision: projection.revision + 1,
5572
+ reversionSegments: [],
5573
+ revertCursor: 0,
5574
+ pendingReversionReleases: [...releases.values()].map((release) =>
5575
+ cloneSerializable(release)),
5576
+ });
5577
+ while (projection.pendingReversionReleases.length > 0) {
5578
+ const release = projection.pendingReversionReleases[0];
5579
+ if (this.reversionProviderProtocolVersion() !== release.protocolVersion) {
5580
+ throw new FlexHarnessValidationError(
5581
+ `Deleted session release requires protocolVersion ${release.protocolVersion}.`,
5582
+ );
5583
+ }
5584
+ try {
5585
+ await this.withReversionMaintenanceSignal((signal) =>
5586
+ this.turnReversionProvider!.release(Object.freeze({
5587
+ scopeId,
5588
+ scope,
5589
+ storageKey,
5590
+ sessionId,
5591
+ runId: release.runId,
5592
+ captureId: release.captureId,
5593
+ reference: cloneSerializable(release.reference),
5594
+ signal,
5595
+ })));
5596
+ } catch (error) {
5597
+ throw this.projectOperationError(
5598
+ error,
5599
+ 'turnReversion',
5600
+ scopeId,
5601
+ sessionId,
5602
+ `release:${release.captureId}`,
5603
+ );
5604
+ }
5605
+ await persistDirect({
5606
+ ...projection,
5607
+ revision: projection.revision + 1,
5608
+ pendingReversionReleases: projection.pendingReversionReleases.slice(1),
5609
+ });
5610
+ }
5611
+ }
3072
5612
  }
3073
5613
 
3074
5614
  private async promoteCompletedTerminal(
@@ -3688,6 +6228,13 @@ export class FlexHarness<TScope = unknown> {
3688
6228
  await this.mutateProjection(run.state, run.stored, () => {
3689
6229
  run.stored.messages = run.stored.messages.filter((message) => message.runId !== run.runId);
3690
6230
  run.stored.stagedTerminals = run.stored.stagedTerminals.filter((entry) => entry.runId !== run.runId);
6231
+ run.stored.reversionSegments = run.stored.reversionSegments.filter(
6232
+ (segment) => segment.runId !== run.runId,
6233
+ );
6234
+ if (
6235
+ run.stored.pendingReversion?.kind === 'capture'
6236
+ && run.stored.pendingReversion.runId === run.runId
6237
+ ) delete run.stored.pendingReversion;
3691
6238
  }, true);
3692
6239
  } catch (error) {
3693
6240
  errors.push(this.projectExternalError(run, error, 'persistence'));
@@ -4060,6 +6607,7 @@ export class FlexHarness<TScope = unknown> {
4060
6607
  storageKey,
4061
6608
  compactorLifecycleController,
4062
6609
  scopeIdHint: scopeId,
6610
+ scopeContext: { scopeId, scope },
4063
6611
  revision: snapshot.revision,
4064
6612
  sessions: new Map(),
4065
6613
  retainedSessionCleanups: new Map(),
@@ -4084,7 +6632,14 @@ export class FlexHarness<TScope = unknown> {
4084
6632
  const stored = await this.loadSessionRuntime(state, metadata, scopeId, scope);
4085
6633
  state.sessions.set(metadata.sessionId, stored);
4086
6634
  loadedSessions.push(stored);
4087
- scopeChanged = (await this.repairLoadedSession(state.storageKey, stored)) || scopeChanged;
6635
+ scopeChanged = (await this.repairLoadedSession(
6636
+ state.storageKey,
6637
+ stored,
6638
+ state,
6639
+ scopeId,
6640
+ scope,
6641
+ )) || scopeChanged;
6642
+ await this.drainReversionReleases(state, stored, scopeId, scope, false);
4088
6643
  if (stored.projectionRevision < 0) throw new Error('Invalid projection revision.');
4089
6644
  }
4090
6645
  for (const stored of state.sessions.values()) {
@@ -4115,12 +6670,23 @@ export class FlexHarness<TScope = unknown> {
4115
6670
  try {
4116
6671
  const group = this.tombstoneGroup(state, rootSessionId);
4117
6672
  for (const tombstone of group) {
6673
+ await this.releaseDeletedSessionReversions(
6674
+ state,
6675
+ storageKey,
6676
+ tombstone.sessionId,
6677
+ scopeId,
6678
+ scope,
6679
+ );
4118
6680
  await this.cleanupSessionDomains(storageKey, tombstone.sessionId);
4119
6681
  }
4120
6682
  for (const tombstone of group) state.tombstones.delete(tombstone.sessionId);
4121
6683
  scopeChanged = true;
4122
- } catch {
6684
+ } catch (error) {
4123
6685
  // A retained tombstone is retried by the next load or explicit delete call.
6686
+ if (state.lifecycle === 'fenced') {
6687
+ this.deferNamespaceDrain(state, error, { scopeId, scope });
6688
+ break;
6689
+ }
4124
6690
  }
4125
6691
  }
4126
6692
  if (scopeChanged) {
@@ -4130,6 +6696,16 @@ export class FlexHarness<TScope = unknown> {
4130
6696
  }
4131
6697
  return state;
4132
6698
  } catch (error) {
6699
+ for (const rootSessionId of new Set([...state.tombstones.values()].map((tombstone) =>
6700
+ tombstone.rootSessionId ?? tombstone.sessionId))) {
6701
+ const key = JSON.stringify([storageKey, rootSessionId]);
6702
+ this.orphanedTombstoneOwners.set(key, {
6703
+ state,
6704
+ rootSessionId,
6705
+ scopeId,
6706
+ scope,
6707
+ });
6708
+ }
4133
6709
  const closeResults = await Promise.allSettled(
4134
6710
  loadedSessions.map((stored) => this.closeStoredSession(stored)),
4135
6711
  );
@@ -4162,6 +6738,12 @@ export class FlexHarness<TScope = unknown> {
4162
6738
  session,
4163
6739
  messages: [],
4164
6740
  stagedTerminals: [],
6741
+ reversionSegments: [],
6742
+ revertCursor: 0,
6743
+ excludedRunIds: [],
6744
+ pendingReversionReleases: [],
6745
+ projectionSchemaVersion: 3,
6746
+ projectionReconciliationRequired: false,
4165
6747
  projectionRevision: 0,
4166
6748
  projectionQueue: Promise.resolve(),
4167
6749
  rememberedPermissionKeys: new Set(),
@@ -4228,6 +6810,28 @@ export class FlexHarness<TScope = unknown> {
4228
6810
  throw combineErrors(acquisitionErrors);
4229
6811
  }
4230
6812
  const projection = projectionResult.value;
6813
+ const legacyProjection = projection?.schemaVersion === 2 ? projection : undefined;
6814
+ const currentProjection = projection?.schemaVersion === 3 ? projection : undefined;
6815
+ const reversionSegments: IFlexReversionSegment[] = currentProjection
6816
+ ? cloneSerializable(currentProjection.reversionSegments)
6817
+ : (legacyProjection?.reversionSegments ?? []).map((segment): IFlexReversionSegment => ({
6818
+ ...cloneSerializable(segment),
6819
+ protocolVersion: 1,
6820
+ provenance: segment.workspaceCaptured ? 'workspace' : 'transcript',
6821
+ ...(segment.workspaceCaptured
6822
+ ? { disposition: segment.status === 'capturing' ? 'pending' : 'revertible' }
6823
+ : {}),
6824
+ }));
6825
+ const pendingReversion = currentProjection?.pendingReversion
6826
+ ?? (legacyProjection?.pendingReversion?.kind === 'capture'
6827
+ ? { ...legacyProjection.pendingReversion, protocolVersion: 1 as const }
6828
+ : legacyProjection?.pendingReversion);
6829
+ const pendingReversionReleases: IFlexPendingReversionRelease[] = currentProjection
6830
+ ? cloneSerializable(currentProjection.pendingReversionReleases)
6831
+ : (legacyProjection?.pendingReversionReleases ?? []).map((release) => ({
6832
+ ...cloneSerializable(release),
6833
+ protocolVersion: 1,
6834
+ }));
4231
6835
  const permission = permissionResult.value;
4232
6836
  const eventStore = eventStoreResult.value;
4233
6837
  const jobStore = jobStoreResult.value;
@@ -4258,6 +6862,20 @@ export class FlexHarness<TScope = unknown> {
4258
6862
  session: cloneSerializable(metadata),
4259
6863
  messages: cloneSerializable(projection?.messages ?? []),
4260
6864
  stagedTerminals: cloneSerializable(projection?.stagedTerminals ?? []),
6865
+ reversionSegments,
6866
+ revertCursor: currentProjection?.revertCursor ?? legacyProjection?.revertCursor ?? 0,
6867
+ excludedRunIds: cloneSerializable(
6868
+ currentProjection?.excludedRunIds ?? legacyProjection?.excludedRunIds ?? [],
6869
+ ),
6870
+ ...(pendingReversion === undefined
6871
+ ? {}
6872
+ : { pendingReversion: cloneSerializable(pendingReversion) }),
6873
+ pendingReversionReleases,
6874
+ projectionSchemaVersion: projection?.schemaVersion ?? 3,
6875
+ projectionReconciliationRequired: false,
6876
+ ...(projection && projection.schemaVersion !== 3
6877
+ ? { projectionBaseline: cloneSerializable(projection) }
6878
+ : {}),
4261
6879
  projectionRevision: projection?.revision ?? 0,
4262
6880
  projectionQueue: Promise.resolve(),
4263
6881
  rememberedPermissionKeys: new Set(permission?.rememberedPermissionKeys ?? []),
@@ -4306,7 +6924,9 @@ export class FlexHarness<TScope = unknown> {
4306
6924
  eventStore,
4307
6925
  executionContext: contextualExecutionContext,
4308
6926
  contextBuilder: ({ events }) => hydrateAgentMessages(
4309
- (this.agentSessionPolicy.contextBuilder ?? ((options) => plugins.buildModelMessages(options.events)))({ events }),
6927
+ (this.agentSessionPolicy.contextBuilder ?? ((options) => plugins.buildModelMessages(options.events)))({
6928
+ events: this.filterReversionContextEvents(stored, events),
6929
+ }),
4310
6930
  ),
4311
6931
  ...(contextCompactor
4312
6932
  ? {
@@ -4323,7 +6943,14 @@ export class FlexHarness<TScope = unknown> {
4323
6943
  ) {
4324
6944
  throw new Error('Agent context compaction is missing its exact FlexHarness invocation context.');
4325
6945
  }
4326
- return contextCompactor(messages, events, {
6946
+ const filteredEvents = this.filterReversionContextEvents(stored, events);
6947
+ const filteredMessages = hydrateAgentMessages(
6948
+ (this.agentSessionPolicy.contextBuilder
6949
+ ?? ((contextOptions) => plugins.buildModelMessages(contextOptions.events)))({
6950
+ events: filteredEvents,
6951
+ }),
6952
+ );
6953
+ return contextCompactor(filteredMessages, filteredEvents, {
4327
6954
  ...options,
4328
6955
  ...invocationContext,
4329
6956
  scope: invocationContext.scope as TScope,
@@ -4390,13 +7017,62 @@ export class FlexHarness<TScope = unknown> {
4390
7017
  private async repairLoadedSession(
4391
7018
  storageKey: string,
4392
7019
  stored: IStoredSessionState,
7020
+ state?: IStorageState,
7021
+ scopeId?: string,
7022
+ scope?: TScope,
4393
7023
  ): Promise<boolean> {
4394
7024
  const beforeProjection = JSON.stringify({
4395
7025
  messages: stored.messages,
4396
7026
  stagedTerminals: stored.stagedTerminals,
7027
+ reversionSegments: stored.reversionSegments,
7028
+ revertCursor: stored.revertCursor,
7029
+ excludedRunIds: stored.excludedRunIds,
7030
+ pendingReversion: stored.pendingReversion,
7031
+ pendingReversionReleases: stored.pendingReversionReleases,
4397
7032
  });
4398
7033
  const beforeSession = JSON.stringify(stored.session);
7034
+ if (
7035
+ !this.turnReversionProvider
7036
+ && (
7037
+ stored.reversionSegments.some((segment) => segment.captureId)
7038
+ || stored.pendingReversionReleases.length > 0
7039
+ || stored.pendingReversion?.kind === 'capture'
7040
+ )
7041
+ ) {
7042
+ throw new FlexHarnessValidationError(
7043
+ 'Capture-backed session recovery requires its turn reversion provider.',
7044
+ );
7045
+ }
7046
+ const requiredProtocols = new Set<number>([
7047
+ ...stored.reversionSegments
7048
+ .filter((segment) => segment.captureId !== undefined)
7049
+ .map((segment) => segment.protocolVersion),
7050
+ ...stored.pendingReversionReleases.map((release) => release.protocolVersion),
7051
+ ...(stored.pendingReversion?.kind === 'capture'
7052
+ ? [stored.pendingReversion.protocolVersion]
7053
+ : []),
7054
+ ]);
7055
+ const providerProtocol = this.reversionProviderProtocolVersion();
7056
+ if (requiredProtocols.size > 0 && (
7057
+ requiredProtocols.size !== 1
7058
+ || !requiredProtocols.has(providerProtocol ?? 0)
7059
+ )) {
7060
+ throw new FlexHarnessValidationError(
7061
+ `Capture-backed session recovery requires protocolVersion ${[...requiredProtocols].join(', ')}.`,
7062
+ );
7063
+ }
7064
+ if (stored.pendingReversion?.kind === 'capture' && stored.pendingReversionReleases.length > 0) {
7065
+ if (!state || scopeId === undefined || scope === undefined) {
7066
+ throw new FlexHarnessValidationError('Pending capture recovery is missing its scope context.');
7067
+ }
7068
+ await this.drainReversionReleases(state, stored, scopeId, scope, false);
7069
+ this.assertStateAcceptingWork(state);
7070
+ }
4399
7071
  const outcomes = this.canonicalOutcomes(stored.agentSession.getEvents());
7072
+ const firstHiddenCandidate = this.completedReversionCandidates(stored)[stored.revertCursor];
7073
+ const reversionVisibilityBoundary = firstHiddenCandidate
7074
+ ? stored.reversionSegments.findIndex((segment) => segment.runId === firstHiddenCandidate.runId)
7075
+ : stored.reversionSegments.length;
4400
7076
  const stages = new Map(stored.stagedTerminals.map((terminal) => [terminal.runId, terminal]));
4401
7077
  const runIds = new Set([
4402
7078
  ...stored.messages.map((message) => message.runId),
@@ -4433,6 +7109,37 @@ export class FlexHarness<TScope = unknown> {
4433
7109
  }
4434
7110
  stored.stagedTerminals = stored.stagedTerminals.filter((terminal) => !outcomes.has(terminal.runId));
4435
7111
  if (stored.stagedTerminals.length > 0) stored.stagedTerminals = [];
7112
+ const activeEvents = stored.agentSession.getEvents();
7113
+ const activeEventIds = new Set(activeEvents.map((event) => event.id));
7114
+ for (let index = 0; index < stored.reversionSegments.length; index++) {
7115
+ const segment = stored.reversionSegments[index];
7116
+ if (segment.eventIds.length > 0 && !segment.eventIds.some((id) => activeEventIds.has(id))) {
7117
+ segment.contextAvailable = false;
7118
+ }
7119
+ if (segment.status !== 'capturing') {
7120
+ const outcome = outcomes.get(segment.runId);
7121
+ if (outcome === 'accepted') segment.status = 'completed';
7122
+ else if (outcome === 'rejected') segment.status = 'failed';
7123
+ else if (outcome === 'interrupted' || segment.contextAvailable) segment.status = 'cancelled';
7124
+ }
7125
+ }
7126
+ stored.revertCursor = stored.reversionSegments
7127
+ .slice(0, reversionVisibilityBoundary)
7128
+ .filter((segment) => segment.status === 'completed')
7129
+ .length;
7130
+ this.commitArchivedHiddenBranchState(stored);
7131
+ this.pruneReversionState(stored, false, true);
7132
+ if (stored.pendingReversion?.kind === 'apply') {
7133
+ if (!state || scopeId === undefined || scope === undefined) {
7134
+ throw new FlexHarnessValidationError('Pending reversion recovery is missing its scope context.');
7135
+ }
7136
+ await this.resumePendingApply(state, stored, scopeId, scope);
7137
+ } else if (stored.pendingReversion?.kind === 'capture') {
7138
+ if (!this.turnReversionProvider || !state || scopeId === undefined || scope === undefined) {
7139
+ throw new FlexHarnessValidationError('Pending capture recovery requires its reversion provider.');
7140
+ }
7141
+ await this.recoverPendingCapture(state, stored, scopeId, scope, outcomes);
7142
+ }
4436
7143
  if (latestTerminal) {
4437
7144
  const completedAt = latestTerminal.assistantMessage.completedAt ?? new Date().toISOString();
4438
7145
  stored.session.status = latestTerminal.status === 'completed' ? 'idle' : latestTerminal.status;
@@ -4459,6 +7166,11 @@ export class FlexHarness<TScope = unknown> {
4459
7166
  const projectionChanged = beforeProjection !== JSON.stringify({
4460
7167
  messages: stored.messages,
4461
7168
  stagedTerminals: stored.stagedTerminals,
7169
+ reversionSegments: stored.reversionSegments,
7170
+ revertCursor: stored.revertCursor,
7171
+ excludedRunIds: stored.excludedRunIds,
7172
+ pendingReversion: stored.pendingReversion,
7173
+ pendingReversionReleases: stored.pendingReversionReleases,
4462
7174
  });
4463
7175
  if (projectionChanged) {
4464
7176
  const expected = stored.projectionRevision;
@@ -4469,6 +7181,9 @@ export class FlexHarness<TScope = unknown> {
4469
7181
  snapshot,
4470
7182
  expected,
4471
7183
  );
7184
+ stored.projectionSchemaVersion = 3;
7185
+ delete stored.projectionBaseline;
7186
+ stored.projectionReconciliationRequired = false;
4472
7187
  stored.projectionRevision = snapshot.revision;
4473
7188
  }
4474
7189
  return beforeSession !== JSON.stringify(stored.session);
@@ -4628,18 +7343,47 @@ export class FlexHarness<TScope = unknown> {
4628
7343
  ): Promise<TValue> {
4629
7344
  const operation = stored.projectionQueue.then(async () => {
4630
7345
  const beforeRevision = stored.projectionRevision;
7346
+ const beforeSchemaVersion = stored.projectionSchemaVersion;
7347
+ const beforeReconciliationRequired = stored.projectionReconciliationRequired;
7348
+ const beforeBaseline = stored.projectionBaseline === undefined
7349
+ ? undefined
7350
+ : cloneSerializable(stored.projectionBaseline);
4631
7351
  const beforeMessages = cloneSerializable(stored.messages);
4632
7352
  const beforeStages = cloneSerializable(stored.stagedTerminals);
4633
- const beforeSnapshot: IFlexProjectionSnapshot = {
4634
- schemaVersion: 1,
7353
+ const beforeSegments = cloneSerializable(stored.reversionSegments);
7354
+ const beforeCursor = stored.revertCursor;
7355
+ const beforeExcludedRunIds = cloneSerializable(stored.excludedRunIds);
7356
+ const beforePendingReversion = stored.pendingReversion === undefined
7357
+ ? undefined
7358
+ : cloneSerializable(stored.pendingReversion);
7359
+ const beforePendingReversionReleases = cloneSerializable(stored.pendingReversionReleases);
7360
+ const beforeSnapshot: TFlexProjectionSnapshot = beforeBaseline ?? {
7361
+ schemaVersion: 3,
4635
7362
  revision: beforeRevision,
4636
7363
  messages: cloneSerializable(beforeMessages),
4637
7364
  stagedTerminals: cloneSerializable(beforeStages),
7365
+ reversionSegments: cloneSerializable(beforeSegments),
7366
+ revertCursor: beforeCursor,
7367
+ excludedRunIds: cloneSerializable(beforeExcludedRunIds),
7368
+ ...(beforePendingReversion === undefined
7369
+ ? {}
7370
+ : { pendingReversion: cloneSerializable(beforePendingReversion) }),
7371
+ pendingReversionReleases: cloneSerializable(beforePendingReversionReleases),
4638
7372
  };
4639
7373
  const restore = () => {
7374
+ stored.projectionSchemaVersion = beforeSchemaVersion;
7375
+ stored.projectionReconciliationRequired = beforeReconciliationRequired;
7376
+ if (beforeBaseline === undefined) delete stored.projectionBaseline;
7377
+ else stored.projectionBaseline = beforeBaseline;
4640
7378
  stored.projectionRevision = beforeRevision;
4641
7379
  stored.messages = beforeMessages;
4642
7380
  stored.stagedTerminals = beforeStages;
7381
+ stored.reversionSegments = beforeSegments;
7382
+ stored.revertCursor = beforeCursor;
7383
+ stored.excludedRunIds = beforeExcludedRunIds;
7384
+ if (beforePendingReversion === undefined) delete stored.pendingReversion;
7385
+ else stored.pendingReversion = beforePendingReversion;
7386
+ stored.pendingReversionReleases = beforePendingReversionReleases;
4643
7387
  };
4644
7388
  let result: TValue;
4645
7389
  try {
@@ -4656,6 +7400,9 @@ export class FlexHarness<TScope = unknown> {
4656
7400
  snapshot,
4657
7401
  beforeRevision,
4658
7402
  );
7403
+ stored.projectionSchemaVersion = 3;
7404
+ delete stored.projectionBaseline;
7405
+ stored.projectionReconciliationRequired = false;
4659
7406
  stored.projectionRevision = snapshot.revision;
4660
7407
  return result;
4661
7408
  } catch (error) {
@@ -4664,11 +7411,13 @@ export class FlexHarness<TScope = unknown> {
4664
7411
  throw error;
4665
7412
  }
4666
7413
  if (error instanceof FlexHarnessStoreCommitUncertainError) {
7414
+ stored.projectionSchemaVersion = 3;
7415
+ stored.projectionReconciliationRequired = true;
4667
7416
  stored.projectionRevision = snapshot.revision;
4668
7417
  state.lifecycle = 'fenced';
4669
7418
  throw error;
4670
7419
  }
4671
- let current: IFlexProjectionSnapshot | undefined;
7420
+ let current: TFlexProjectionSnapshot | undefined;
4672
7421
  let reconciliationError: unknown;
4673
7422
  try {
4674
7423
  current = await this.stores.projections.load(
@@ -4679,6 +7428,9 @@ export class FlexHarness<TScope = unknown> {
4679
7428
  reconciliationError = loadError;
4680
7429
  }
4681
7430
  if (current && JSON.stringify(current) === JSON.stringify(snapshot)) {
7431
+ stored.projectionSchemaVersion = 3;
7432
+ delete stored.projectionBaseline;
7433
+ stored.projectionReconciliationRequired = false;
4682
7434
  stored.projectionRevision = snapshot.revision;
4683
7435
  throw error;
4684
7436
  }
@@ -4689,6 +7441,8 @@ export class FlexHarness<TScope = unknown> {
4689
7441
  restore();
4690
7442
  throw error;
4691
7443
  }
7444
+ stored.projectionSchemaVersion = 3;
7445
+ stored.projectionReconciliationRequired = true;
4692
7446
  stored.projectionRevision = snapshot.revision;
4693
7447
  state.lifecycle = 'fenced';
4694
7448
  throw reconciliationError === undefined
@@ -4797,15 +7551,92 @@ export class FlexHarness<TScope = unknown> {
4797
7551
  private createProjectionSnapshot(
4798
7552
  stored: IStoredSessionState,
4799
7553
  revision: number,
4800
- ): IFlexProjectionSnapshot {
7554
+ ): IFlexProjectionSnapshotCurrent {
4801
7555
  return {
4802
- schemaVersion: 1,
7556
+ schemaVersion: 3,
4803
7557
  revision,
4804
7558
  messages: cloneSerializable(stored.messages),
4805
7559
  stagedTerminals: cloneSerializable(stored.stagedTerminals),
7560
+ reversionSegments: cloneSerializable(stored.reversionSegments),
7561
+ revertCursor: stored.revertCursor,
7562
+ excludedRunIds: cloneSerializable(stored.excludedRunIds),
7563
+ ...(stored.pendingReversion === undefined
7564
+ ? {}
7565
+ : { pendingReversion: cloneSerializable(stored.pendingReversion) }),
7566
+ pendingReversionReleases: cloneSerializable(stored.pendingReversionReleases),
7567
+ };
7568
+ }
7569
+
7570
+ private upgradeProjectionSnapshot(
7571
+ projection: TFlexProjectionSnapshot,
7572
+ ): IFlexProjectionSnapshotCurrent {
7573
+ if (projection.schemaVersion === 3) return cloneSerializable(projection);
7574
+ if (projection.schemaVersion === 1) {
7575
+ return {
7576
+ schemaVersion: 3,
7577
+ revision: projection.revision,
7578
+ messages: cloneSerializable(projection.messages),
7579
+ stagedTerminals: cloneSerializable(projection.stagedTerminals),
7580
+ reversionSegments: [],
7581
+ revertCursor: 0,
7582
+ excludedRunIds: [],
7583
+ pendingReversionReleases: [],
7584
+ };
7585
+ }
7586
+ return {
7587
+ schemaVersion: 3,
7588
+ revision: projection.revision,
7589
+ messages: cloneSerializable(projection.messages),
7590
+ stagedTerminals: cloneSerializable(projection.stagedTerminals),
7591
+ reversionSegments: projection.reversionSegments.map((segment) => ({
7592
+ ...cloneSerializable(segment),
7593
+ protocolVersion: 1,
7594
+ provenance: segment.workspaceCaptured ? 'workspace' as const : 'transcript' as const,
7595
+ ...(segment.workspaceCaptured
7596
+ ? { disposition: segment.status === 'capturing' ? 'pending' as const : 'revertible' as const }
7597
+ : {}),
7598
+ })),
7599
+ revertCursor: projection.revertCursor,
7600
+ excludedRunIds: cloneSerializable(projection.excludedRunIds),
7601
+ ...(projection.pendingReversion === undefined
7602
+ ? {}
7603
+ : {
7604
+ pendingReversion: projection.pendingReversion.kind === 'capture'
7605
+ ? { ...cloneSerializable(projection.pendingReversion), protocolVersion: 1 }
7606
+ : cloneSerializable(projection.pendingReversion),
7607
+ }),
7608
+ pendingReversionReleases: projection.pendingReversionReleases.map((release) => ({
7609
+ ...cloneSerializable(release),
7610
+ protocolVersion: 1,
7611
+ })),
4806
7612
  };
4807
7613
  }
4808
7614
 
7615
+ private async reconcileProjectionFromStore(stored: IStoredSessionState): Promise<void> {
7616
+ if (!stored.projectionReconciliationRequired) return;
7617
+ const projection = await this.stores.projections.load(
7618
+ stored.storageKey,
7619
+ stored.session.sessionId,
7620
+ );
7621
+ if (!projection) {
7622
+ throw new FlexHarnessValidationError('Uncertain projection persistence could not be reloaded.');
7623
+ }
7624
+ const current = this.upgradeProjectionSnapshot(projection);
7625
+ stored.messages = current.messages;
7626
+ stored.stagedTerminals = current.stagedTerminals;
7627
+ stored.reversionSegments = current.reversionSegments;
7628
+ stored.revertCursor = current.revertCursor;
7629
+ stored.excludedRunIds = current.excludedRunIds;
7630
+ if (current.pendingReversion === undefined) delete stored.pendingReversion;
7631
+ else stored.pendingReversion = current.pendingReversion;
7632
+ stored.pendingReversionReleases = current.pendingReversionReleases;
7633
+ if (projection.schemaVersion === 3) delete stored.projectionBaseline;
7634
+ else stored.projectionBaseline = cloneSerializable(projection);
7635
+ stored.projectionSchemaVersion = projection.schemaVersion;
7636
+ stored.projectionRevision = projection.revision;
7637
+ stored.projectionReconciliationRequired = false;
7638
+ }
7639
+
4809
7640
  private async cleanupSessionDomains(storageKey: string, sessionId: string): Promise<void> {
4810
7641
  const results = await Promise.allSettled([
4811
7642
  this.stores.agentEvents.deleteSession(storageKey, sessionId),
@@ -4914,15 +7745,19 @@ export class FlexHarness<TScope = unknown> {
4914
7745
  }
4915
7746
  const group = this.tombstoneGroup(state, rootSessionId);
4916
7747
  if (group.length === 0) return;
7748
+ const exactInvocation = invocation ?? {
7749
+ scopeId: state.scopeContext.scopeId,
7750
+ scope: state.scopeContext.scope as TScope,
7751
+ };
4917
7752
  const groupIds = new Set(group.map((tombstone) => tombstone.sessionId));
4918
7753
  const reason = this.trustInternalError(new FlexHarnessAbortError('The session subtree was deleted.'));
4919
7754
  const contextFor = (
4920
7755
  sessionId: string,
4921
7756
  retained?: IRetainedSessionCleanup,
4922
- ): IFlexAgentContextInvocation<TScope> | undefined => invocation
7757
+ ): IFlexAgentContextInvocation<TScope> | undefined => exactInvocation
4923
7758
  ? this.createCompactorContext(
4924
- invocation.scopeId,
4925
- invocation.scope,
7759
+ exactInvocation.scopeId,
7760
+ exactInvocation.scope,
4926
7761
  state.storageKey,
4927
7762
  sessionId,
4928
7763
  )
@@ -4995,6 +7830,21 @@ export class FlexHarness<TScope = unknown> {
4995
7830
  if (this.hasOrphanedSessionResources(state.storageKey, sessionId)) {
4996
7831
  throw new Error(`Session "${sessionId}" still has runtime cleanup ownership.`);
4997
7832
  }
7833
+ const releaseContext = exactInvocation ?? retained?.stored.compactorContext as
7834
+ IFlexAgentContextInvocation<TScope> | undefined;
7835
+ if (!releaseContext) {
7836
+ throw new FlexHarnessValidationError(
7837
+ 'Tombstone cleanup requires its exact scope context before deleting projection data.',
7838
+ );
7839
+ }
7840
+ await this.releaseDeletedSessionReversions(
7841
+ state,
7842
+ state.storageKey,
7843
+ sessionId,
7844
+ releaseContext.scopeId,
7845
+ releaseContext.scope,
7846
+ retained?.stored,
7847
+ );
4998
7848
  await this.cleanupSessionDomains(state.storageKey, sessionId);
4999
7849
  if (retained) retained.domainsCompleted = true;
5000
7850
  }
@@ -5182,6 +8032,26 @@ export class FlexHarness<TScope = unknown> {
5182
8032
  void drain.catch(() => undefined);
5183
8033
  }
5184
8034
 
8035
+ private deferReversionReleaseDrain(
8036
+ state: IStorageState,
8037
+ stored: IStoredSessionState,
8038
+ scopeId: string,
8039
+ scope: TScope,
8040
+ ): void {
8041
+ state.lifecycle = 'fenced';
8042
+ const reason = this.trustInternalError(new FlexHarnessAbortError('The session namespace was fenced.'));
8043
+ this.abortCompactorLifecycle(stored, reason);
8044
+ const stateLoad = this.stateLoads.get(state.storageKey);
8045
+ if (!stateLoad) return;
8046
+ const drain = this.drainStorage(
8047
+ state.storageKey,
8048
+ stateLoad,
8049
+ reason,
8050
+ { scopeId, scope },
8051
+ );
8052
+ void drain.catch(() => undefined);
8053
+ }
8054
+
5185
8055
  private sessionsAreDependencyRelated(
5186
8056
  state: IStorageState,
5187
8057
  leftSessionId: string,
@@ -5387,6 +8257,20 @@ export class FlexHarness<TScope = unknown> {
5387
8257
 
5388
8258
  private async closeOrphanedResourcesInternal(storageKey?: string): Promise<unknown[]> {
5389
8259
  const errors: unknown[] = [];
8260
+ for (const [key, owner] of [...this.orphanedTombstoneOwners]) {
8261
+ if (storageKey !== undefined && owner.state.storageKey !== storageKey) continue;
8262
+ try {
8263
+ await this.finishTombstoneCleanup(
8264
+ owner.state,
8265
+ owner.rootSessionId,
8266
+ owner.scopeId,
8267
+ { scopeId: owner.scopeId, scope: owner.scope },
8268
+ );
8269
+ this.orphanedTombstoneOwners.delete(key);
8270
+ } catch (error) {
8271
+ errors.push(error);
8272
+ }
8273
+ }
5390
8274
  const tombstoneCleanups = [...this.orphanedTombstoneCleanups.values()]
5391
8275
  .filter((retained) => storageKey === undefined || retained.storageKey === storageKey);
5392
8276
  const tombstoneResults = await Promise.allSettled(
@@ -5436,6 +8320,7 @@ export class FlexHarness<TScope = unknown> {
5436
8320
  ...[...this.orphanedExecutionContexts.values()].map((owner) => owner.storageKey),
5437
8321
  ...[...this.orphanedProviderReleases.values()].map((retained) => retained.storageKey),
5438
8322
  ...[...this.orphanedTombstoneCleanups.values()].map((retained) => retained.storageKey),
8323
+ ...[...this.orphanedTombstoneOwners.values()].map((owner) => owner.state.storageKey),
5439
8324
  ]);
5440
8325
  }
5441
8326
 
@@ -5443,7 +8328,18 @@ export class FlexHarness<TScope = unknown> {
5443
8328
  const scope = await this.scopeResolver.resolveScope(scopeId);
5444
8329
  this.assertOpen();
5445
8330
  validateIdentifier(scope.storageKey, 'resolved storageKey');
8331
+ this.assertNoAmbientSlashCommandTeardown({ storageKey: scope.storageKey });
5446
8332
  const errors: unknown[] = [];
8333
+ const listingSettlement = this.abortSlashCommandListings(
8334
+ scope.storageKey,
8335
+ this.trustInternalError(new FlexHarnessAbortError(scopeRetirementMessage)),
8336
+ );
8337
+ if (listingSettlement) await listingSettlement;
8338
+ await this.abortSlashCommandExecutions(
8339
+ scope.storageKey,
8340
+ this.trustInternalError(new FlexHarnessAbortError(scopeRetirementMessage)),
8341
+ errors,
8342
+ );
5447
8343
  const stateLoad = this.stateLoads.get(scope.storageKey);
5448
8344
  if (!stateLoad) {
5449
8345
  const drain = this.storageDrains.get(scope.storageKey);
@@ -5515,8 +8411,11 @@ export class FlexHarness<TScope = unknown> {
5515
8411
  return;
5516
8412
  }
5517
8413
  if (state.lifecycle !== 'fenced') state.lifecycle = 'retiring';
5518
- const detachedCleanupAttempts = this.beginDetachedCleanupSettlement(state);
5519
8414
  const errors: unknown[] = [];
8415
+ const listingSettlement = this.abortSlashCommandListings(storageKey, reason);
8416
+ if (listingSettlement) await listingSettlement;
8417
+ await this.abortSlashCommandExecutions(storageKey, reason, errors);
8418
+ const detachedCleanupAttempts = this.beginDetachedCleanupSettlement(state);
5520
8419
  const contextFor = (sessionId: string) => invocation
5521
8420
  ? this.createCompactorContext(invocation.scopeId, invocation.scope, storageKey, sessionId)
5522
8421
  : undefined;
@@ -5585,6 +8484,21 @@ export class FlexHarness<TScope = unknown> {
5585
8484
  'lifecycle-close',
5586
8485
  ));
5587
8486
  }
8487
+ const releaseContext = invocation ?? stored.compactorContext as
8488
+ IFlexAgentContextInvocation<TScope> | undefined;
8489
+ if (releaseContext) {
8490
+ try {
8491
+ await this.drainReversionReleases(
8492
+ state,
8493
+ stored,
8494
+ releaseContext.scopeId,
8495
+ releaseContext.scope,
8496
+ true,
8497
+ );
8498
+ } catch (error) {
8499
+ errors.push(error);
8500
+ }
8501
+ }
5588
8502
  this.purgeStoredPromptQueue(stored);
5589
8503
  }
5590
8504
  const roots = this.orderTombstoneRootsChildFirst(
@@ -5629,6 +8543,21 @@ export class FlexHarness<TScope = unknown> {
5629
8543
  if (!pending.controller.signal.aborted) pending.controller.abort(pendingAdmissionReason);
5630
8544
  }
5631
8545
  await Promise.all(pendingAdmissions.map((pending) => pending.settled));
8546
+ const errors: unknown[] = [];
8547
+ const listingSettlement = this.abortSlashCommandListings(
8548
+ undefined,
8549
+ this.trustInternalError(new FlexHarnessAbortError(
8550
+ 'The slash command listing was aborted because FlexHarness was disposed.',
8551
+ )),
8552
+ );
8553
+ if (listingSettlement) await listingSettlement;
8554
+ await this.abortSlashCommandExecutions(
8555
+ undefined,
8556
+ this.trustInternalError(new FlexHarnessAbortError(
8557
+ 'The slash command was aborted because FlexHarness was disposed.',
8558
+ )),
8559
+ errors,
8560
+ );
5632
8561
  const loads = [...this.stateLoads.entries()];
5633
8562
  const results = await Promise.allSettled(loads.map(([storageKey, stateLoad]) =>
5634
8563
  this.drainStorage(
@@ -5638,7 +8567,6 @@ export class FlexHarness<TScope = unknown> {
5638
8567
  'The run was aborted because FlexHarness was disposed.',
5639
8568
  )),
5640
8569
  )));
5641
- const errors: unknown[] = [];
5642
8570
  for (const result of results) {
5643
8571
  if (result.status === 'rejected') this.appendUnexpectedErrors(errors, result.reason);
5644
8572
  }
@@ -5651,6 +8579,8 @@ export class FlexHarness<TScope = unknown> {
5651
8579
  this.listeners.clear();
5652
8580
  if (errors.length > 0) throw combineErrors(errors);
5653
8581
  this.compactorInvocationContext.disable();
8582
+ this.slashCommandInvocationContext.disable();
8583
+ this.slashCommandActivityContext.disable();
5654
8584
  this.stateLoads.clear();
5655
8585
  this.scopeAdmissions.clear();
5656
8586
  this.scopeRetirements.clear();
@@ -5658,6 +8588,45 @@ export class FlexHarness<TScope = unknown> {
5658
8588
  this.storageCompactorLifecycleControllers.clear();
5659
8589
  }
5660
8590
 
8591
+ private async abortSlashCommandExecutions(
8592
+ storageKey: string | undefined,
8593
+ reason: FlexHarnessAbortError,
8594
+ errors: unknown[],
8595
+ sessionId?: string,
8596
+ ): Promise<void> {
8597
+ const active = [...this.activeSlashCommandExecutions.values()]
8598
+ .filter((execution) =>
8599
+ (storageKey === undefined || execution.storageKey === storageKey)
8600
+ && (sessionId === undefined || execution.sessionId === sessionId));
8601
+ for (const execution of active) {
8602
+ if (!execution.controller.signal.aborted) execution.controller.abort(reason);
8603
+ }
8604
+ const awaited = active.filter((execution) => execution.kind !== 'prompt-admission');
8605
+ const results = await Promise.allSettled(awaited.map((execution) => execution.completion));
8606
+ for (let index = 0; index < results.length; index++) {
8607
+ const result = results[index];
8608
+ if (result.status === 'rejected' && awaited[index].kind === 'handler') {
8609
+ this.appendUnexpectedErrors(errors, result.reason);
8610
+ }
8611
+ }
8612
+ }
8613
+
8614
+ private abortSlashCommandListings(
8615
+ storageKey: string | undefined,
8616
+ reason: FlexHarnessAbortError,
8617
+ sessionId?: string,
8618
+ ): Promise<void> | undefined {
8619
+ const active = [...this.activeSlashCommandListings]
8620
+ .filter((listing) =>
8621
+ (storageKey === undefined || listing.storageKey === storageKey)
8622
+ && (sessionId === undefined || listing.sessionId === sessionId));
8623
+ if (active.length === 0) return undefined;
8624
+ for (const listing of active) {
8625
+ if (!listing.controller.signal.aborted) listing.controller.abort(reason);
8626
+ }
8627
+ return Promise.allSettled(active.map((listing) => listing.completion)).then(() => undefined);
8628
+ }
8629
+
5661
8630
  private appendUnexpectedErrors(target: unknown[], error: unknown): void {
5662
8631
  if (error instanceof FlexHarnessRunError) {
5663
8632
  for (const nested of error.errors) this.appendUnexpectedErrors(target, nested);
@@ -5722,6 +8691,23 @@ export class FlexHarness<TScope = unknown> {
5722
8691
  }
5723
8692
  const state = await stateLoad;
5724
8693
  this.assertOpen();
8694
+ if (state.lifecycle === 'fenced') {
8695
+ const drain = this.storageDrains.get(scope.storageKey) ?? this.drainStorage(
8696
+ scope.storageKey,
8697
+ stateLoad,
8698
+ this.trustInternalError(new FlexHarnessAbortError('The session namespace was fenced.')),
8699
+ { scopeId, scope: scope.scope },
8700
+ );
8701
+ if (this.ambientSlashCommandOwner({ storageKey: scope.storageKey })) {
8702
+ throw this.createScopeRetirementError();
8703
+ }
8704
+ try {
8705
+ await drain;
8706
+ } catch {
8707
+ // Fenced cleanup ownership remains available to explicit retirement or disposal.
8708
+ }
8709
+ throw this.createScopeRetirementError();
8710
+ }
5725
8711
  if (
5726
8712
  admission.retiring
5727
8713
  || admission.generation !== generation
@@ -5756,6 +8742,29 @@ export class FlexHarness<TScope = unknown> {
5756
8742
  return this.trustInternalError(new FlexHarnessAbortError(scopeRetirementMessage));
5757
8743
  }
5758
8744
 
8745
+ private ambientSlashCommandOwner(
8746
+ criteria: { scopeId?: string; storageKey?: string; sessionId?: string } = {},
8747
+ ): ISlashCommandInvocationOwner | ISlashCommandActivityOwner | undefined {
8748
+ const matches = (owner: ISlashCommandInvocationOwner | ISlashCommandActivityOwner) => (
8749
+ (criteria.scopeId === undefined || owner.scopeId === criteria.scopeId)
8750
+ && (criteria.storageKey === undefined || owner.storageKey === criteria.storageKey)
8751
+ && (criteria.sessionId === undefined || owner.sessionId === criteria.sessionId)
8752
+ );
8753
+ const invocationOwner = this.slashCommandInvocationContext.getStore();
8754
+ if (invocationOwner && matches(invocationOwner)) return invocationOwner;
8755
+ const activityOwner = this.slashCommandActivityContext.getStore();
8756
+ return activityOwner && matches(activityOwner) ? activityOwner : undefined;
8757
+ }
8758
+
8759
+ private assertNoAmbientSlashCommandTeardown(
8760
+ criteria: { scopeId?: string; storageKey?: string; sessionId?: string } = {},
8761
+ ): void {
8762
+ const owner = this.ambientSlashCommandOwner(criteria);
8763
+ if (owner) {
8764
+ throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(owner.sessionId));
8765
+ }
8766
+ }
8767
+
5759
8768
  private assertStateAcceptingWork(state: IStorageState): void {
5760
8769
  if (state.lifecycle !== 'active' || this.storageDrains.has(state.storageKey)) {
5761
8770
  throw this.createScopeRetirementError();
@@ -5805,6 +8814,17 @@ export class FlexHarness<TScope = unknown> {
5805
8814
  return stored;
5806
8815
  }
5807
8816
 
8817
+ private completedReversionCandidates(stored: IStoredSessionState): IFlexReversionSegment[] {
8818
+ return stored.reversionSegments.filter((segment) => segment.status === 'completed');
8819
+ }
8820
+
8821
+ private visibleMessages(stored: IStoredSessionState): IFlexMessage[] {
8822
+ const firstHiddenSegment = this.reversionGroups(stored)[stored.revertCursor]?.segments[0];
8823
+ if (!firstHiddenSegment) return stored.messages;
8824
+ const boundary = stored.messages.findIndex((message) => message.runId === firstHiddenSegment.runId);
8825
+ return boundary < 0 ? stored.messages : stored.messages.slice(0, boundary);
8826
+ }
8827
+
5808
8828
  private requireMutableSession(state: IStorageState, sessionId: string): IStoredSessionState {
5809
8829
  const stored = this.requireSession(state, sessionId);
5810
8830
  const pending = [...state.pendingPermissions.values()].some((entry) =>
@@ -5819,7 +8839,7 @@ export class FlexHarness<TScope = unknown> {
5819
8839
  }
5820
8840
 
5821
8841
  private requireMessage(stored: IStoredSessionState, messageId: string): IFlexMessage {
5822
- const message = stored.messages.find((entry) => entry.messageId === messageId);
8842
+ const message = this.visibleMessages(stored).find((entry) => entry.messageId === messageId);
5823
8843
  if (!message) throw new FlexHarnessNotFoundError('Message', messageId);
5824
8844
  return message;
5825
8845
  }