@modelprofile.com/flexharness 3.7.0 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,13 +18,18 @@ import {
18
18
  errorToInfo,
19
19
  } from './errors.js';
20
20
  import {
21
+ FLEX_REVERSION_MAX_AFFECTED_WORKSPACES,
21
22
  FLEX_REVERSION_DEFAULT_LIMITS,
22
23
  FLEX_REVERSION_MAXIMUM_LIMITS,
24
+ FLEX_REVERSION_REASON_CODE_MAX_BYTES,
23
25
  FLEX_REVERSION_REFERENCE_MAX_BYTES,
26
+ FLEX_REVERSION_WORKSPACE_ID_MAX_BYTES,
27
+ FLEX_REVERSION_WORKSPACE_LABEL_MAX_BYTES,
24
28
  } from './interfaces.js';
25
29
  import type {
26
30
  IFlexAgentContextInvocation,
27
31
  IFlexAgentSessionPolicy,
32
+ IFlexAffectedWorkspace,
28
33
  IFlexAttachmentMessagePart,
29
34
  IFlexBackgroundExecution,
30
35
  IFlexCallbackLimits,
@@ -61,6 +66,8 @@ import type {
61
66
  IFlexSchedulePromptOptions,
62
67
  IFlexScopeSnapshot,
63
68
  IFlexSession,
69
+ IFlexSessionReversionGroup,
70
+ IFlexSessionReversionInfo,
64
71
  IFlexSessionTombstone,
65
72
  IFlexSlashCommandDescriptor,
66
73
  IFlexSlashCommandExecutionOptions,
@@ -93,6 +100,9 @@ import type {
93
100
  TFlexSlashCommandRegistration,
94
101
  TFlexPendingReversion,
95
102
  TFlexProjectionSnapshot,
103
+ TFlexReversionPolicy,
104
+ TFlexSessionReversionGroupKind,
105
+ TFlexTurnReversionFinalizedOutcome,
96
106
  TFlexToolExecutionReconciliation,
97
107
  TJsonValue,
98
108
  } from './interfaces.js';
@@ -138,7 +148,7 @@ interface IStoredSessionState {
138
148
  excludedRunIds: string[];
139
149
  pendingReversion?: TFlexPendingReversion;
140
150
  pendingReversionReleases: IFlexPendingReversionRelease[];
141
- projectionSchemaVersion: 1 | 2;
151
+ projectionSchemaVersion: 1 | 2 | 3;
142
152
  projectionBaseline?: TFlexProjectionSnapshot;
143
153
  projectionReconciliationRequired: boolean;
144
154
  projectionRevision: number;
@@ -168,6 +178,18 @@ interface IRetainedSessionCleanup {
168
178
  domainsCompleted: boolean;
169
179
  }
170
180
 
181
+ interface IReversionGroup {
182
+ target: IFlexReversionSegment;
183
+ segments: IFlexReversionSegment[];
184
+ kind: TFlexSessionReversionGroupKind;
185
+ affectedWorkspaces: IFlexAffectedWorkspace[];
186
+ affectedWorkspacesTruncated: boolean;
187
+ }
188
+
189
+ type TNormalizedCaptureInspection =
190
+ | { status: 'missing' | 'prepared' | 'unknown' }
191
+ | { status: 'finalized'; outcome: TFlexTurnReversionFinalizedOutcome };
192
+
171
193
  interface IDetachedCleanup {
172
194
  cleanup: () => Promise<void> | void;
173
195
  projectError: (error: unknown) => Error;
@@ -332,6 +354,12 @@ interface ISlashCommandInvocationOwner {
332
354
  sessionKey: string;
333
355
  }
334
356
 
357
+ interface ISlashCommandActivityOwner {
358
+ scopeId: string;
359
+ storageKey: string;
360
+ sessionId: string;
361
+ }
362
+
335
363
  interface IRunResultProjection {
336
364
  text: string;
337
365
  steps: number;
@@ -1113,6 +1141,7 @@ export class FlexHarness<TScope = unknown> {
1113
1141
  private readonly promptQueueLimits: Required<IFlexPromptQueueLimits>;
1114
1142
  private readonly reversionLimits: Required<IFlexReversionLimits>;
1115
1143
  private readonly turnReversionProvider: IFlexHarnessOptions<TScope>['turnReversionProvider'];
1144
+ private readonly reversionPolicy: TFlexReversionPolicy;
1116
1145
  private readonly externalErrorProjector?: TFlexExternalErrorProjector;
1117
1146
  private readonly subagents: ReadonlyMap<string, Readonly<IFlexSubagentDefinition>>;
1118
1147
  private readonly slashCommands: ReadonlyMap<string, TRegisteredSlashCommand<TScope>>;
@@ -1143,6 +1172,9 @@ export class FlexHarness<TScope = unknown> {
1143
1172
  private readonly slashCommandInvocationContext = new plugins.AsyncLocalStorage<
1144
1173
  ISlashCommandInvocationOwner
1145
1174
  >();
1175
+ private readonly slashCommandActivityContext = new plugins.AsyncLocalStorage<
1176
+ ISlashCommandActivityOwner
1177
+ >();
1146
1178
  private readonly deferredCompactorContexts = new WeakMap<
1147
1179
  IFlexAgentContextInvocation<unknown>,
1148
1180
  IFlexAgentContextInvocation<unknown>
@@ -1170,6 +1202,24 @@ export class FlexHarness<TScope = unknown> {
1170
1202
  this.promptQueueLimits = resolvePromptQueueLimits(options.promptQueueLimits);
1171
1203
  this.reversionLimits = resolveReversionLimits(options.reversionLimits);
1172
1204
  this.turnReversionProvider = options.turnReversionProvider;
1205
+ this.reversionPolicy = options.reversionPolicy ?? 'transcript-optional';
1206
+ if (!['transcript-optional', 'workspace-required'].includes(this.reversionPolicy)) {
1207
+ throw new FlexHarnessValidationError(
1208
+ 'reversionPolicy must be "transcript-optional" or "workspace-required".',
1209
+ );
1210
+ }
1211
+ const protocolVersion = this.turnReversionProvider
1212
+ && 'protocolVersion' in this.turnReversionProvider
1213
+ ? this.turnReversionProvider.protocolVersion
1214
+ : 1;
1215
+ if (protocolVersion !== 1 && protocolVersion !== 2) {
1216
+ throw new FlexHarnessValidationError('Turn reversion provider protocolVersion is invalid.');
1217
+ }
1218
+ if (this.reversionPolicy === 'workspace-required' && protocolVersion !== 2) {
1219
+ throw new FlexHarnessValidationError(
1220
+ 'workspace-required reversion policy requires a protocolVersion 2 turn reversion provider.',
1221
+ );
1222
+ }
1173
1223
  this.externalErrorProjector = options.externalErrorProjector;
1174
1224
  this.subagents = normalizeSubagents(options.subagents);
1175
1225
  this.slashCommands = normalizeSlashCommands(options.slashCommands);
@@ -1357,6 +1407,41 @@ export class FlexHarness<TScope = unknown> {
1357
1407
  return publicSnapshot(this.requireSession(state, sessionId).session);
1358
1408
  }
1359
1409
 
1410
+ public async getSessionReversionInfo(
1411
+ scopeId: string,
1412
+ sessionId: string,
1413
+ ): Promise<IFlexSessionReversionInfo> {
1414
+ const { state } = await this.resolveState(scopeId);
1415
+ const stored = this.requireSession(state, sessionId);
1416
+ await stored.projectionQueue;
1417
+ this.assertStateAcceptingWork(state);
1418
+ await this.reconcileContextAvailability(state, stored);
1419
+ const unavailableReason = await this.reversionUnavailableReason(state, stored);
1420
+ const groups = this.reversionGroups(stored);
1421
+ const undo = this.reversionUnit(stored, 'undo');
1422
+ const redo = this.reversionUnit(stored, 'redo');
1423
+ const isAvailable = (unit: ReturnType<typeof this.reversionUnit>) => Boolean(
1424
+ !unavailableReason
1425
+ && unit
1426
+ && unit.target.contextAvailable
1427
+ && !unit.segments.some((segment) =>
1428
+ segment.provenance === 'workspace'
1429
+ && segment.disposition === 'revertible'
1430
+ && segment.workspaceReference === undefined),
1431
+ );
1432
+ return publicSnapshot({
1433
+ undoAvailable: isAvailable(undo),
1434
+ redoAvailable: isAvailable(redo),
1435
+ groups: groups.map((group, index): IFlexSessionReversionGroup => ({
1436
+ runId: group.target.runId,
1437
+ kind: group.kind,
1438
+ visibility: index < stored.revertCursor ? 'visible' : 'hidden',
1439
+ affectedWorkspaces: cloneSerializable(group.affectedWorkspaces),
1440
+ affectedWorkspacesTruncated: group.affectedWorkspacesTruncated,
1441
+ })),
1442
+ });
1443
+ }
1444
+
1360
1445
  public async updateSession(
1361
1446
  scopeId: string,
1362
1447
  sessionId: string,
@@ -1384,14 +1469,9 @@ export class FlexHarness<TScope = unknown> {
1384
1469
  }
1385
1470
 
1386
1471
  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
- }
1472
+ this.assertNoAmbientSlashCommandTeardown({ scopeId, sessionId });
1391
1473
  const { scope, state } = await this.resolveState(scopeId);
1392
- if (invocationOwner?.storageKey === state.storageKey) {
1393
- throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(invocationOwner.sessionId));
1394
- }
1474
+ this.assertNoAmbientSlashCommandTeardown({ storageKey: state.storageKey });
1395
1475
  validateIdentifier(sessionId, 'sessionId');
1396
1476
  await state.scopeQueue;
1397
1477
  const deletionKey = state.tombstones.get(sessionId)?.rootSessionId ?? sessionId;
@@ -1606,7 +1686,7 @@ export class FlexHarness<TScope = unknown> {
1606
1686
  sessionId: string,
1607
1687
  ): Promise<IFlexSlashCommandDescriptor[]> {
1608
1688
  const { state } = await this.resolveState(scopeId);
1609
- return this.trackSlashCommandListing(state, sessionId, async (signal) => {
1689
+ return this.trackSlashCommandListing(scopeId, state, sessionId, async (signal) => {
1610
1690
  await this.awaitReversionOperation(state.scopeQueue, signal);
1611
1691
  signal.throwIfAborted();
1612
1692
  this.assertStateAcceptingWork(state);
@@ -1644,7 +1724,7 @@ export class FlexHarness<TScope = unknown> {
1644
1724
  const workspaceReversion = (unit: ReturnType<typeof this.reversionUnit>) =>
1645
1725
  !unit
1646
1726
  || !this.turnReversionProvider
1647
- || unit.segments.some((segment) => !segment.workspaceCaptured)
1727
+ || unit.segments.some((segment) => segment.provenance !== 'workspace')
1648
1728
  ? 'unsupported' as const
1649
1729
  : 'supported' as const;
1650
1730
  const reversionAvailability = (unit: ReturnType<typeof this.reversionUnit>) => {
@@ -1658,7 +1738,7 @@ export class FlexHarness<TScope = unknown> {
1658
1738
  unavailableReason: 'The turn is beyond the context archive horizon.',
1659
1739
  };
1660
1740
  if (unit.segments.some((segment) =>
1661
- segment.workspaceCaptured && segment.workspaceReference === undefined)) return {
1741
+ segment.disposition === 'revertible' && segment.workspaceReference === undefined)) return {
1662
1742
  available: false,
1663
1743
  unavailableReason: 'The turn has no available workspace reversion capture.',
1664
1744
  };
@@ -1802,6 +1882,7 @@ export class FlexHarness<TScope = unknown> {
1802
1882
  }
1803
1883
  this.requireSessionAvailableForSlashCommand(state, stored, parsed.name);
1804
1884
  return this.trackSlashCommandExecution(
1885
+ scopeId,
1805
1886
  state,
1806
1887
  sessionId,
1807
1888
  'operation',
@@ -1829,6 +1910,7 @@ export class FlexHarness<TScope = unknown> {
1829
1910
  this.promptAdmissionByteSize(expanded, promptOptions),
1830
1911
  );
1831
1912
  return this.trackSlashCommandExecution(
1913
+ scopeId,
1832
1914
  state,
1833
1915
  sessionId,
1834
1916
  'prompt-admission',
@@ -2001,6 +2083,7 @@ export class FlexHarness<TScope = unknown> {
2001
2083
  }
2002
2084
 
2003
2085
  private trackSlashCommandExecution<TResult>(
2086
+ scopeId: string,
2004
2087
  state: IStorageState,
2005
2088
  sessionId: string,
2006
2089
  kind: IActiveSlashCommandExecution['kind'],
@@ -2024,10 +2107,11 @@ export class FlexHarness<TScope = unknown> {
2024
2107
  };
2025
2108
  externalSignal?.addEventListener('abort', abortFromExternalSignal, { once: true });
2026
2109
  if (externalSignal?.aborted) abortFromExternalSignal();
2027
- const completion = Promise.resolve().then(() => {
2110
+ const activityOwner = Object.freeze({ scopeId, storageKey: state.storageKey, sessionId });
2111
+ const completion = Promise.resolve().then(() => this.slashCommandActivityContext.run(activityOwner, () => {
2028
2112
  if (controller.signal.aborted) throw controller.signal.reason;
2029
2113
  return operation(controller.signal);
2030
- });
2114
+ }));
2031
2115
  const active: IActiveSlashCommandExecution = {
2032
2116
  kind,
2033
2117
  storageKey: state.storageKey,
@@ -2045,12 +2129,15 @@ export class FlexHarness<TScope = unknown> {
2045
2129
  }
2046
2130
 
2047
2131
  private trackSlashCommandListing<TResult>(
2132
+ scopeId: string,
2048
2133
  state: IStorageState,
2049
2134
  sessionId: string,
2050
2135
  operation: (signal: AbortSignal) => Promise<TResult>,
2051
2136
  ): Promise<TResult> {
2052
2137
  const controller = new AbortController();
2053
- const completion = Promise.resolve().then(() => operation(controller.signal));
2138
+ const activityOwner = Object.freeze({ scopeId, storageKey: state.storageKey, sessionId });
2139
+ const completion = Promise.resolve().then(() =>
2140
+ this.slashCommandActivityContext.run(activityOwner, () => operation(controller.signal)));
2054
2141
  const active: IActiveSlashCommandListing = {
2055
2142
  storageKey: state.storageKey,
2056
2143
  sessionId,
@@ -2094,6 +2181,7 @@ export class FlexHarness<TScope = unknown> {
2094
2181
  sessionKey,
2095
2182
  });
2096
2183
  return this.trackSlashCommandExecution(
2184
+ scopeId,
2097
2185
  state,
2098
2186
  stored.session.sessionId,
2099
2187
  'handler',
@@ -2403,6 +2491,7 @@ export class FlexHarness<TScope = unknown> {
2403
2491
  }
2404
2492
  this.requireSessionAvailableForSlashCommand(state, stored, 'compact');
2405
2493
  await this.trackSlashCommandExecution(
2494
+ scopeId,
2406
2495
  state,
2407
2496
  sessionId,
2408
2497
  'operation',
@@ -2464,24 +2553,77 @@ export class FlexHarness<TScope = unknown> {
2464
2553
  stored: IStoredSessionState,
2465
2554
  direction: 'undo' | 'redo',
2466
2555
  ): { 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;
2556
+ const groups = this.reversionGroups(stored);
2557
+ if (direction === 'undo') {
2558
+ let start = stored.revertCursor - 1;
2559
+ while (start >= 0 && groups[start]?.kind === 'no-change') start--;
2560
+ const candidate = groups[start];
2561
+ if (!candidate || candidate.kind !== 'candidate') return undefined;
2562
+ return {
2563
+ target: candidate.target,
2564
+ segments: groups.slice(start, stored.revertCursor).flatMap((group) => group.segments),
2565
+ toCursor: start,
2566
+ };
2567
+ }
2568
+ const candidate = groups[stored.revertCursor];
2569
+ if (!candidate || candidate.kind !== 'candidate') return undefined;
2570
+ let end = stored.revertCursor + 1;
2571
+ while (groups[end]?.kind === 'no-change') end++;
2478
2572
  return {
2479
- target: candidate,
2480
- segments: stored.reversionSegments.slice(start, end),
2481
- toCursor: stored.revertCursor + (direction === 'undo' ? -1 : 1),
2573
+ target: candidate.target,
2574
+ segments: groups.slice(stored.revertCursor, end).flatMap((group) => group.segments),
2575
+ toCursor: end,
2482
2576
  };
2483
2577
  }
2484
2578
 
2579
+ private reversionGroups(stored: IStoredSessionState): IReversionGroup[] {
2580
+ const groups: IReversionGroup[] = [];
2581
+ let leading: IFlexReversionSegment[] = [];
2582
+ for (const segment of stored.reversionSegments) {
2583
+ if (segment.status === 'completed') {
2584
+ const segments = groups.length === 0 ? [...leading, segment] : [segment];
2585
+ leading = [];
2586
+ groups.push({
2587
+ target: segment,
2588
+ segments,
2589
+ kind: 'candidate',
2590
+ affectedWorkspaces: [],
2591
+ affectedWorkspacesTruncated: false,
2592
+ });
2593
+ } else if (groups.length > 0) {
2594
+ groups[groups.length - 1].segments.push(segment);
2595
+ } else {
2596
+ leading.push(segment);
2597
+ }
2598
+ }
2599
+ for (const group of groups) {
2600
+ const affected = new Map<string, IFlexAffectedWorkspace>();
2601
+ for (const workspace of group.segments.flatMap((segment) =>
2602
+ segment.affectedWorkspaces ?? [])) {
2603
+ if (affected.has(workspace.id)) continue;
2604
+ if (affected.size < FLEX_REVERSION_MAX_AFFECTED_WORKSPACES) {
2605
+ affected.set(workspace.id, cloneSerializable(workspace));
2606
+ } else {
2607
+ group.affectedWorkspacesTruncated = true;
2608
+ }
2609
+ }
2610
+ group.affectedWorkspaces = [...affected.values()];
2611
+ if (this.reversionPolicy === 'transcript-optional') continue;
2612
+ if (group.segments.some((segment) =>
2613
+ segment.status === 'capturing'
2614
+ || segment.disposition === 'pending'
2615
+ || (segment.protocolVersion === 1 && segment.provenance === 'transcript')
2616
+ || segment.disposition === 'nonrevertible')) {
2617
+ group.kind = 'barrier';
2618
+ } else if (group.segments.some((segment) => segment.disposition === 'revertible')) {
2619
+ group.kind = 'candidate';
2620
+ } else {
2621
+ group.kind = 'no-change';
2622
+ }
2623
+ }
2624
+ return groups;
2625
+ }
2626
+
2485
2627
  private async requireReversionIdle(
2486
2628
  state: IStorageState,
2487
2629
  stored: IStoredSessionState,
@@ -2587,6 +2729,7 @@ export class FlexHarness<TScope = unknown> {
2587
2729
  throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(sessionId));
2588
2730
  }
2589
2731
  return this.trackSlashCommandExecution(
2732
+ scopeId,
2590
2733
  state,
2591
2734
  sessionId,
2592
2735
  'operation',
@@ -2609,8 +2752,9 @@ export class FlexHarness<TScope = unknown> {
2609
2752
  if (pending.direction !== direction) {
2610
2753
  throw new FlexHarnessSessionBusyError(sessionId, 'has a pending opposite reversion operation');
2611
2754
  }
2612
- const candidates = this.completedReversionCandidates(stored);
2613
- const target = candidates[direction === 'undo' ? pending.fromCursor - 1 : pending.fromCursor];
2755
+ const target = pending.segmentRunIds
2756
+ .map((runId) => stored.reversionSegments.find((segment) => segment.runId === runId))
2757
+ .find((segment) => segment?.status === 'completed');
2614
2758
  if (!target) throw new FlexHarnessValidationError('Pending reversion has no target candidate.');
2615
2759
  await this.resumePendingApply(state, stored, scopeId, scope, operationSignal);
2616
2760
  this.emitEvent(scopeId, sessionId, {
@@ -2634,7 +2778,7 @@ export class FlexHarness<TScope = unknown> {
2634
2778
  );
2635
2779
  }
2636
2780
  if (this.turnReversionProvider && unit.segments.some((segment) =>
2637
- segment.captureId !== undefined && segment.workspaceReference === undefined)) {
2781
+ segment.disposition === 'revertible' && segment.workspaceReference === undefined)) {
2638
2782
  throw new FlexHarnessSlashCommandUnavailableError(
2639
2783
  direction,
2640
2784
  'The turn has no available workspace reversion capture.',
@@ -2713,7 +2857,8 @@ export class FlexHarness<TScope = unknown> {
2713
2857
  if (pending?.kind !== 'apply') return;
2714
2858
  const segmentMap = new Map(stored.reversionSegments.map((segment) => [segment.runId, segment]));
2715
2859
  if (
2716
- pending.segmentRunIds.some((runId) => segmentMap.get(runId)?.workspaceCaptured)
2860
+ pending.segmentRunIds.some((runId) =>
2861
+ segmentMap.get(runId)?.disposition === 'revertible')
2717
2862
  && !this.turnReversionProvider
2718
2863
  ) {
2719
2864
  throw new FlexHarnessValidationError(
@@ -2723,18 +2868,25 @@ export class FlexHarness<TScope = unknown> {
2723
2868
  const orderedRunIds = pending.direction === 'undo'
2724
2869
  ? [...pending.segmentRunIds].reverse()
2725
2870
  : [...pending.segmentRunIds];
2871
+ let workspaceProgress = pending.appliedRunIds.some((runId) =>
2872
+ segmentMap.get(runId)?.disposition === 'revertible');
2726
2873
  for (const runId of orderedRunIds) {
2727
2874
  if (pending.appliedRunIds.includes(runId)) continue;
2728
- if (signal?.aborted) {
2875
+ if (signal?.aborted && !workspaceProgress) {
2729
2876
  await this.persistKnownNotApplied(state, stored, pending.operationId, runId);
2730
2877
  throw signal.reason;
2731
2878
  }
2732
2879
  const segment = segmentMap.get(runId);
2733
2880
  if (!segment) throw new FlexHarnessValidationError('Pending reversion references a missing segment.');
2734
- if (segment.workspaceCaptured) {
2881
+ if (segment.disposition === 'revertible') {
2735
2882
  if (!this.turnReversionProvider || !segment.captureId || segment.workspaceReference === undefined) {
2736
2883
  throw new FlexHarnessValidationError('Pending workspace reversion segment is incomplete.');
2737
2884
  }
2885
+ if (this.reversionProviderProtocolVersion() !== segment.protocolVersion) {
2886
+ throw new FlexHarnessValidationError(
2887
+ `Pending workspace reversion requires protocolVersion ${segment.protocolVersion}.`,
2888
+ );
2889
+ }
2738
2890
  const captureId = segment.captureId;
2739
2891
  const reference = segment.workspaceReference;
2740
2892
  const childOperationId = `${pending.operationId}:${sha256Hex(runId).slice(0, 16)}`;
@@ -2770,7 +2922,7 @@ export class FlexHarness<TScope = unknown> {
2770
2922
  }
2771
2923
  if (inspection.status === 'not-applied') {
2772
2924
  try {
2773
- if (signal) {
2925
+ if (signal && !workspaceProgress) {
2774
2926
  await this.turnReversionProvider.apply(createContext(signal));
2775
2927
  } else {
2776
2928
  await this.withReversionMaintenanceSignal((maintenanceSignal) =>
@@ -2811,6 +2963,7 @@ export class FlexHarness<TScope = unknown> {
2811
2963
  }
2812
2964
  if (!current.appliedRunIds.includes(runId)) current.appliedRunIds.push(runId);
2813
2965
  }, true);
2966
+ if (segment.disposition === 'revertible') workspaceProgress = true;
2814
2967
  }
2815
2968
  await this.mutateProjection(state, stored, () => {
2816
2969
  const current = stored.pendingReversion;
@@ -2834,7 +2987,10 @@ export class FlexHarness<TScope = unknown> {
2834
2987
  throw new FlexHarnessValidationError('Pending reversion changed while recording failed apply.');
2835
2988
  }
2836
2989
  current.appliedRunIds = current.appliedRunIds.filter((entry) => entry !== runId);
2837
- if (current.appliedRunIds.length === 0) {
2990
+ const hasWorkspaceProgress = current.appliedRunIds.some((appliedRunId) =>
2991
+ stored.reversionSegments.some((segment) =>
2992
+ segment.runId === appliedRunId && segment.disposition === 'revertible'));
2993
+ if (!hasWorkspaceProgress) {
2838
2994
  stored.revertCursor = current.fromCursor;
2839
2995
  delete stored.pendingReversion;
2840
2996
  }
@@ -2852,17 +3008,31 @@ export class FlexHarness<TScope = unknown> {
2852
3008
  if (pending?.kind !== 'capture' || !this.turnReversionProvider) return;
2853
3009
  const segment = stored.reversionSegments.find((entry) => entry.runId === pending.runId);
2854
3010
  if (!segment) throw new FlexHarnessValidationError('Pending capture references a missing segment.');
3011
+ if (this.reversionProviderProtocolVersion() !== pending.protocolVersion) {
3012
+ throw new FlexHarnessValidationError(
3013
+ `Pending capture recovery requires protocolVersion ${pending.protocolVersion}.`,
3014
+ );
3015
+ }
2855
3016
  const outcome = outcomes.get(pending.runId);
2856
- const reference = await this.resolveReversionCaptureReference(
3017
+ if (pending.state === 'prepared') {
3018
+ await this.mutateProjection(state, stored, () => {
3019
+ const current = stored.pendingReversion;
3020
+ if (current?.kind !== 'capture' || current.captureId !== pending.captureId) {
3021
+ throw new FlexHarnessValidationError('Pending capture changed before finalization.');
3022
+ }
3023
+ current.state = 'finalizing';
3024
+ }, true);
3025
+ }
3026
+ const finalizedOutcome = await this.resolveReversionCaptureOutcome(
2857
3027
  state,
2858
3028
  scopeId,
2859
3029
  scope,
2860
3030
  stored.session.sessionId,
2861
3031
  pending.runId,
2862
3032
  pending.captureId,
2863
- pending.state,
3033
+ pending.state === 'prepared' ? 'finalizing' : pending.state,
2864
3034
  );
2865
- if (reference === undefined) {
3035
+ if (finalizedOutcome === undefined) {
2866
3036
  await this.mutateProjection(state, stored, () => {
2867
3037
  stored.reversionSegments = stored.reversionSegments.filter(
2868
3038
  (entry) => entry.runId !== pending.runId,
@@ -2877,7 +3047,7 @@ export class FlexHarness<TScope = unknown> {
2877
3047
  : outcome === 'rejected'
2878
3048
  ? 'failed'
2879
3049
  : 'cancelled';
2880
- segment.workspaceReference = cloneSerializable(reference);
3050
+ this.applyFinalizedReversionOutcome(stored, segment, finalizedOutcome);
2881
3051
  segment.eventIds = stored.agentSession.getEvents()
2882
3052
  .filter((event) => event.generationId === pending.runId)
2883
3053
  .map((event) => event.id);
@@ -3017,10 +3187,7 @@ export class FlexHarness<TScope = unknown> {
3017
3187
  }
3018
3188
 
3019
3189
  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
- }
3190
+ this.assertNoAmbientSlashCommandTeardown({ scopeId });
3024
3191
  this.assertOpen();
3025
3192
  validateIdentifier(scopeId, 'scopeId');
3026
3193
  const existing = this.scopeRetirements.get(scopeId);
@@ -3039,13 +3206,8 @@ export class FlexHarness<TScope = unknown> {
3039
3206
  }
3040
3207
 
3041
3208
  public async dispose(): Promise<void> {
3209
+ this.assertNoAmbientSlashCommandTeardown();
3042
3210
  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
- }
3049
3211
  this.closed = true;
3050
3212
  let disposal!: Promise<void>;
3051
3213
  disposal = this.disposeInternal().catch((error) => {
@@ -3339,6 +3501,7 @@ export class FlexHarness<TScope = unknown> {
3339
3501
  const captureId = this.turnReversionProvider
3340
3502
  ? this.reversionId('capture', run.state.storageKey, run.sessionId, run.runId)
3341
3503
  : undefined;
3504
+ const protocolVersion = this.reversionProviderProtocolVersion() ?? 1;
3342
3505
  run.stored.reversionSegments.push({
3343
3506
  runId: run.runId,
3344
3507
  userMessageId: reservation.userMessage.messageId,
@@ -3346,6 +3509,9 @@ export class FlexHarness<TScope = unknown> {
3346
3509
  contextAvailable: true,
3347
3510
  eventIds: [],
3348
3511
  workspaceCaptured: captureId !== undefined,
3512
+ protocolVersion,
3513
+ provenance: captureId === undefined ? 'transcript' : 'workspace',
3514
+ ...(captureId === undefined ? {} : { disposition: 'pending' }),
3349
3515
  ...(captureId === undefined ? {} : { captureId }),
3350
3516
  });
3351
3517
  if (captureId) {
@@ -3354,6 +3520,7 @@ export class FlexHarness<TScope = unknown> {
3354
3520
  runId: run.runId,
3355
3521
  captureId,
3356
3522
  state: 'preparing',
3523
+ protocolVersion,
3357
3524
  };
3358
3525
  }
3359
3526
  }
@@ -4445,6 +4612,11 @@ export class FlexHarness<TScope = unknown> {
4445
4612
  return `${kind}_${sha256Hex(JSON.stringify([storageKey, sessionId, value]))}`;
4446
4613
  }
4447
4614
 
4615
+ private reversionProviderProtocolVersion(): 1 | 2 | undefined {
4616
+ if (!this.turnReversionProvider) return undefined;
4617
+ return 'protocolVersion' in this.turnReversionProvider ? 2 : 1;
4618
+ }
4619
+
4448
4620
  private reversionCaptureContext(
4449
4621
  run: IActiveRun,
4450
4622
  captureId: string,
@@ -4491,9 +4663,10 @@ export class FlexHarness<TScope = unknown> {
4491
4663
  sessionId: string,
4492
4664
  runId: string,
4493
4665
  captureId: string,
4494
- ) {
4495
- return this.withReversionMaintenanceSignal((signal) =>
4496
- this.turnReversionProvider!.inspectCapture(Object.freeze({
4666
+ ): Promise<TNormalizedCaptureInspection> {
4667
+ return this.withReversionMaintenanceSignal(async (signal) => {
4668
+ const provider = this.turnReversionProvider!;
4669
+ const inspection = await provider.inspectCapture(Object.freeze({
4497
4670
  scopeId,
4498
4671
  scope,
4499
4672
  storageKey,
@@ -4501,7 +4674,29 @@ export class FlexHarness<TScope = unknown> {
4501
4674
  runId,
4502
4675
  captureId,
4503
4676
  signal,
4504
- })));
4677
+ }));
4678
+ if (inspection.status !== 'finalized') return { status: inspection.status };
4679
+ if (this.reversionProviderProtocolVersion() === 2) {
4680
+ if (!('outcome' in inspection)) {
4681
+ throw new FlexHarnessValidationError('Protocol-2 finalized inspection has no outcome.');
4682
+ }
4683
+ return {
4684
+ status: 'finalized',
4685
+ outcome: this.normalizeReversionOutcome(inspection.outcome),
4686
+ };
4687
+ }
4688
+ if (!('reference' in inspection) || inspection.reference === undefined) {
4689
+ throw new FlexHarnessValidationError('Protocol-1 finalized inspection has no reference.');
4690
+ }
4691
+ return {
4692
+ status: 'finalized',
4693
+ outcome: {
4694
+ disposition: 'revertible',
4695
+ reference: this.normalizeReversionReference(inspection.reference),
4696
+ affectedWorkspaces: [],
4697
+ },
4698
+ };
4699
+ });
4505
4700
  }
4506
4701
 
4507
4702
  private finalizeReversionCapture(
@@ -4511,9 +4706,9 @@ export class FlexHarness<TScope = unknown> {
4511
4706
  sessionId: string,
4512
4707
  runId: string,
4513
4708
  captureId: string,
4514
- ) {
4515
- return this.withReversionMaintenanceSignal((signal) =>
4516
- this.turnReversionProvider!.finalize(Object.freeze({
4709
+ ): Promise<TFlexTurnReversionFinalizedOutcome> {
4710
+ return this.withReversionMaintenanceSignal(async (signal) => {
4711
+ const result = await this.turnReversionProvider!.finalize(Object.freeze({
4517
4712
  scopeId,
4518
4713
  scope,
4519
4714
  storageKey,
@@ -4521,10 +4716,18 @@ export class FlexHarness<TScope = unknown> {
4521
4716
  runId,
4522
4717
  captureId,
4523
4718
  signal,
4524
- })));
4719
+ }));
4720
+ return this.reversionProviderProtocolVersion() === 2
4721
+ ? this.normalizeReversionOutcome(result)
4722
+ : {
4723
+ disposition: 'revertible',
4724
+ reference: this.normalizeReversionReference(result),
4725
+ affectedWorkspaces: [],
4726
+ };
4727
+ });
4525
4728
  }
4526
4729
 
4527
- private async resolveReversionCaptureReference(
4730
+ private async resolveReversionCaptureOutcome(
4528
4731
  state: IStorageState,
4529
4732
  scopeId: string,
4530
4733
  scope: TScope,
@@ -4532,7 +4735,7 @@ export class FlexHarness<TScope = unknown> {
4532
4735
  runId: string,
4533
4736
  captureId: string,
4534
4737
  durableState: 'preparing' | 'prepared' | 'finalizing',
4535
- ): Promise<TJsonValue | undefined> {
4738
+ ): Promise<TFlexTurnReversionFinalizedOutcome | undefined> {
4536
4739
  let inspection;
4537
4740
  try {
4538
4741
  inspection = await this.inspectReversionCapture(
@@ -4556,10 +4759,10 @@ export class FlexHarness<TScope = unknown> {
4556
4759
  state.lifecycle = 'fenced';
4557
4760
  throw new FlexHarnessValidationError('Prepared reversion capture is missing.');
4558
4761
  }
4559
- let reference = inspection.reference;
4762
+ let outcome = inspection.status === 'finalized' ? inspection.outcome : undefined;
4560
4763
  if (inspection.status === 'prepared') {
4561
4764
  try {
4562
- reference = await this.finalizeReversionCapture(
4765
+ outcome = await this.finalizeReversionCapture(
4563
4766
  scopeId,
4564
4767
  scope,
4565
4768
  state.storageKey,
@@ -4576,18 +4779,18 @@ export class FlexHarness<TScope = unknown> {
4576
4779
  runId,
4577
4780
  captureId,
4578
4781
  );
4579
- if (finalInspection.status !== 'finalized' || finalInspection.reference === undefined) {
4782
+ if (finalInspection.status !== 'finalized') {
4580
4783
  if (finalInspection.status === 'unknown') state.lifecycle = 'fenced';
4581
4784
  throw this.projectOperationError(error, 'turnReversion', scopeId, sessionId, captureId);
4582
4785
  }
4583
- reference = finalInspection.reference;
4786
+ outcome = finalInspection.outcome;
4584
4787
  }
4585
4788
  }
4586
- if (reference === undefined) {
4789
+ if (outcome === undefined) {
4587
4790
  state.lifecycle = 'fenced';
4588
- throw new FlexHarnessValidationError('Finalized reversion capture has no reference.');
4791
+ throw new FlexHarnessValidationError('Finalized reversion capture has no outcome.');
4589
4792
  }
4590
- return this.normalizeReversionReference(reference);
4793
+ return outcome;
4591
4794
  }
4592
4795
 
4593
4796
  private normalizeReversionReference(reference: unknown): TJsonValue {
@@ -4600,6 +4803,102 @@ export class FlexHarness<TScope = unknown> {
4600
4803
  });
4601
4804
  }
4602
4805
 
4806
+ private normalizeReversionOutcome(value: unknown): TFlexTurnReversionFinalizedOutcome {
4807
+ if (
4808
+ !value
4809
+ || typeof value !== 'object'
4810
+ || Array.isArray(value)
4811
+ || (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
4812
+ ) throw new FlexHarnessValidationError('Turn reversion finalized outcome must be a plain object.');
4813
+ const outcome = value as Record<string, unknown>;
4814
+ const disposition = outcome.disposition;
4815
+ if (!['revertible', 'no-change', 'nonrevertible'].includes(String(disposition))) {
4816
+ throw new FlexHarnessValidationError('Turn reversion finalized outcome disposition is invalid.');
4817
+ }
4818
+ const allowedKeys = disposition === 'nonrevertible'
4819
+ ? ['disposition', 'reference', 'reasonCode', 'affectedWorkspaces']
4820
+ : ['disposition', 'reference', 'affectedWorkspaces'];
4821
+ const unsupported = Object.keys(outcome).find((key) => !allowedKeys.includes(key));
4822
+ if (unsupported) {
4823
+ throw new FlexHarnessValidationError(
4824
+ `Turn reversion finalized outcome does not support "${unsupported}".`,
4825
+ );
4826
+ }
4827
+ if (!Object.prototype.hasOwnProperty.call(outcome, 'reference')) {
4828
+ throw new FlexHarnessValidationError('Turn reversion finalized outcome requires a reference.');
4829
+ }
4830
+ const reference = this.normalizeReversionReference(outcome.reference);
4831
+ const affectedWorkspaces = outcome.affectedWorkspaces === undefined
4832
+ ? undefined
4833
+ : this.normalizeAffectedWorkspaces(outcome.affectedWorkspaces);
4834
+ if (disposition === 'revertible') {
4835
+ if (affectedWorkspaces === undefined) {
4836
+ throw new FlexHarnessValidationError('Revertible outcome requires affectedWorkspaces.');
4837
+ }
4838
+ return { disposition, reference, affectedWorkspaces };
4839
+ }
4840
+ if (disposition === 'no-change') {
4841
+ if (affectedWorkspaces && affectedWorkspaces.length > 0) {
4842
+ throw new FlexHarnessValidationError('No-change outcome cannot affect a workspace.');
4843
+ }
4844
+ return {
4845
+ disposition,
4846
+ reference,
4847
+ ...(affectedWorkspaces === undefined ? {} : { affectedWorkspaces: [] }),
4848
+ };
4849
+ }
4850
+ if (
4851
+ typeof outcome.reasonCode !== 'string'
4852
+ || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(outcome.reasonCode)
4853
+ || Buffer.byteLength(outcome.reasonCode, 'utf8') > FLEX_REVERSION_REASON_CODE_MAX_BYTES
4854
+ ) throw new FlexHarnessValidationError('Nonrevertible outcome reasonCode is invalid.');
4855
+ return {
4856
+ disposition: 'nonrevertible',
4857
+ reference,
4858
+ reasonCode: outcome.reasonCode,
4859
+ ...(affectedWorkspaces === undefined ? {} : { affectedWorkspaces }),
4860
+ };
4861
+ }
4862
+
4863
+ private normalizeAffectedWorkspaces(value: unknown): IFlexAffectedWorkspace[] {
4864
+ if (!Array.isArray(value) || value.length > FLEX_REVERSION_MAX_AFFECTED_WORKSPACES) {
4865
+ throw new FlexHarnessValidationError(
4866
+ `affectedWorkspaces must contain at most ${FLEX_REVERSION_MAX_AFFECTED_WORKSPACES} entries.`,
4867
+ );
4868
+ }
4869
+ const ids = new Set<string>();
4870
+ return value.map((entry, index) => {
4871
+ if (
4872
+ !entry
4873
+ || typeof entry !== 'object'
4874
+ || Array.isArray(entry)
4875
+ || (Object.getPrototypeOf(entry) !== Object.prototype && Object.getPrototypeOf(entry) !== null)
4876
+ ) throw new FlexHarnessValidationError(`affectedWorkspaces[${index}] must be a plain object.`);
4877
+ const descriptor = entry as Record<string, unknown>;
4878
+ const unsupported = Object.keys(descriptor).find((key) => !['id', 'label'].includes(key));
4879
+ if (unsupported) {
4880
+ throw new FlexHarnessValidationError(
4881
+ `affectedWorkspaces[${index}] does not support "${unsupported}".`,
4882
+ );
4883
+ }
4884
+ if (
4885
+ typeof descriptor.id !== 'string'
4886
+ || !descriptor.id.trim()
4887
+ || Buffer.byteLength(descriptor.id, 'utf8') > FLEX_REVERSION_WORKSPACE_ID_MAX_BYTES
4888
+ ) throw new FlexHarnessValidationError(`affectedWorkspaces[${index}].id is invalid.`);
4889
+ if (
4890
+ typeof descriptor.label !== 'string'
4891
+ || !descriptor.label.trim()
4892
+ || Buffer.byteLength(descriptor.label, 'utf8') > FLEX_REVERSION_WORKSPACE_LABEL_MAX_BYTES
4893
+ ) throw new FlexHarnessValidationError(`affectedWorkspaces[${index}].label is invalid.`);
4894
+ if (ids.has(descriptor.id)) {
4895
+ throw new FlexHarnessValidationError(`affectedWorkspaces contains duplicate id "${descriptor.id}".`);
4896
+ }
4897
+ ids.add(descriptor.id);
4898
+ return { id: descriptor.id, label: descriptor.label };
4899
+ });
4900
+ }
4901
+
4603
4902
  private async prepareRunReversion(run: IActiveRun, signal: AbortSignal): Promise<void> {
4604
4903
  if (run.stored.session.agent !== undefined || !this.turnReversionProvider) return;
4605
4904
  const segment = run.stored.reversionSegments.find((entry) => entry.runId === run.runId);
@@ -4612,6 +4911,13 @@ export class FlexHarness<TScope = unknown> {
4612
4911
  );
4613
4912
  }
4614
4913
  const context = this.reversionCaptureContext(run, captureId, signal);
4914
+ if (this.reversionProviderProtocolVersion() !== segment.protocolVersion) {
4915
+ throw this.projectExternalError(
4916
+ run,
4917
+ new Error(`Root generation requires reversion protocolVersion ${segment.protocolVersion}.`),
4918
+ 'turnReversion',
4919
+ );
4920
+ }
4615
4921
  try {
4616
4922
  await this.turnReversionProvider.prepare(context);
4617
4923
  } catch (error) {
@@ -4653,7 +4959,7 @@ export class FlexHarness<TScope = unknown> {
4653
4959
  const eventIds = run.stored.agentSession.getEvents()
4654
4960
  .filter((event) => event.generationId === run.runId)
4655
4961
  .map((event) => event.id);
4656
- if (!this.turnReversionProvider || !segment.captureId) {
4962
+ if (!this.turnReversionProvider || segment.provenance === 'transcript') {
4657
4963
  await this.mutateProjection(run.state, run.stored, () => {
4658
4964
  const wasCompleted = segment.status === 'completed';
4659
4965
  segment.status = status;
@@ -4665,6 +4971,9 @@ export class FlexHarness<TScope = unknown> {
4665
4971
  }, true);
4666
4972
  return;
4667
4973
  }
4974
+ if (!segment.captureId) {
4975
+ throw new FlexHarnessValidationError('Workspace reversion segment has no capture ownership.');
4976
+ }
4668
4977
  const captureId = segment.captureId;
4669
4978
  if (segment.workspaceReference !== undefined) {
4670
4979
  await this.mutateProjection(run.state, run.stored, () => {
@@ -4681,16 +4990,30 @@ export class FlexHarness<TScope = unknown> {
4681
4990
  if (pending?.kind !== 'capture' || pending.runId !== run.runId) {
4682
4991
  throw new FlexHarnessValidationError('Reversion capture intent changed before finalization.');
4683
4992
  }
4684
- const reference = await this.resolveReversionCaptureReference(
4993
+ if (this.reversionProviderProtocolVersion() !== pending.protocolVersion) {
4994
+ throw new FlexHarnessValidationError(
4995
+ `Reversion capture finalization requires protocolVersion ${pending.protocolVersion}.`,
4996
+ );
4997
+ }
4998
+ if (pending.state === 'prepared') {
4999
+ await this.mutateProjection(run.state, run.stored, () => {
5000
+ const current = run.stored.pendingReversion;
5001
+ if (current?.kind !== 'capture' || current.captureId !== captureId) {
5002
+ throw new FlexHarnessValidationError('Reversion capture changed before finalization.');
5003
+ }
5004
+ current.state = 'finalizing';
5005
+ }, true);
5006
+ }
5007
+ const outcome = await this.resolveReversionCaptureOutcome(
4685
5008
  run.state,
4686
5009
  run.scopeId,
4687
5010
  run.scope as TScope,
4688
5011
  run.sessionId,
4689
5012
  run.runId,
4690
5013
  captureId,
4691
- pending.state,
5014
+ pending.state === 'prepared' ? 'finalizing' : pending.state,
4692
5015
  );
4693
- if (reference === undefined) {
5016
+ if (outcome === undefined) {
4694
5017
  await this.mutateProjection(run.state, run.stored, () => {
4695
5018
  run.stored.reversionSegments = run.stored.reversionSegments.filter(
4696
5019
  (entry) => entry.runId !== run.runId,
@@ -4707,7 +5030,7 @@ export class FlexHarness<TScope = unknown> {
4707
5030
  const wasCompleted = segment.status === 'completed';
4708
5031
  segment.status = status;
4709
5032
  segment.eventIds = eventIds;
4710
- segment.workspaceReference = reference;
5033
+ this.applyFinalizedReversionOutcome(run.stored, segment, outcome);
4711
5034
  if (!wasCompleted && status === 'completed') run.stored.revertCursor++;
4712
5035
  else if (wasCompleted && status !== 'completed') run.stored.revertCursor--;
4713
5036
  delete run.stored.pendingReversion;
@@ -4715,34 +5038,73 @@ export class FlexHarness<TScope = unknown> {
4715
5038
  }, true);
4716
5039
  }
4717
5040
 
5041
+ private applyFinalizedReversionOutcome(
5042
+ stored: IStoredSessionState,
5043
+ segment: IFlexReversionSegment,
5044
+ outcome: TFlexTurnReversionFinalizedOutcome,
5045
+ ): void {
5046
+ const captureId = segment.captureId;
5047
+ if (!captureId) {
5048
+ throw new FlexHarnessValidationError('Finalized workspace outcome has no capture ownership.');
5049
+ }
5050
+ segment.disposition = outcome.disposition;
5051
+ delete segment.affectedWorkspaces;
5052
+ delete segment.reasonCode;
5053
+ if (segment.protocolVersion === 2) {
5054
+ if (outcome.affectedWorkspaces !== undefined) {
5055
+ segment.affectedWorkspaces = cloneSerializable([...outcome.affectedWorkspaces]);
5056
+ }
5057
+ if (outcome.disposition === 'nonrevertible') segment.reasonCode = outcome.reasonCode;
5058
+ }
5059
+ if (outcome.disposition === 'revertible') {
5060
+ segment.workspaceReference = cloneSerializable(outcome.reference);
5061
+ return;
5062
+ }
5063
+ this.enqueueReversionReleaseReference(stored, {
5064
+ runId: segment.runId,
5065
+ captureId,
5066
+ reference: cloneSerializable(outcome.reference),
5067
+ protocolVersion: segment.protocolVersion,
5068
+ });
5069
+ delete segment.captureId;
5070
+ delete segment.workspaceReference;
5071
+ }
5072
+
4718
5073
  private hiddenReversionSegments(stored: IStoredSessionState): IFlexReversionSegment[] {
4719
- const candidates = this.completedReversionCandidates(stored);
4720
- const firstHidden = candidates[stored.revertCursor];
5074
+ const firstHidden = this.reversionGroups(stored)[stored.revertCursor]?.segments[0];
4721
5075
  if (!firstHidden) return [];
4722
5076
  const index = stored.reversionSegments.findIndex((segment) => segment.runId === firstHidden.runId);
4723
5077
  if (index < 0) return [];
4724
- return stored.reversionSegments.slice(firstHidden === candidates[0] ? 0 : index);
5078
+ return stored.reversionSegments.slice(index);
4725
5079
  }
4726
5080
 
4727
5081
  private enqueueReversionRelease(
4728
5082
  stored: IStoredSessionState,
4729
5083
  segment: IFlexReversionSegment,
4730
5084
  ): void {
4731
- if (!segment.workspaceCaptured) return;
5085
+ if (segment.disposition !== 'revertible') return;
4732
5086
  if (segment.captureId === undefined || segment.workspaceReference === undefined) {
4733
5087
  throw new FlexHarnessValidationError('Capture-backed segment has no releasable workspace reference.');
4734
5088
  }
4735
- if (stored.pendingReversionReleases.some((release) => release.captureId === segment.captureId)) {
5089
+ this.enqueueReversionReleaseReference(stored, {
5090
+ runId: segment.runId,
5091
+ captureId: segment.captureId,
5092
+ reference: cloneSerializable(segment.workspaceReference),
5093
+ protocolVersion: segment.protocolVersion,
5094
+ });
5095
+ }
5096
+
5097
+ private enqueueReversionReleaseReference(
5098
+ stored: IStoredSessionState,
5099
+ release: IFlexPendingReversionRelease,
5100
+ ): void {
5101
+ if (stored.pendingReversionReleases.some((entry) => entry.captureId === release.captureId)) {
4736
5102
  return;
4737
5103
  }
4738
5104
  if (stored.pendingReversionReleases.length >= this.reversionLimits.maxPendingReversionReleases) {
4739
5105
  throw new FlexHarnessValidationError('Pending reversion release limit was reached.');
4740
5106
  }
4741
- stored.pendingReversionReleases.push({
4742
- runId: segment.runId,
4743
- captureId: segment.captureId,
4744
- reference: cloneSerializable(segment.workspaceReference),
4745
- });
5107
+ stored.pendingReversionReleases.push(cloneSerializable(release));
4746
5108
  }
4747
5109
 
4748
5110
  private pruneReversionState(
@@ -4784,7 +5146,8 @@ export class FlexHarness<TScope = unknown> {
4784
5146
  }
4785
5147
  if (end <= 0) break;
4786
5148
  const prefix = stored.reversionSegments.slice(0, end);
4787
- if (prefix.some((segment) => segment.workspaceCaptured && segment.workspaceReference === undefined)) break;
5149
+ if (prefix.some((segment) =>
5150
+ segment.disposition === 'revertible' && segment.workspaceReference === undefined)) break;
4788
5151
  for (const segment of prefix) this.enqueueReversionRelease(stored, segment);
4789
5152
  stored.reversionSegments.splice(0, end);
4790
5153
  stored.revertCursor = Math.max(
@@ -4897,6 +5260,11 @@ export class FlexHarness<TScope = unknown> {
4897
5260
  }
4898
5261
  while (stored.pendingReversionReleases.length > 0) {
4899
5262
  const release = stored.pendingReversionReleases[0];
5263
+ if (this.reversionProviderProtocolVersion() !== release.protocolVersion) {
5264
+ throw new FlexHarnessValidationError(
5265
+ `Pending workspace release requires protocolVersion ${release.protocolVersion}.`,
5266
+ );
5267
+ }
4900
5268
  try {
4901
5269
  await this.withReversionMaintenanceSignal((signal) =>
4902
5270
  this.turnReversionProvider!.release(Object.freeze({
@@ -4947,9 +5315,33 @@ export class FlexHarness<TScope = unknown> {
4947
5315
  stored?: IStoredSessionState,
4948
5316
  ): Promise<void> {
4949
5317
  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;
5318
+ let durableProjection = await this.stores.projections.load(storageKey, sessionId);
5319
+ if (!durableProjection) return;
5320
+ let projection = this.upgradeProjectionSnapshot(durableProjection);
5321
+ const persistDirect = async (next: IFlexProjectionSnapshotCurrent): Promise<void> => {
5322
+ const before = durableProjection;
5323
+ try {
5324
+ await this.stores.projections.save(storageKey, sessionId, next, projection.revision);
5325
+ durableProjection = next;
5326
+ projection = next;
5327
+ } catch (error) {
5328
+ let current: TFlexProjectionSnapshot | undefined;
5329
+ let reconciliationError: unknown;
5330
+ try {
5331
+ current = await this.stores.projections.load(storageKey, sessionId);
5332
+ } catch (loadError) {
5333
+ reconciliationError = loadError;
5334
+ }
5335
+ if (current && JSON.stringify(current) === JSON.stringify(next)) {
5336
+ durableProjection = current;
5337
+ projection = next;
5338
+ return;
5339
+ }
5340
+ if (current && before && JSON.stringify(current) === JSON.stringify(before)) throw error;
5341
+ state.lifecycle = 'fenced';
5342
+ throw reconciliationError === undefined ? error : combineErrors([error, reconciliationError]);
5343
+ }
5344
+ };
4953
5345
  if (projection.pendingReversion?.kind === 'apply') {
4954
5346
  if (stored) {
4955
5347
  await this.resumePendingApply(state, stored, scopeId, scope);
@@ -4961,12 +5353,17 @@ export class FlexHarness<TScope = unknown> {
4961
5353
  for (const runId of orderedRunIds) {
4962
5354
  if (pending.appliedRunIds.includes(runId)) continue;
4963
5355
  const segment = projection.reversionSegments.find((entry) => entry.runId === runId)!;
4964
- if (segment.workspaceCaptured) {
5356
+ if (segment.disposition === 'revertible') {
4965
5357
  if (!this.turnReversionProvider || !segment.captureId || segment.workspaceReference === undefined) {
4966
5358
  throw new FlexHarnessValidationError(
4967
5359
  'Pending workspace reversion deletion requires its turn reversion provider.',
4968
5360
  );
4969
5361
  }
5362
+ if (this.reversionProviderProtocolVersion() !== segment.protocolVersion) {
5363
+ throw new FlexHarnessValidationError(
5364
+ `Pending workspace reversion deletion requires protocolVersion ${segment.protocolVersion}.`,
5365
+ );
5366
+ }
4970
5367
  const captureId = segment.captureId;
4971
5368
  const reference = segment.workspaceReference;
4972
5369
  const operationId = `${pending.operationId}:${sha256Hex(runId).slice(0, 16)}`;
@@ -4985,6 +5382,7 @@ export class FlexHarness<TScope = unknown> {
4985
5382
  let inspection = await this.withReversionMaintenanceSignal((signal) =>
4986
5383
  this.turnReversionProvider!.inspectApply(createContext(signal)));
4987
5384
  if (inspection.status === 'unknown') {
5385
+ state.lifecycle = 'fenced';
4988
5386
  throw new FlexHarnessValidationError('Workspace reversion apply outcome is unknown.');
4989
5387
  }
4990
5388
  if (inspection.status === 'not-applied') {
@@ -4994,14 +5392,15 @@ export class FlexHarness<TScope = unknown> {
4994
5392
  } catch (error) {
4995
5393
  inspection = await this.withReversionMaintenanceSignal((signal) =>
4996
5394
  this.turnReversionProvider!.inspectApply(createContext(signal)));
4997
- if (inspection.status !== 'applied') throw error;
5395
+ if (inspection.status !== 'applied') {
5396
+ if (inspection.status === 'unknown') state.lifecycle = 'fenced';
5397
+ throw error;
5398
+ }
4998
5399
  }
4999
5400
  }
5000
5401
  }
5001
5402
  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;
5403
+ await persistDirect({ ...projection, revision: projection.revision + 1 });
5005
5404
  }
5006
5405
  const { pendingReversion: _pendingReversion, ...completedProjection } = projection;
5007
5406
  const completed: IFlexProjectionSnapshotCurrent = {
@@ -5009,23 +5408,12 @@ export class FlexHarness<TScope = unknown> {
5009
5408
  revision: projection.revision + 1,
5010
5409
  revertCursor: pending.toCursor,
5011
5410
  };
5012
- await this.stores.projections.save(storageKey, sessionId, completed, projection.revision);
5013
- projection = completed;
5411
+ await persistDirect(completed);
5014
5412
  }
5015
5413
  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;
5414
+ if (!recoveredProjection) return;
5415
+ durableProjection = recoveredProjection;
5416
+ projection = this.upgradeProjectionSnapshot(recoveredProjection);
5029
5417
  }
5030
5418
  const releases = new Map<string, IFlexPendingReversionRelease>();
5031
5419
  for (const release of projection.pendingReversionReleases) {
@@ -5037,12 +5425,14 @@ export class FlexHarness<TScope = unknown> {
5037
5425
  runId: segment.runId,
5038
5426
  captureId: segment.captureId,
5039
5427
  reference: cloneSerializable(segment.workspaceReference),
5428
+ protocolVersion: segment.protocolVersion,
5040
5429
  });
5041
5430
  }
5042
5431
  }
5043
5432
  const unresolvedCaptures = new Map<string, {
5044
5433
  runId: string;
5045
5434
  captureId: string;
5435
+ protocolVersion: 1 | 2;
5046
5436
  durableState?: 'preparing' | 'prepared' | 'finalizing';
5047
5437
  }>();
5048
5438
  for (const segment of projection.reversionSegments) {
@@ -5050,6 +5440,7 @@ export class FlexHarness<TScope = unknown> {
5050
5440
  unresolvedCaptures.set(segment.captureId, {
5051
5441
  runId: segment.runId,
5052
5442
  captureId: segment.captureId,
5443
+ protocolVersion: segment.protocolVersion,
5053
5444
  });
5054
5445
  }
5055
5446
  }
@@ -5057,10 +5448,36 @@ export class FlexHarness<TScope = unknown> {
5057
5448
  unresolvedCaptures.set(projection.pendingReversion.captureId, {
5058
5449
  runId: projection.pendingReversion.runId,
5059
5450
  captureId: projection.pendingReversion.captureId,
5451
+ protocolVersion: projection.pendingReversion.protocolVersion,
5060
5452
  durableState: projection.pendingReversion.state,
5061
5453
  });
5062
5454
  }
5455
+ if (unresolvedCaptures.size > 0 && !this.turnReversionProvider) {
5456
+ throw new FlexHarnessValidationError(
5457
+ 'Capture-backed session deletion requires its turn reversion provider.',
5458
+ );
5459
+ }
5063
5460
  for (const capture of unresolvedCaptures.values()) {
5461
+ if (this.reversionProviderProtocolVersion() !== capture.protocolVersion) {
5462
+ throw new FlexHarnessValidationError(
5463
+ `Deleted session capture requires protocolVersion ${capture.protocolVersion}.`,
5464
+ );
5465
+ }
5466
+ if (capture.durableState === 'prepared') {
5467
+ if (stored) {
5468
+ await this.mutateProjection(state, stored, () => {
5469
+ const pending = stored.pendingReversion;
5470
+ if (pending?.kind !== 'capture' || pending.captureId !== capture.captureId) {
5471
+ throw new FlexHarnessValidationError('Deleted session capture changed before finalization.');
5472
+ }
5473
+ pending.state = 'finalizing';
5474
+ }, true);
5475
+ } else if (projection.pendingReversion?.kind === 'capture') {
5476
+ projection.pendingReversion.state = 'finalizing';
5477
+ await persistDirect({ ...projection, revision: projection.revision + 1 });
5478
+ }
5479
+ capture.durableState = 'finalizing';
5480
+ }
5064
5481
  let inspection;
5065
5482
  try {
5066
5483
  inspection = await this.inspectReversionCapture(
@@ -5081,6 +5498,7 @@ export class FlexHarness<TScope = unknown> {
5081
5498
  );
5082
5499
  }
5083
5500
  if (inspection.status === 'unknown') {
5501
+ state.lifecycle = 'fenced';
5084
5502
  throw new FlexHarnessValidationError('Deleted session capture outcome is unknown.');
5085
5503
  }
5086
5504
  if (inspection.status === 'missing') {
@@ -5088,10 +5506,10 @@ export class FlexHarness<TScope = unknown> {
5088
5506
  state.lifecycle = 'fenced';
5089
5507
  throw new FlexHarnessValidationError('Prepared deleted-session capture is missing.');
5090
5508
  }
5091
- let reference = inspection.reference;
5509
+ let outcome = inspection.status === 'finalized' ? inspection.outcome : undefined;
5092
5510
  if (inspection.status === 'prepared') {
5093
5511
  try {
5094
- reference = await this.finalizeReversionCapture(
5512
+ outcome = await this.finalizeReversionCapture(
5095
5513
  scopeId,
5096
5514
  scope,
5097
5515
  storageKey,
@@ -5108,7 +5526,8 @@ export class FlexHarness<TScope = unknown> {
5108
5526
  capture.runId,
5109
5527
  capture.captureId,
5110
5528
  );
5111
- if (finalInspection.status !== 'finalized' || finalInspection.reference === undefined) {
5529
+ if (finalInspection.status !== 'finalized') {
5530
+ if (finalInspection.status === 'unknown') state.lifecycle = 'fenced';
5112
5531
  throw this.projectOperationError(
5113
5532
  error,
5114
5533
  'turnReversion',
@@ -5117,63 +5536,77 @@ export class FlexHarness<TScope = unknown> {
5117
5536
  `release:${capture.captureId}`,
5118
5537
  );
5119
5538
  }
5120
- reference = finalInspection.reference;
5539
+ outcome = finalInspection.outcome;
5121
5540
  }
5122
5541
  }
5123
- if (reference === undefined) {
5542
+ if (outcome === undefined) {
5124
5543
  throw new FlexHarnessValidationError('Deleted session capture has no release reference.');
5125
5544
  }
5126
5545
  releases.set(capture.captureId, {
5127
5546
  runId: capture.runId,
5128
5547
  captureId: capture.captureId,
5129
- reference: this.normalizeReversionReference(reference),
5548
+ reference: cloneSerializable(outcome.reference),
5549
+ protocolVersion: capture.protocolVersion,
5130
5550
  });
5131
5551
  }
5552
+ if (releases.size === 0) return;
5553
+ if (!this.turnReversionProvider) {
5554
+ throw new FlexHarnessValidationError(
5555
+ 'Capture-backed session deletion requires its turn reversion provider.',
5556
+ );
5557
+ }
5132
5558
  if (stored) {
5133
5559
  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
- }
5560
+ stored.reversionSegments = [];
5561
+ stored.revertCursor = 0;
5562
+ delete stored.pendingReversion;
5563
+ stored.pendingReversionReleases = [...releases.values()].map((release) =>
5564
+ cloneSerializable(release));
5139
5565
  }, true);
5566
+ await this.drainReversionReleases(state, stored, scopeId, scope, true);
5140
5567
  } 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({
5568
+ const { pendingReversion: _pendingReversion, ...withoutPending } = projection;
5569
+ await persistDirect({
5570
+ ...withoutPending,
5571
+ revision: projection.revision + 1,
5572
+ reversionSegments: [],
5573
+ revertCursor: 0,
5574
+ pendingReversionReleases: [...releases.values()].map((release) =>
5575
+ cloneSerializable(release)),
5576
+ });
5577
+ while (projection.pendingReversionReleases.length > 0) {
5578
+ const release = projection.pendingReversionReleases[0];
5579
+ if (this.reversionProviderProtocolVersion() !== release.protocolVersion) {
5580
+ throw new FlexHarnessValidationError(
5581
+ `Deleted session release requires protocolVersion ${release.protocolVersion}.`,
5582
+ );
5583
+ }
5584
+ try {
5585
+ await this.withReversionMaintenanceSignal((signal) =>
5586
+ this.turnReversionProvider!.release(Object.freeze({
5587
+ scopeId,
5588
+ scope,
5589
+ storageKey,
5590
+ sessionId,
5591
+ runId: release.runId,
5592
+ captureId: release.captureId,
5593
+ reference: cloneSerializable(release.reference),
5594
+ signal,
5595
+ })));
5596
+ } catch (error) {
5597
+ throw this.projectOperationError(
5598
+ error,
5599
+ 'turnReversion',
5160
5600
  scopeId,
5161
- scope,
5162
- storageKey,
5163
5601
  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
- );
5602
+ `release:${release.captureId}`,
5603
+ );
5604
+ }
5605
+ await persistDirect({
5606
+ ...projection,
5607
+ revision: projection.revision + 1,
5608
+ pendingReversionReleases: projection.pendingReversionReleases.slice(1),
5609
+ });
5177
5610
  }
5178
5611
  }
5179
5612
  }
@@ -6248,8 +6681,12 @@ export class FlexHarness<TScope = unknown> {
6248
6681
  }
6249
6682
  for (const tombstone of group) state.tombstones.delete(tombstone.sessionId);
6250
6683
  scopeChanged = true;
6251
- } catch {
6684
+ } catch (error) {
6252
6685
  // A retained tombstone is retried by the next load or explicit delete call.
6686
+ if (state.lifecycle === 'fenced') {
6687
+ this.deferNamespaceDrain(state, error, { scopeId, scope });
6688
+ break;
6689
+ }
6253
6690
  }
6254
6691
  }
6255
6692
  if (scopeChanged) {
@@ -6305,7 +6742,7 @@ export class FlexHarness<TScope = unknown> {
6305
6742
  revertCursor: 0,
6306
6743
  excludedRunIds: [],
6307
6744
  pendingReversionReleases: [],
6308
- projectionSchemaVersion: 2,
6745
+ projectionSchemaVersion: 3,
6309
6746
  projectionReconciliationRequired: false,
6310
6747
  projectionRevision: 0,
6311
6748
  projectionQueue: Promise.resolve(),
@@ -6373,7 +6810,28 @@ export class FlexHarness<TScope = unknown> {
6373
6810
  throw combineErrors(acquisitionErrors);
6374
6811
  }
6375
6812
  const projection = projectionResult.value;
6376
- const projectionV2 = projection?.schemaVersion === 2 ? projection : undefined;
6813
+ const legacyProjection = projection?.schemaVersion === 2 ? projection : undefined;
6814
+ const currentProjection = projection?.schemaVersion === 3 ? projection : undefined;
6815
+ const reversionSegments: IFlexReversionSegment[] = currentProjection
6816
+ ? cloneSerializable(currentProjection.reversionSegments)
6817
+ : (legacyProjection?.reversionSegments ?? []).map((segment): IFlexReversionSegment => ({
6818
+ ...cloneSerializable(segment),
6819
+ protocolVersion: 1,
6820
+ provenance: segment.workspaceCaptured ? 'workspace' : 'transcript',
6821
+ ...(segment.workspaceCaptured
6822
+ ? { disposition: segment.status === 'capturing' ? 'pending' : 'revertible' }
6823
+ : {}),
6824
+ }));
6825
+ const pendingReversion = currentProjection?.pendingReversion
6826
+ ?? (legacyProjection?.pendingReversion?.kind === 'capture'
6827
+ ? { ...legacyProjection.pendingReversion, protocolVersion: 1 as const }
6828
+ : legacyProjection?.pendingReversion);
6829
+ const pendingReversionReleases: IFlexPendingReversionRelease[] = currentProjection
6830
+ ? cloneSerializable(currentProjection.pendingReversionReleases)
6831
+ : (legacyProjection?.pendingReversionReleases ?? []).map((release) => ({
6832
+ ...cloneSerializable(release),
6833
+ protocolVersion: 1,
6834
+ }));
6377
6835
  const permission = permissionResult.value;
6378
6836
  const eventStore = eventStoreResult.value;
6379
6837
  const jobStore = jobStoreResult.value;
@@ -6404,18 +6862,18 @@ export class FlexHarness<TScope = unknown> {
6404
6862
  session: cloneSerializable(metadata),
6405
6863
  messages: cloneSerializable(projection?.messages ?? []),
6406
6864
  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 ?? [],
6865
+ reversionSegments,
6866
+ revertCursor: currentProjection?.revertCursor ?? legacyProjection?.revertCursor ?? 0,
6867
+ excludedRunIds: cloneSerializable(
6868
+ currentProjection?.excludedRunIds ?? legacyProjection?.excludedRunIds ?? [],
6415
6869
  ),
6416
- projectionSchemaVersion: projection?.schemaVersion ?? 2,
6870
+ ...(pendingReversion === undefined
6871
+ ? {}
6872
+ : { pendingReversion: cloneSerializable(pendingReversion) }),
6873
+ pendingReversionReleases,
6874
+ projectionSchemaVersion: projection?.schemaVersion ?? 3,
6417
6875
  projectionReconciliationRequired: false,
6418
- ...(projection?.schemaVersion === 1
6876
+ ...(projection && projection.schemaVersion !== 3
6419
6877
  ? { projectionBaseline: cloneSerializable(projection) }
6420
6878
  : {}),
6421
6879
  projectionRevision: projection?.revision ?? 0,
@@ -6585,6 +7043,31 @@ export class FlexHarness<TScope = unknown> {
6585
7043
  'Capture-backed session recovery requires its turn reversion provider.',
6586
7044
  );
6587
7045
  }
7046
+ const requiredProtocols = new Set<number>([
7047
+ ...stored.reversionSegments
7048
+ .filter((segment) => segment.captureId !== undefined)
7049
+ .map((segment) => segment.protocolVersion),
7050
+ ...stored.pendingReversionReleases.map((release) => release.protocolVersion),
7051
+ ...(stored.pendingReversion?.kind === 'capture'
7052
+ ? [stored.pendingReversion.protocolVersion]
7053
+ : []),
7054
+ ]);
7055
+ const providerProtocol = this.reversionProviderProtocolVersion();
7056
+ if (requiredProtocols.size > 0 && (
7057
+ requiredProtocols.size !== 1
7058
+ || !requiredProtocols.has(providerProtocol ?? 0)
7059
+ )) {
7060
+ throw new FlexHarnessValidationError(
7061
+ `Capture-backed session recovery requires protocolVersion ${[...requiredProtocols].join(', ')}.`,
7062
+ );
7063
+ }
7064
+ if (stored.pendingReversion?.kind === 'capture' && stored.pendingReversionReleases.length > 0) {
7065
+ if (!state || scopeId === undefined || scope === undefined) {
7066
+ throw new FlexHarnessValidationError('Pending capture recovery is missing its scope context.');
7067
+ }
7068
+ await this.drainReversionReleases(state, stored, scopeId, scope, false);
7069
+ this.assertStateAcceptingWork(state);
7070
+ }
6588
7071
  const outcomes = this.canonicalOutcomes(stored.agentSession.getEvents());
6589
7072
  const firstHiddenCandidate = this.completedReversionCandidates(stored)[stored.revertCursor];
6590
7073
  const reversionVisibilityBoundary = firstHiddenCandidate
@@ -6698,7 +7181,7 @@ export class FlexHarness<TScope = unknown> {
6698
7181
  snapshot,
6699
7182
  expected,
6700
7183
  );
6701
- stored.projectionSchemaVersion = 2;
7184
+ stored.projectionSchemaVersion = 3;
6702
7185
  delete stored.projectionBaseline;
6703
7186
  stored.projectionReconciliationRequired = false;
6704
7187
  stored.projectionRevision = snapshot.revision;
@@ -6875,7 +7358,7 @@ export class FlexHarness<TScope = unknown> {
6875
7358
  : cloneSerializable(stored.pendingReversion);
6876
7359
  const beforePendingReversionReleases = cloneSerializable(stored.pendingReversionReleases);
6877
7360
  const beforeSnapshot: TFlexProjectionSnapshot = beforeBaseline ?? {
6878
- schemaVersion: 2,
7361
+ schemaVersion: 3,
6879
7362
  revision: beforeRevision,
6880
7363
  messages: cloneSerializable(beforeMessages),
6881
7364
  stagedTerminals: cloneSerializable(beforeStages),
@@ -6917,7 +7400,7 @@ export class FlexHarness<TScope = unknown> {
6917
7400
  snapshot,
6918
7401
  beforeRevision,
6919
7402
  );
6920
- stored.projectionSchemaVersion = 2;
7403
+ stored.projectionSchemaVersion = 3;
6921
7404
  delete stored.projectionBaseline;
6922
7405
  stored.projectionReconciliationRequired = false;
6923
7406
  stored.projectionRevision = snapshot.revision;
@@ -6928,7 +7411,7 @@ export class FlexHarness<TScope = unknown> {
6928
7411
  throw error;
6929
7412
  }
6930
7413
  if (error instanceof FlexHarnessStoreCommitUncertainError) {
6931
- stored.projectionSchemaVersion = 2;
7414
+ stored.projectionSchemaVersion = 3;
6932
7415
  stored.projectionReconciliationRequired = true;
6933
7416
  stored.projectionRevision = snapshot.revision;
6934
7417
  state.lifecycle = 'fenced';
@@ -6945,7 +7428,7 @@ export class FlexHarness<TScope = unknown> {
6945
7428
  reconciliationError = loadError;
6946
7429
  }
6947
7430
  if (current && JSON.stringify(current) === JSON.stringify(snapshot)) {
6948
- stored.projectionSchemaVersion = 2;
7431
+ stored.projectionSchemaVersion = 3;
6949
7432
  delete stored.projectionBaseline;
6950
7433
  stored.projectionReconciliationRequired = false;
6951
7434
  stored.projectionRevision = snapshot.revision;
@@ -6958,7 +7441,7 @@ export class FlexHarness<TScope = unknown> {
6958
7441
  restore();
6959
7442
  throw error;
6960
7443
  }
6961
- stored.projectionSchemaVersion = 2;
7444
+ stored.projectionSchemaVersion = 3;
6962
7445
  stored.projectionReconciliationRequired = true;
6963
7446
  stored.projectionRevision = snapshot.revision;
6964
7447
  state.lifecycle = 'fenced';
@@ -7070,7 +7553,7 @@ export class FlexHarness<TScope = unknown> {
7070
7553
  revision: number,
7071
7554
  ): IFlexProjectionSnapshotCurrent {
7072
7555
  return {
7073
- schemaVersion: 2,
7556
+ schemaVersion: 3,
7074
7557
  revision,
7075
7558
  messages: cloneSerializable(stored.messages),
7076
7559
  stagedTerminals: cloneSerializable(stored.stagedTerminals),
@@ -7084,6 +7567,51 @@ export class FlexHarness<TScope = unknown> {
7084
7567
  };
7085
7568
  }
7086
7569
 
7570
+ private upgradeProjectionSnapshot(
7571
+ projection: TFlexProjectionSnapshot,
7572
+ ): IFlexProjectionSnapshotCurrent {
7573
+ if (projection.schemaVersion === 3) return cloneSerializable(projection);
7574
+ if (projection.schemaVersion === 1) {
7575
+ return {
7576
+ schemaVersion: 3,
7577
+ revision: projection.revision,
7578
+ messages: cloneSerializable(projection.messages),
7579
+ stagedTerminals: cloneSerializable(projection.stagedTerminals),
7580
+ reversionSegments: [],
7581
+ revertCursor: 0,
7582
+ excludedRunIds: [],
7583
+ pendingReversionReleases: [],
7584
+ };
7585
+ }
7586
+ return {
7587
+ schemaVersion: 3,
7588
+ revision: projection.revision,
7589
+ messages: cloneSerializable(projection.messages),
7590
+ stagedTerminals: cloneSerializable(projection.stagedTerminals),
7591
+ reversionSegments: projection.reversionSegments.map((segment) => ({
7592
+ ...cloneSerializable(segment),
7593
+ protocolVersion: 1,
7594
+ provenance: segment.workspaceCaptured ? 'workspace' as const : 'transcript' as const,
7595
+ ...(segment.workspaceCaptured
7596
+ ? { disposition: segment.status === 'capturing' ? 'pending' as const : 'revertible' as const }
7597
+ : {}),
7598
+ })),
7599
+ revertCursor: projection.revertCursor,
7600
+ excludedRunIds: cloneSerializable(projection.excludedRunIds),
7601
+ ...(projection.pendingReversion === undefined
7602
+ ? {}
7603
+ : {
7604
+ pendingReversion: projection.pendingReversion.kind === 'capture'
7605
+ ? { ...cloneSerializable(projection.pendingReversion), protocolVersion: 1 }
7606
+ : cloneSerializable(projection.pendingReversion),
7607
+ }),
7608
+ pendingReversionReleases: projection.pendingReversionReleases.map((release) => ({
7609
+ ...cloneSerializable(release),
7610
+ protocolVersion: 1,
7611
+ })),
7612
+ };
7613
+ }
7614
+
7087
7615
  private async reconcileProjectionFromStore(stored: IStoredSessionState): Promise<void> {
7088
7616
  if (!stored.projectionReconciliationRequired) return;
7089
7617
  const projection = await this.stores.projections.load(
@@ -7093,24 +7621,17 @@ export class FlexHarness<TScope = unknown> {
7093
7621
  if (!projection) {
7094
7622
  throw new FlexHarnessValidationError('Uncertain projection persistence could not be reloaded.');
7095
7623
  }
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
- }
7624
+ const current = this.upgradeProjectionSnapshot(projection);
7625
+ stored.messages = current.messages;
7626
+ stored.stagedTerminals = current.stagedTerminals;
7627
+ stored.reversionSegments = current.reversionSegments;
7628
+ stored.revertCursor = current.revertCursor;
7629
+ stored.excludedRunIds = current.excludedRunIds;
7630
+ if (current.pendingReversion === undefined) delete stored.pendingReversion;
7631
+ else stored.pendingReversion = current.pendingReversion;
7632
+ stored.pendingReversionReleases = current.pendingReversionReleases;
7633
+ if (projection.schemaVersion === 3) delete stored.projectionBaseline;
7634
+ else stored.projectionBaseline = cloneSerializable(projection);
7114
7635
  stored.projectionSchemaVersion = projection.schemaVersion;
7115
7636
  stored.projectionRevision = projection.revision;
7116
7637
  stored.projectionReconciliationRequired = false;
@@ -7807,12 +8328,7 @@ export class FlexHarness<TScope = unknown> {
7807
8328
  const scope = await this.scopeResolver.resolveScope(scopeId);
7808
8329
  this.assertOpen();
7809
8330
  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
- }
8331
+ this.assertNoAmbientSlashCommandTeardown({ storageKey: scope.storageKey });
7816
8332
  const errors: unknown[] = [];
7817
8333
  const listingSettlement = this.abortSlashCommandListings(
7818
8334
  scope.storageKey,
@@ -7895,8 +8411,11 @@ export class FlexHarness<TScope = unknown> {
7895
8411
  return;
7896
8412
  }
7897
8413
  if (state.lifecycle !== 'fenced') state.lifecycle = 'retiring';
7898
- const detachedCleanupAttempts = this.beginDetachedCleanupSettlement(state);
7899
8414
  const errors: unknown[] = [];
8415
+ const listingSettlement = this.abortSlashCommandListings(storageKey, reason);
8416
+ if (listingSettlement) await listingSettlement;
8417
+ await this.abortSlashCommandExecutions(storageKey, reason, errors);
8418
+ const detachedCleanupAttempts = this.beginDetachedCleanupSettlement(state);
7900
8419
  const contextFor = (sessionId: string) => invocation
7901
8420
  ? this.createCompactorContext(invocation.scopeId, invocation.scope, storageKey, sessionId)
7902
8421
  : undefined;
@@ -8061,6 +8580,7 @@ export class FlexHarness<TScope = unknown> {
8061
8580
  if (errors.length > 0) throw combineErrors(errors);
8062
8581
  this.compactorInvocationContext.disable();
8063
8582
  this.slashCommandInvocationContext.disable();
8583
+ this.slashCommandActivityContext.disable();
8064
8584
  this.stateLoads.clear();
8065
8585
  this.scopeAdmissions.clear();
8066
8586
  this.scopeRetirements.clear();
@@ -8171,6 +8691,23 @@ export class FlexHarness<TScope = unknown> {
8171
8691
  }
8172
8692
  const state = await stateLoad;
8173
8693
  this.assertOpen();
8694
+ if (state.lifecycle === 'fenced') {
8695
+ const drain = this.storageDrains.get(scope.storageKey) ?? this.drainStorage(
8696
+ scope.storageKey,
8697
+ stateLoad,
8698
+ this.trustInternalError(new FlexHarnessAbortError('The session namespace was fenced.')),
8699
+ { scopeId, scope: scope.scope },
8700
+ );
8701
+ if (this.ambientSlashCommandOwner({ storageKey: scope.storageKey })) {
8702
+ throw this.createScopeRetirementError();
8703
+ }
8704
+ try {
8705
+ await drain;
8706
+ } catch {
8707
+ // Fenced cleanup ownership remains available to explicit retirement or disposal.
8708
+ }
8709
+ throw this.createScopeRetirementError();
8710
+ }
8174
8711
  if (
8175
8712
  admission.retiring
8176
8713
  || admission.generation !== generation
@@ -8205,6 +8742,29 @@ export class FlexHarness<TScope = unknown> {
8205
8742
  return this.trustInternalError(new FlexHarnessAbortError(scopeRetirementMessage));
8206
8743
  }
8207
8744
 
8745
+ private ambientSlashCommandOwner(
8746
+ criteria: { scopeId?: string; storageKey?: string; sessionId?: string } = {},
8747
+ ): ISlashCommandInvocationOwner | ISlashCommandActivityOwner | undefined {
8748
+ const matches = (owner: ISlashCommandInvocationOwner | ISlashCommandActivityOwner) => (
8749
+ (criteria.scopeId === undefined || owner.scopeId === criteria.scopeId)
8750
+ && (criteria.storageKey === undefined || owner.storageKey === criteria.storageKey)
8751
+ && (criteria.sessionId === undefined || owner.sessionId === criteria.sessionId)
8752
+ );
8753
+ const invocationOwner = this.slashCommandInvocationContext.getStore();
8754
+ if (invocationOwner && matches(invocationOwner)) return invocationOwner;
8755
+ const activityOwner = this.slashCommandActivityContext.getStore();
8756
+ return activityOwner && matches(activityOwner) ? activityOwner : undefined;
8757
+ }
8758
+
8759
+ private assertNoAmbientSlashCommandTeardown(
8760
+ criteria: { scopeId?: string; storageKey?: string; sessionId?: string } = {},
8761
+ ): void {
8762
+ const owner = this.ambientSlashCommandOwner(criteria);
8763
+ if (owner) {
8764
+ throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(owner.sessionId));
8765
+ }
8766
+ }
8767
+
8208
8768
  private assertStateAcceptingWork(state: IStorageState): void {
8209
8769
  if (state.lifecycle !== 'active' || this.storageDrains.has(state.storageKey)) {
8210
8770
  throw this.createScopeRetirementError();
@@ -8259,8 +8819,7 @@ export class FlexHarness<TScope = unknown> {
8259
8819
  }
8260
8820
 
8261
8821
  private visibleMessages(stored: IStoredSessionState): IFlexMessage[] {
8262
- const firstHiddenUnit = this.reversionUnit(stored, 'redo');
8263
- const firstHiddenSegment = firstHiddenUnit?.segments[0];
8822
+ const firstHiddenSegment = this.reversionGroups(stored)[stored.revertCursor]?.segments[0];
8264
8823
  if (!firstHiddenSegment) return stored.messages;
8265
8824
  const boundary = stored.messages.findIndex((message) => message.runId === firstHiddenSegment.runId);
8266
8825
  return boundary < 0 ? stored.messages : stored.messages.slice(0, boundary);