@modelprofile.com/flexharness 3.5.0 → 3.7.0

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