@modelprofile.com/flexharness 3.7.0 → 4.0.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,16 +18,23 @@ import {
18
18
  errorToInfo,
19
19
  } from './errors.js';
20
20
  import {
21
+ FLEX_PROJECT_MANAGEMENT_LIMITS,
22
+ FLEX_REVERSION_MAX_AFFECTED_WORKSPACES,
21
23
  FLEX_REVERSION_DEFAULT_LIMITS,
22
24
  FLEX_REVERSION_MAXIMUM_LIMITS,
25
+ FLEX_REVERSION_REASON_CODE_MAX_BYTES,
23
26
  FLEX_REVERSION_REFERENCE_MAX_BYTES,
27
+ FLEX_REVERSION_WORKSPACE_ID_MAX_BYTES,
28
+ FLEX_REVERSION_WORKSPACE_LABEL_MAX_BYTES,
24
29
  } from './interfaces.js';
25
30
  import type {
26
31
  IFlexAgentContextInvocation,
27
32
  IFlexAgentSessionPolicy,
33
+ IFlexAffectedWorkspace,
28
34
  IFlexAttachmentMessagePart,
29
35
  IFlexBackgroundExecution,
30
36
  IFlexCallbackLimits,
37
+ IFlexCreateProjectTaskInput,
31
38
  IFlexCreateSessionOptions,
32
39
  IFlexErrorInfo,
33
40
  IFlexEventArchiveMetadata,
@@ -42,6 +49,15 @@ import type {
42
49
  IFlexPermissionRequest,
43
50
  IFlexPermissionRequestInput,
44
51
  IFlexPermissionSnapshot,
52
+ IFlexProjectGoalResult,
53
+ IFlexProjectManagementSnapshot,
54
+ IFlexProjectManagementTombstone,
55
+ IFlexProjectManagementWriteContext,
56
+ IFlexProjectScratchpadResult,
57
+ IFlexProjectStateResult,
58
+ IFlexProjectTask,
59
+ IFlexProjectTaskResult,
60
+ IFlexProjectTasksResult,
45
61
  IFlexPromptAdmission,
46
62
  IFlexPromptQueueAdmission,
47
63
  IFlexPromptQueueEntry,
@@ -61,6 +77,8 @@ import type {
61
77
  IFlexSchedulePromptOptions,
62
78
  IFlexScopeSnapshot,
63
79
  IFlexSession,
80
+ IFlexSessionReversionGroup,
81
+ IFlexSessionReversionInfo,
64
82
  IFlexSessionTombstone,
65
83
  IFlexSlashCommandDescriptor,
66
84
  IFlexSlashCommandExecutionOptions,
@@ -74,6 +92,7 @@ import type {
74
92
  IFlexUncertainToolExecution,
75
93
  IFlexUndoSessionResult,
76
94
  IFlexUpdateSessionOptions,
95
+ IFlexUpdateProjectTaskInput,
77
96
  IFlexUsage,
78
97
  IJsonObject,
79
98
  TFlexAgentRunResult,
@@ -86,6 +105,8 @@ import type {
86
105
  TFlexHarnessEventListener,
87
106
  TFlexMessagePart,
88
107
  TFlexPermissionDecision,
108
+ TFlexProjectTaskPriority,
109
+ TFlexProjectTaskStatus,
89
110
  TFlexPrompt,
90
111
  TFlexPromptPart,
91
112
  TFlexPromptQueueStatus,
@@ -93,6 +114,9 @@ import type {
93
114
  TFlexSlashCommandRegistration,
94
115
  TFlexPendingReversion,
95
116
  TFlexProjectionSnapshot,
117
+ TFlexReversionPolicy,
118
+ TFlexSessionReversionGroupKind,
119
+ TFlexTurnReversionFinalizedOutcome,
96
120
  TFlexToolExecutionReconciliation,
97
121
  TJsonValue,
98
122
  } from './interfaces.js';
@@ -104,6 +128,11 @@ import {
104
128
  normalizeJsonValue,
105
129
  resolveJsonLimits,
106
130
  } from './utils.json.js';
131
+ import {
132
+ assertFlexProjectManagementRecord,
133
+ assertFlexProjectManagementSnapshot,
134
+ createEmptyFlexProjectManagementSnapshot,
135
+ } from './utils.projectmanagement.js';
107
136
  import {
108
137
  hydrateAgentMessages,
109
138
  normalizeFlexPrompt,
@@ -138,7 +167,7 @@ interface IStoredSessionState {
138
167
  excludedRunIds: string[];
139
168
  pendingReversion?: TFlexPendingReversion;
140
169
  pendingReversionReleases: IFlexPendingReversionRelease[];
141
- projectionSchemaVersion: 1 | 2;
170
+ projectionSchemaVersion: 1 | 2 | 3;
142
171
  projectionBaseline?: TFlexProjectionSnapshot;
143
172
  projectionReconciliationRequired: boolean;
144
173
  projectionRevision: number;
@@ -168,6 +197,18 @@ interface IRetainedSessionCleanup {
168
197
  domainsCompleted: boolean;
169
198
  }
170
199
 
200
+ interface IReversionGroup {
201
+ target: IFlexReversionSegment;
202
+ segments: IFlexReversionSegment[];
203
+ kind: TFlexSessionReversionGroupKind;
204
+ affectedWorkspaces: IFlexAffectedWorkspace[];
205
+ affectedWorkspacesTruncated: boolean;
206
+ }
207
+
208
+ type TNormalizedCaptureInspection =
209
+ | { status: 'missing' | 'prepared' | 'unknown' }
210
+ | { status: 'finalized'; outcome: TFlexTurnReversionFinalizedOutcome };
211
+
171
212
  interface IDetachedCleanup {
172
213
  cleanup: () => Promise<void> | void;
173
214
  projectError: (error: unknown) => Error;
@@ -332,6 +373,12 @@ interface ISlashCommandInvocationOwner {
332
373
  sessionKey: string;
333
374
  }
334
375
 
376
+ interface ISlashCommandActivityOwner {
377
+ scopeId: string;
378
+ storageKey: string;
379
+ sessionId: string;
380
+ }
381
+
335
382
  interface IRunResultProjection {
336
383
  text: string;
337
384
  steps: number;
@@ -369,6 +416,47 @@ interface IFlexSubagentAcquisition {
369
416
  created: boolean;
370
417
  }
371
418
 
419
+ interface INormalizedProjectManagementTools {
420
+ readonly task: boolean;
421
+ readonly goal: boolean;
422
+ readonly scratchpad: boolean;
423
+ }
424
+
425
+ interface INormalizedBuiltInTools {
426
+ readonly renameSession: boolean;
427
+ readonly projectManagement?: INormalizedProjectManagementTools;
428
+ }
429
+
430
+ type TProjectTaskToolInput =
431
+ | { action: 'list' }
432
+ | {
433
+ action: 'create';
434
+ id?: string;
435
+ content: string;
436
+ status?: TFlexProjectTaskStatus;
437
+ priority?: TFlexProjectTaskPriority;
438
+ }
439
+ | {
440
+ action: 'update';
441
+ id: string;
442
+ content?: string;
443
+ status?: TFlexProjectTaskStatus;
444
+ priority?: TFlexProjectTaskPriority;
445
+ }
446
+ | { action: 'delete'; id: string }
447
+ | { action: 'clear' };
448
+
449
+ type TProjectGoalToolInput =
450
+ | { action: 'get' }
451
+ | { action: 'set'; goal: string }
452
+ | { action: 'clear' };
453
+
454
+ type TProjectScratchpadToolInput =
455
+ | { action: 'get' }
456
+ | { action: 'set'; content: string }
457
+ | { action: 'append'; content: string }
458
+ | { action: 'clear' };
459
+
372
460
  type TEventDetails = Record<string, unknown> & {
373
461
  type: TFlexHarnessEvent['type'];
374
462
  };
@@ -416,6 +504,7 @@ const maxResourceToolNameBytes = 512;
416
504
  const maxSlashCommandRegistrations = 128;
417
505
  const maxSlashCommandDescriptionBytes = 2048;
418
506
  const maxSlashCommandTemplateBytes = FLEX_SLASH_COMMAND_MAX_INPUT_BYTES;
507
+ const maxProjectManagementTombstoneConflicts = 8;
419
508
  const resourceToolStemLength = 16;
420
509
  const repairCancellationMessage = 'The process stopped before this run completed.';
421
510
  const scopeRetirementMessage = 'The scope is being retired.';
@@ -427,6 +516,43 @@ const externalErrorFallback: IFlexErrorInfo = Object.freeze({
427
516
  code: 'FLEX_EXTERNAL_ERROR',
428
517
  });
429
518
 
519
+ function createSessionGenerationId(): string {
520
+ return `generation_${plugins.crypto.randomUUID()}`;
521
+ }
522
+
523
+ function deriveLegacySessionGenerationId(
524
+ storageKey: string,
525
+ sessionId: string,
526
+ createdAt: string,
527
+ ): string {
528
+ const digest = plugins.crypto.createHash('sha256').update(JSON.stringify([
529
+ 'flexharness-legacy-session-generation-v1',
530
+ storageKey,
531
+ sessionId,
532
+ createdAt,
533
+ ])).digest('hex');
534
+ return `legacy_${digest}`;
535
+ }
536
+
537
+ function requireSessionGeneration(
538
+ session: Pick<
539
+ IFlexSession | IFlexSessionTombstone,
540
+ 'sessionGenerationId' | 'sessionGenerationSequence'
541
+ >,
542
+ ): { sessionGenerationId: string; sessionGenerationSequence: number } {
543
+ if (
544
+ !session.sessionGenerationId
545
+ || !Number.isSafeInteger(session.sessionGenerationSequence)
546
+ || Number(session.sessionGenerationSequence) < 1
547
+ ) {
548
+ throw new FlexHarnessValidationError('Session generation metadata is missing.');
549
+ }
550
+ return {
551
+ sessionGenerationId: session.sessionGenerationId,
552
+ sessionGenerationSequence: session.sessionGenerationSequence as number,
553
+ };
554
+ }
555
+
430
556
  function isAsyncIterable(value: unknown): value is AsyncIterable<unknown> {
431
557
  return Boolean(
432
558
  value
@@ -635,6 +761,58 @@ function normalizeSubagents(
635
761
  return Object.freeze(normalized);
636
762
  }
637
763
 
764
+ function normalizeBuiltInTools(
765
+ options: IFlexHarnessOptions<unknown>['builtInTools'],
766
+ ): INormalizedBuiltInTools {
767
+ if (options === undefined) return Object.freeze({ renameSession: false });
768
+ if (
769
+ !options
770
+ || typeof options !== 'object'
771
+ || Array.isArray(options)
772
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(options))
773
+ ) {
774
+ throw new FlexHarnessValidationError('builtInTools must be a plain object.');
775
+ }
776
+ const unsupported = Object.keys(options)
777
+ .find((key) => !['renameSession', 'projectManagement'].includes(key));
778
+ if (unsupported) throw new FlexHarnessValidationError(`builtInTools does not support "${unsupported}".`);
779
+ if (options.renameSession !== undefined && typeof options.renameSession !== 'boolean') {
780
+ throw new FlexHarnessValidationError('builtInTools.renameSession must be a boolean.');
781
+ }
782
+ const projectManagement = options.projectManagement;
783
+ if (projectManagement === undefined) {
784
+ return Object.freeze({ renameSession: options.renameSession ?? false });
785
+ }
786
+ if (
787
+ !projectManagement
788
+ || typeof projectManagement !== 'object'
789
+ || Array.isArray(projectManagement)
790
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(projectManagement))
791
+ ) {
792
+ throw new FlexHarnessValidationError('builtInTools.projectManagement must be a plain object.');
793
+ }
794
+ const unsupportedProjectOption = Object.keys(projectManagement)
795
+ .find((key) => !['task', 'goal', 'scratchpad'].includes(key));
796
+ if (unsupportedProjectOption) {
797
+ throw new FlexHarnessValidationError(
798
+ `builtInTools.projectManagement does not support "${unsupportedProjectOption}".`,
799
+ );
800
+ }
801
+ for (const key of ['task', 'goal', 'scratchpad'] as const) {
802
+ if (projectManagement[key] !== undefined && typeof projectManagement[key] !== 'boolean') {
803
+ throw new FlexHarnessValidationError(`builtInTools.projectManagement.${key} must be a boolean.`);
804
+ }
805
+ }
806
+ return Object.freeze({
807
+ renameSession: options.renameSession ?? false,
808
+ projectManagement: Object.freeze({
809
+ task: projectManagement.task ?? true,
810
+ goal: projectManagement.goal ?? true,
811
+ scratchpad: projectManagement.scratchpad ?? true,
812
+ }),
813
+ });
814
+ }
815
+
638
816
  function normalizeSlashCommands<TScope>(
639
817
  registrations: readonly TFlexSlashCommandRegistration<TScope>[] | undefined,
640
818
  ): ReadonlyMap<string, TRegisteredSlashCommand<TScope>> {
@@ -969,13 +1147,81 @@ function validateUpdateSessionOptions(options: IFlexUpdateSessionOptions): void
969
1147
  const unsupportedKey = keys.find((key) => key !== 'title' && key !== 'archived');
970
1148
  if (unsupportedKey) throw new FlexHarnessValidationError(`updateSession does not support "${unsupportedKey}".`);
971
1149
  if (Object.prototype.hasOwnProperty.call(options, 'title') && options.title !== null) {
972
- validateIdentifier(options.title!, 'title');
1150
+ validateUtf8String(
1151
+ options.title,
1152
+ 'title',
1153
+ FLEX_PROJECT_MANAGEMENT_LIMITS.maxTitleBytes,
1154
+ true,
1155
+ );
973
1156
  }
974
1157
  if (Object.prototype.hasOwnProperty.call(options, 'archived') && typeof options.archived !== 'boolean') {
975
1158
  throw new FlexHarnessValidationError('archived must be a boolean.');
976
1159
  }
977
1160
  }
978
1161
 
1162
+ function validateProjectTaskStatus(value: unknown): asserts value is TFlexProjectTaskStatus {
1163
+ if (!['pending', 'in_progress', 'completed', 'cancelled'].includes(String(value))) {
1164
+ throw new FlexHarnessValidationError('Project task status is invalid.');
1165
+ }
1166
+ }
1167
+
1168
+ function validateProjectTaskPriority(value: unknown): asserts value is TFlexProjectTaskPriority {
1169
+ if (!['high', 'medium', 'low'].includes(String(value))) {
1170
+ throw new FlexHarnessValidationError('Project task priority is invalid.');
1171
+ }
1172
+ }
1173
+
1174
+ function validateProjectTaskId(value: unknown): asserts value is string {
1175
+ validateUtf8String(
1176
+ value,
1177
+ 'project task id',
1178
+ FLEX_PROJECT_MANAGEMENT_LIMITS.maxTaskIdBytes,
1179
+ true,
1180
+ );
1181
+ }
1182
+
1183
+ function validateProjectTaskContent(value: unknown): asserts value is string {
1184
+ validateUtf8String(
1185
+ value,
1186
+ 'project task content',
1187
+ FLEX_PROJECT_MANAGEMENT_LIMITS.maxTaskContentBytes,
1188
+ true,
1189
+ );
1190
+ }
1191
+
1192
+ function validateCreateProjectTaskInput(input: IFlexCreateProjectTaskInput): void {
1193
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
1194
+ throw new FlexHarnessValidationError('Project task create input must be a plain object.');
1195
+ }
1196
+ const unsupported = Object.keys(input)
1197
+ .find((key) => !['id', 'content', 'status', 'priority'].includes(key));
1198
+ if (unsupported) {
1199
+ throw new FlexHarnessValidationError(`Project task create input does not support "${unsupported}".`);
1200
+ }
1201
+ validateProjectTaskId(input.id);
1202
+ validateProjectTaskContent(input.content);
1203
+ if (input.status !== undefined) validateProjectTaskStatus(input.status);
1204
+ if (input.priority !== undefined) validateProjectTaskPriority(input.priority);
1205
+ }
1206
+
1207
+ function validateUpdateProjectTaskInput(input: IFlexUpdateProjectTaskInput): void {
1208
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
1209
+ throw new FlexHarnessValidationError('Project task update input must be a plain object.');
1210
+ }
1211
+ const keys = Object.keys(input);
1212
+ const unsupported = keys.find((key) => !['id', 'content', 'status', 'priority'].includes(key));
1213
+ if (unsupported) {
1214
+ throw new FlexHarnessValidationError(`Project task update input does not support "${unsupported}".`);
1215
+ }
1216
+ if (!keys.some((key) => key !== 'id')) {
1217
+ throw new FlexHarnessValidationError('Project task update requires a changed field.');
1218
+ }
1219
+ validateProjectTaskId(input.id);
1220
+ if (input.content !== undefined) validateProjectTaskContent(input.content);
1221
+ if (input.status !== undefined) validateProjectTaskStatus(input.status);
1222
+ if (input.priority !== undefined) validateProjectTaskPriority(input.priority);
1223
+ }
1224
+
979
1225
  function validatePromptOptions(options: IFlexPromptOptions, scheduled: boolean): void {
980
1226
  if (!options || typeof options !== 'object' || Array.isArray(options)) {
981
1227
  throw new FlexHarnessValidationError('Prompt options must be a plain object.');
@@ -1100,6 +1346,22 @@ function normalizeAgentSessionPolicy<TScope>(
1100
1346
  };
1101
1347
  }
1102
1348
 
1349
+ function requireHarnessStores(stores: IFlexHarnessStores): IFlexHarnessStores {
1350
+ const projectManagement = stores?.projectManagement;
1351
+ if (
1352
+ !projectManagement
1353
+ || typeof projectManagement.load !== 'function'
1354
+ || typeof projectManagement.save !== 'function'
1355
+ || typeof projectManagement.tombstoneSession !== 'function'
1356
+ || typeof projectManagement.purgeNamespace !== 'function'
1357
+ ) {
1358
+ throw new FlexHarnessValidationError(
1359
+ 'stores.projectManagement requires load/save/tombstoneSession/purgeNamespace methods.',
1360
+ );
1361
+ }
1362
+ return stores;
1363
+ }
1364
+
1103
1365
  export class FlexHarness<TScope = unknown> {
1104
1366
  private readonly scopeResolver: IFlexHarnessOptions<TScope>['scopeResolver'];
1105
1367
  private readonly modelResolver: IFlexHarnessOptions<TScope>['modelResolver'];
@@ -1113,8 +1375,10 @@ export class FlexHarness<TScope = unknown> {
1113
1375
  private readonly promptQueueLimits: Required<IFlexPromptQueueLimits>;
1114
1376
  private readonly reversionLimits: Required<IFlexReversionLimits>;
1115
1377
  private readonly turnReversionProvider: IFlexHarnessOptions<TScope>['turnReversionProvider'];
1378
+ private readonly reversionPolicy: TFlexReversionPolicy;
1116
1379
  private readonly externalErrorProjector?: TFlexExternalErrorProjector;
1117
1380
  private readonly subagents: ReadonlyMap<string, Readonly<IFlexSubagentDefinition>>;
1381
+ private readonly builtInTools: INormalizedBuiltInTools;
1118
1382
  private readonly slashCommands: ReadonlyMap<string, TRegisteredSlashCommand<TScope>>;
1119
1383
  private readonly maxSubagentDepth: number;
1120
1384
  private readonly maxSubagentCallsPerRun: number;
@@ -1135,6 +1399,7 @@ export class FlexHarness<TScope = unknown> {
1135
1399
  private readonly orphanedTombstoneCleanups = new Map<string, IOrphanedTombstoneCleanup>();
1136
1400
  private readonly orphanedTombstoneOwners = new Map<string, IOrphanedTombstoneOwner<TScope>>();
1137
1401
  private readonly pendingPromptAdmissionOwners = new Set<IPendingPromptAdmission>();
1402
+ private readonly projectManagementQueues = new Map<string, Promise<void>>();
1138
1403
  private readonly activeSlashCommandExecutions = new Map<string, IActiveSlashCommandExecution>();
1139
1404
  private readonly activeSlashCommandListings = new Set<IActiveSlashCommandListing>();
1140
1405
  private readonly compactorInvocationContext = new plugins.AsyncLocalStorage<
@@ -1143,6 +1408,9 @@ export class FlexHarness<TScope = unknown> {
1143
1408
  private readonly slashCommandInvocationContext = new plugins.AsyncLocalStorage<
1144
1409
  ISlashCommandInvocationOwner
1145
1410
  >();
1411
+ private readonly slashCommandActivityContext = new plugins.AsyncLocalStorage<
1412
+ ISlashCommandActivityOwner
1413
+ >();
1146
1414
  private readonly deferredCompactorContexts = new WeakMap<
1147
1415
  IFlexAgentContextInvocation<unknown>,
1148
1416
  IFlexAgentContextInvocation<unknown>
@@ -1163,15 +1431,34 @@ export class FlexHarness<TScope = unknown> {
1163
1431
  this.toolProvider = options.toolProvider;
1164
1432
  this.resourceToolProviderResolver = options.resourceToolProviderResolver;
1165
1433
  this.executionContextProvider = options.executionContextProvider;
1166
- this.stores = options.stores ?? new InMemoryFlexHarnessStores();
1434
+ this.stores = requireHarnessStores(options.stores ?? new InMemoryFlexHarnessStores());
1167
1435
  this.agentSessionPolicy = normalizeAgentSessionPolicy(options.agentSessionPolicy);
1168
1436
  this.toolOutputLimits = resolveJsonLimits(options.toolOutputLimits);
1169
1437
  this.callbackLimits = resolveCallbackLimits(options.callbackLimits);
1170
1438
  this.promptQueueLimits = resolvePromptQueueLimits(options.promptQueueLimits);
1171
1439
  this.reversionLimits = resolveReversionLimits(options.reversionLimits);
1172
1440
  this.turnReversionProvider = options.turnReversionProvider;
1441
+ this.reversionPolicy = options.reversionPolicy ?? 'transcript-optional';
1442
+ if (!['transcript-optional', 'workspace-required'].includes(this.reversionPolicy)) {
1443
+ throw new FlexHarnessValidationError(
1444
+ 'reversionPolicy must be "transcript-optional" or "workspace-required".',
1445
+ );
1446
+ }
1447
+ const protocolVersion = this.turnReversionProvider
1448
+ && 'protocolVersion' in this.turnReversionProvider
1449
+ ? this.turnReversionProvider.protocolVersion
1450
+ : 1;
1451
+ if (protocolVersion !== 1 && protocolVersion !== 2) {
1452
+ throw new FlexHarnessValidationError('Turn reversion provider protocolVersion is invalid.');
1453
+ }
1454
+ if (this.reversionPolicy === 'workspace-required' && protocolVersion !== 2) {
1455
+ throw new FlexHarnessValidationError(
1456
+ 'workspace-required reversion policy requires a protocolVersion 2 turn reversion provider.',
1457
+ );
1458
+ }
1173
1459
  this.externalErrorProjector = options.externalErrorProjector;
1174
1460
  this.subagents = normalizeSubagents(options.subagents);
1461
+ this.builtInTools = normalizeBuiltInTools(options.builtInTools);
1175
1462
  this.slashCommands = normalizeSlashCommands(options.slashCommands);
1176
1463
  this.maxSubagentDepth = resolveBoundedPositiveInteger(
1177
1464
  options.maxSubagentDepth,
@@ -1211,7 +1498,14 @@ export class FlexHarness<TScope = unknown> {
1211
1498
  const sessionId = options.sessionId ?? plugins.crypto.randomUUID();
1212
1499
  validateIdentifier(sessionId, 'sessionId');
1213
1500
  requireTransferIdentifier(sessionId, 'sessionId');
1214
- if (options.title !== undefined) validateIdentifier(options.title, 'title');
1501
+ if (options.title !== undefined) {
1502
+ validateUtf8String(
1503
+ options.title,
1504
+ 'title',
1505
+ FLEX_PROJECT_MANAGEMENT_LIMITS.maxTitleBytes,
1506
+ true,
1507
+ );
1508
+ }
1215
1509
  await resolved.state.scopeQueue;
1216
1510
  if (
1217
1511
  resolved.state.sessions.has(sessionId)
@@ -1246,6 +1540,8 @@ export class FlexHarness<TScope = unknown> {
1246
1540
  metadata = {
1247
1541
  scopeId,
1248
1542
  sessionId,
1543
+ sessionGenerationId: createSessionGenerationId(),
1544
+ sessionGenerationSequence: resolved.state.revision + 1,
1249
1545
  ...(options.title ? { title: options.title } : {}),
1250
1546
  createdAt: timestamp,
1251
1547
  updatedAt: timestamp,
@@ -1291,6 +1587,7 @@ export class FlexHarness<TScope = unknown> {
1291
1587
  if (!scopeReserved) throw projectedError;
1292
1588
  const tombstone: IFlexSessionTombstone = {
1293
1589
  sessionId,
1590
+ ...requireSessionGeneration(metadata),
1294
1591
  deletedAt: new Date().toISOString(),
1295
1592
  rootSessionId: sessionId,
1296
1593
  depth: 0,
@@ -1357,6 +1654,41 @@ export class FlexHarness<TScope = unknown> {
1357
1654
  return publicSnapshot(this.requireSession(state, sessionId).session);
1358
1655
  }
1359
1656
 
1657
+ public async getSessionReversionInfo(
1658
+ scopeId: string,
1659
+ sessionId: string,
1660
+ ): Promise<IFlexSessionReversionInfo> {
1661
+ const { state } = await this.resolveState(scopeId);
1662
+ const stored = this.requireSession(state, sessionId);
1663
+ await stored.projectionQueue;
1664
+ this.assertStateAcceptingWork(state);
1665
+ await this.reconcileContextAvailability(state, stored);
1666
+ const unavailableReason = await this.reversionUnavailableReason(state, stored);
1667
+ const groups = this.reversionGroups(stored);
1668
+ const undo = this.reversionUnit(stored, 'undo');
1669
+ const redo = this.reversionUnit(stored, 'redo');
1670
+ const isAvailable = (unit: ReturnType<typeof this.reversionUnit>) => Boolean(
1671
+ !unavailableReason
1672
+ && unit
1673
+ && unit.target.contextAvailable
1674
+ && !unit.segments.some((segment) =>
1675
+ segment.provenance === 'workspace'
1676
+ && segment.disposition === 'revertible'
1677
+ && segment.workspaceReference === undefined),
1678
+ );
1679
+ return publicSnapshot({
1680
+ undoAvailable: isAvailable(undo),
1681
+ redoAvailable: isAvailable(redo),
1682
+ groups: groups.map((group, index): IFlexSessionReversionGroup => ({
1683
+ runId: group.target.runId,
1684
+ kind: group.kind,
1685
+ visibility: index < stored.revertCursor ? 'visible' : 'hidden',
1686
+ affectedWorkspaces: cloneSerializable(group.affectedWorkspaces),
1687
+ affectedWorkspacesTruncated: group.affectedWorkspacesTruncated,
1688
+ })),
1689
+ });
1690
+ }
1691
+
1360
1692
  public async updateSession(
1361
1693
  scopeId: string,
1362
1694
  sessionId: string,
@@ -1383,15 +1715,185 @@ export class FlexHarness<TScope = unknown> {
1383
1715
  return result;
1384
1716
  }
1385
1717
 
1718
+ public async getProjectState(
1719
+ scopeId: string,
1720
+ sessionId: string,
1721
+ ): Promise<IFlexProjectStateResult> {
1722
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1723
+ const snapshot = await this.readProjectManagementState(state, stored);
1724
+ return this.projectStateResult(snapshot);
1725
+ }
1726
+
1727
+ public async listProjectTasks(
1728
+ scopeId: string,
1729
+ sessionId: string,
1730
+ ): Promise<IFlexProjectTasksResult> {
1731
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1732
+ const snapshot = await this.readProjectManagementState(state, stored);
1733
+ return this.projectTasksResult(snapshot, snapshot.tasks);
1734
+ }
1735
+
1736
+ public async createProjectTask(
1737
+ scopeId: string,
1738
+ sessionId: string,
1739
+ input: IFlexCreateProjectTaskInput,
1740
+ ): Promise<IFlexProjectTaskResult> {
1741
+ validateCreateProjectTaskInput(input);
1742
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1743
+ return this.createProjectTaskInState(
1744
+ state,
1745
+ stored,
1746
+ input,
1747
+ Object.freeze({ actor: 'application' }),
1748
+ );
1749
+ }
1750
+
1751
+ public async updateProjectTask(
1752
+ scopeId: string,
1753
+ sessionId: string,
1754
+ input: IFlexUpdateProjectTaskInput,
1755
+ ): Promise<IFlexProjectTaskResult> {
1756
+ validateUpdateProjectTaskInput(input);
1757
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1758
+ return this.updateProjectTaskInState(
1759
+ state,
1760
+ stored,
1761
+ input,
1762
+ Object.freeze({ actor: 'application' }),
1763
+ );
1764
+ }
1765
+
1766
+ public async deleteProjectTask(
1767
+ scopeId: string,
1768
+ sessionId: string,
1769
+ taskId: string,
1770
+ ): Promise<IFlexProjectTaskResult> {
1771
+ validateProjectTaskId(taskId);
1772
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1773
+ return this.deleteProjectTaskInState(
1774
+ state,
1775
+ stored,
1776
+ taskId,
1777
+ Object.freeze({ actor: 'application' }),
1778
+ );
1779
+ }
1780
+
1781
+ public async clearProjectTasks(
1782
+ scopeId: string,
1783
+ sessionId: string,
1784
+ ): Promise<IFlexProjectTasksResult> {
1785
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1786
+ return this.clearProjectTasksInState(
1787
+ state,
1788
+ stored,
1789
+ Object.freeze({ actor: 'application' }),
1790
+ );
1791
+ }
1792
+
1793
+ public async getProjectGoal(
1794
+ scopeId: string,
1795
+ sessionId: string,
1796
+ ): Promise<IFlexProjectGoalResult> {
1797
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1798
+ const snapshot = await this.readProjectManagementState(state, stored);
1799
+ return this.projectGoalResult(snapshot);
1800
+ }
1801
+
1802
+ public async setProjectGoal(
1803
+ scopeId: string,
1804
+ sessionId: string,
1805
+ goal: string,
1806
+ ): Promise<IFlexProjectGoalResult> {
1807
+ validateUtf8String(
1808
+ goal,
1809
+ 'project goal',
1810
+ FLEX_PROJECT_MANAGEMENT_LIMITS.maxGoalBytes,
1811
+ true,
1812
+ );
1813
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1814
+ return this.setProjectGoalInState(
1815
+ state,
1816
+ stored,
1817
+ goal,
1818
+ Object.freeze({ actor: 'application' }),
1819
+ );
1820
+ }
1821
+
1822
+ public async clearProjectGoal(
1823
+ scopeId: string,
1824
+ sessionId: string,
1825
+ ): Promise<IFlexProjectGoalResult> {
1826
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1827
+ return this.clearProjectGoalInState(
1828
+ state,
1829
+ stored,
1830
+ Object.freeze({ actor: 'application' }),
1831
+ );
1832
+ }
1833
+
1834
+ public async getProjectScratchpad(
1835
+ scopeId: string,
1836
+ sessionId: string,
1837
+ ): Promise<IFlexProjectScratchpadResult> {
1838
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1839
+ const snapshot = await this.readProjectManagementState(state, stored);
1840
+ return this.projectScratchpadResult(snapshot);
1841
+ }
1842
+
1843
+ public async setProjectScratchpad(
1844
+ scopeId: string,
1845
+ sessionId: string,
1846
+ content: string,
1847
+ ): Promise<IFlexProjectScratchpadResult> {
1848
+ validateUtf8String(
1849
+ content,
1850
+ 'project scratchpad',
1851
+ FLEX_PROJECT_MANAGEMENT_LIMITS.maxScratchpadBytes,
1852
+ );
1853
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1854
+ return this.setProjectScratchpadInState(
1855
+ state,
1856
+ stored,
1857
+ content,
1858
+ Object.freeze({ actor: 'application' }),
1859
+ );
1860
+ }
1861
+
1862
+ public async appendProjectScratchpad(
1863
+ scopeId: string,
1864
+ sessionId: string,
1865
+ content: string,
1866
+ ): Promise<IFlexProjectScratchpadResult> {
1867
+ validateUtf8String(
1868
+ content,
1869
+ 'project scratchpad append',
1870
+ FLEX_PROJECT_MANAGEMENT_LIMITS.maxScratchpadBytes,
1871
+ );
1872
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1873
+ return this.appendProjectScratchpadInState(
1874
+ state,
1875
+ stored,
1876
+ content,
1877
+ Object.freeze({ actor: 'application' }),
1878
+ );
1879
+ }
1880
+
1881
+ public async clearProjectScratchpad(
1882
+ scopeId: string,
1883
+ sessionId: string,
1884
+ ): Promise<IFlexProjectScratchpadResult> {
1885
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1886
+ return this.clearProjectScratchpadInState(
1887
+ state,
1888
+ stored,
1889
+ Object.freeze({ actor: 'application' }),
1890
+ );
1891
+ }
1892
+
1386
1893
  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
- }
1894
+ this.assertNoAmbientSlashCommandTeardown({ scopeId, sessionId });
1391
1895
  const { scope, state } = await this.resolveState(scopeId);
1392
- if (invocationOwner?.storageKey === state.storageKey) {
1393
- throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(invocationOwner.sessionId));
1394
- }
1896
+ this.assertNoAmbientSlashCommandTeardown({ storageKey: state.storageKey });
1395
1897
  validateIdentifier(sessionId, 'sessionId');
1396
1898
  await state.scopeQueue;
1397
1899
  const deletionKey = state.tombstones.get(sessionId)?.rootSessionId ?? sessionId;
@@ -1456,6 +1958,7 @@ export class FlexHarness<TScope = unknown> {
1456
1958
  });
1457
1959
  state.tombstones.set(entrySessionId, {
1458
1960
  sessionId: entrySessionId,
1961
+ ...requireSessionGeneration(entry.session),
1459
1962
  deletedAt,
1460
1963
  rootSessionId: sessionId,
1461
1964
  depth: (entry.session.depth ?? rootDepth) - rootDepth,
@@ -1606,7 +2109,7 @@ export class FlexHarness<TScope = unknown> {
1606
2109
  sessionId: string,
1607
2110
  ): Promise<IFlexSlashCommandDescriptor[]> {
1608
2111
  const { state } = await this.resolveState(scopeId);
1609
- return this.trackSlashCommandListing(state, sessionId, async (signal) => {
2112
+ return this.trackSlashCommandListing(scopeId, state, sessionId, async (signal) => {
1610
2113
  await this.awaitReversionOperation(state.scopeQueue, signal);
1611
2114
  signal.throwIfAborted();
1612
2115
  this.assertStateAcceptingWork(state);
@@ -1644,7 +2147,7 @@ export class FlexHarness<TScope = unknown> {
1644
2147
  const workspaceReversion = (unit: ReturnType<typeof this.reversionUnit>) =>
1645
2148
  !unit
1646
2149
  || !this.turnReversionProvider
1647
- || unit.segments.some((segment) => !segment.workspaceCaptured)
2150
+ || unit.segments.some((segment) => segment.provenance !== 'workspace')
1648
2151
  ? 'unsupported' as const
1649
2152
  : 'supported' as const;
1650
2153
  const reversionAvailability = (unit: ReturnType<typeof this.reversionUnit>) => {
@@ -1658,7 +2161,7 @@ export class FlexHarness<TScope = unknown> {
1658
2161
  unavailableReason: 'The turn is beyond the context archive horizon.',
1659
2162
  };
1660
2163
  if (unit.segments.some((segment) =>
1661
- segment.workspaceCaptured && segment.workspaceReference === undefined)) return {
2164
+ segment.disposition === 'revertible' && segment.workspaceReference === undefined)) return {
1662
2165
  available: false,
1663
2166
  unavailableReason: 'The turn has no available workspace reversion capture.',
1664
2167
  };
@@ -1802,6 +2305,7 @@ export class FlexHarness<TScope = unknown> {
1802
2305
  }
1803
2306
  this.requireSessionAvailableForSlashCommand(state, stored, parsed.name);
1804
2307
  return this.trackSlashCommandExecution(
2308
+ scopeId,
1805
2309
  state,
1806
2310
  sessionId,
1807
2311
  'operation',
@@ -1829,6 +2333,7 @@ export class FlexHarness<TScope = unknown> {
1829
2333
  this.promptAdmissionByteSize(expanded, promptOptions),
1830
2334
  );
1831
2335
  return this.trackSlashCommandExecution(
2336
+ scopeId,
1832
2337
  state,
1833
2338
  sessionId,
1834
2339
  'prompt-admission',
@@ -1925,7 +2430,7 @@ export class FlexHarness<TScope = unknown> {
1925
2430
  )) return 'Session already has an active slash command execution.';
1926
2431
  if (stored.pendingReversion) return 'Session has a pending reversion operation.';
1927
2432
  if (stored.session.agent !== undefined) {
1928
- return 'Subagent sessions can only be prompted through the foreground task tool.';
2433
+ return 'Subagent sessions can only be prompted through the foreground delegate tool.';
1929
2434
  }
1930
2435
  if (stored.outstandingPromptsById.size >= this.promptQueueLimits.maxOutstandingPromptsPerSession) {
1931
2436
  return 'Session has reached its outstanding prompt limit.';
@@ -2001,6 +2506,7 @@ export class FlexHarness<TScope = unknown> {
2001
2506
  }
2002
2507
 
2003
2508
  private trackSlashCommandExecution<TResult>(
2509
+ scopeId: string,
2004
2510
  state: IStorageState,
2005
2511
  sessionId: string,
2006
2512
  kind: IActiveSlashCommandExecution['kind'],
@@ -2024,10 +2530,11 @@ export class FlexHarness<TScope = unknown> {
2024
2530
  };
2025
2531
  externalSignal?.addEventListener('abort', abortFromExternalSignal, { once: true });
2026
2532
  if (externalSignal?.aborted) abortFromExternalSignal();
2027
- const completion = Promise.resolve().then(() => {
2533
+ const activityOwner = Object.freeze({ scopeId, storageKey: state.storageKey, sessionId });
2534
+ const completion = Promise.resolve().then(() => this.slashCommandActivityContext.run(activityOwner, () => {
2028
2535
  if (controller.signal.aborted) throw controller.signal.reason;
2029
2536
  return operation(controller.signal);
2030
- });
2537
+ }));
2031
2538
  const active: IActiveSlashCommandExecution = {
2032
2539
  kind,
2033
2540
  storageKey: state.storageKey,
@@ -2045,12 +2552,15 @@ export class FlexHarness<TScope = unknown> {
2045
2552
  }
2046
2553
 
2047
2554
  private trackSlashCommandListing<TResult>(
2555
+ scopeId: string,
2048
2556
  state: IStorageState,
2049
2557
  sessionId: string,
2050
2558
  operation: (signal: AbortSignal) => Promise<TResult>,
2051
2559
  ): Promise<TResult> {
2052
2560
  const controller = new AbortController();
2053
- const completion = Promise.resolve().then(() => operation(controller.signal));
2561
+ const activityOwner = Object.freeze({ scopeId, storageKey: state.storageKey, sessionId });
2562
+ const completion = Promise.resolve().then(() =>
2563
+ this.slashCommandActivityContext.run(activityOwner, () => operation(controller.signal)));
2054
2564
  const active: IActiveSlashCommandListing = {
2055
2565
  storageKey: state.storageKey,
2056
2566
  sessionId,
@@ -2094,6 +2604,7 @@ export class FlexHarness<TScope = unknown> {
2094
2604
  sessionKey,
2095
2605
  });
2096
2606
  return this.trackSlashCommandExecution(
2607
+ scopeId,
2097
2608
  state,
2098
2609
  stored.session.sessionId,
2099
2610
  'handler',
@@ -2403,6 +2914,7 @@ export class FlexHarness<TScope = unknown> {
2403
2914
  }
2404
2915
  this.requireSessionAvailableForSlashCommand(state, stored, 'compact');
2405
2916
  await this.trackSlashCommandExecution(
2917
+ scopeId,
2406
2918
  state,
2407
2919
  sessionId,
2408
2920
  'operation',
@@ -2464,24 +2976,77 @@ export class FlexHarness<TScope = unknown> {
2464
2976
  stored: IStoredSessionState,
2465
2977
  direction: 'undo' | 'redo',
2466
2978
  ): { 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;
2979
+ const groups = this.reversionGroups(stored);
2980
+ if (direction === 'undo') {
2981
+ let start = stored.revertCursor - 1;
2982
+ while (start >= 0 && groups[start]?.kind === 'no-change') start--;
2983
+ const candidate = groups[start];
2984
+ if (!candidate || candidate.kind !== 'candidate') return undefined;
2985
+ return {
2986
+ target: candidate.target,
2987
+ segments: groups.slice(start, stored.revertCursor).flatMap((group) => group.segments),
2988
+ toCursor: start,
2989
+ };
2990
+ }
2991
+ const candidate = groups[stored.revertCursor];
2992
+ if (!candidate || candidate.kind !== 'candidate') return undefined;
2993
+ let end = stored.revertCursor + 1;
2994
+ while (groups[end]?.kind === 'no-change') end++;
2478
2995
  return {
2479
- target: candidate,
2480
- segments: stored.reversionSegments.slice(start, end),
2481
- toCursor: stored.revertCursor + (direction === 'undo' ? -1 : 1),
2996
+ target: candidate.target,
2997
+ segments: groups.slice(stored.revertCursor, end).flatMap((group) => group.segments),
2998
+ toCursor: end,
2482
2999
  };
2483
3000
  }
2484
3001
 
3002
+ private reversionGroups(stored: IStoredSessionState): IReversionGroup[] {
3003
+ const groups: IReversionGroup[] = [];
3004
+ let leading: IFlexReversionSegment[] = [];
3005
+ for (const segment of stored.reversionSegments) {
3006
+ if (segment.status === 'completed') {
3007
+ const segments = groups.length === 0 ? [...leading, segment] : [segment];
3008
+ leading = [];
3009
+ groups.push({
3010
+ target: segment,
3011
+ segments,
3012
+ kind: 'candidate',
3013
+ affectedWorkspaces: [],
3014
+ affectedWorkspacesTruncated: false,
3015
+ });
3016
+ } else if (groups.length > 0) {
3017
+ groups[groups.length - 1].segments.push(segment);
3018
+ } else {
3019
+ leading.push(segment);
3020
+ }
3021
+ }
3022
+ for (const group of groups) {
3023
+ const affected = new Map<string, IFlexAffectedWorkspace>();
3024
+ for (const workspace of group.segments.flatMap((segment) =>
3025
+ segment.affectedWorkspaces ?? [])) {
3026
+ if (affected.has(workspace.id)) continue;
3027
+ if (affected.size < FLEX_REVERSION_MAX_AFFECTED_WORKSPACES) {
3028
+ affected.set(workspace.id, cloneSerializable(workspace));
3029
+ } else {
3030
+ group.affectedWorkspacesTruncated = true;
3031
+ }
3032
+ }
3033
+ group.affectedWorkspaces = [...affected.values()];
3034
+ if (this.reversionPolicy === 'transcript-optional') continue;
3035
+ if (group.segments.some((segment) =>
3036
+ segment.status === 'capturing'
3037
+ || segment.disposition === 'pending'
3038
+ || (segment.protocolVersion === 1 && segment.provenance === 'transcript')
3039
+ || segment.disposition === 'nonrevertible')) {
3040
+ group.kind = 'barrier';
3041
+ } else if (group.segments.some((segment) => segment.disposition === 'revertible')) {
3042
+ group.kind = 'candidate';
3043
+ } else {
3044
+ group.kind = 'no-change';
3045
+ }
3046
+ }
3047
+ return groups;
3048
+ }
3049
+
2485
3050
  private async requireReversionIdle(
2486
3051
  state: IStorageState,
2487
3052
  stored: IStoredSessionState,
@@ -2587,6 +3152,7 @@ export class FlexHarness<TScope = unknown> {
2587
3152
  throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(sessionId));
2588
3153
  }
2589
3154
  return this.trackSlashCommandExecution(
3155
+ scopeId,
2590
3156
  state,
2591
3157
  sessionId,
2592
3158
  'operation',
@@ -2609,8 +3175,9 @@ export class FlexHarness<TScope = unknown> {
2609
3175
  if (pending.direction !== direction) {
2610
3176
  throw new FlexHarnessSessionBusyError(sessionId, 'has a pending opposite reversion operation');
2611
3177
  }
2612
- const candidates = this.completedReversionCandidates(stored);
2613
- const target = candidates[direction === 'undo' ? pending.fromCursor - 1 : pending.fromCursor];
3178
+ const target = pending.segmentRunIds
3179
+ .map((runId) => stored.reversionSegments.find((segment) => segment.runId === runId))
3180
+ .find((segment) => segment?.status === 'completed');
2614
3181
  if (!target) throw new FlexHarnessValidationError('Pending reversion has no target candidate.');
2615
3182
  await this.resumePendingApply(state, stored, scopeId, scope, operationSignal);
2616
3183
  this.emitEvent(scopeId, sessionId, {
@@ -2634,7 +3201,7 @@ export class FlexHarness<TScope = unknown> {
2634
3201
  );
2635
3202
  }
2636
3203
  if (this.turnReversionProvider && unit.segments.some((segment) =>
2637
- segment.captureId !== undefined && segment.workspaceReference === undefined)) {
3204
+ segment.disposition === 'revertible' && segment.workspaceReference === undefined)) {
2638
3205
  throw new FlexHarnessSlashCommandUnavailableError(
2639
3206
  direction,
2640
3207
  'The turn has no available workspace reversion capture.',
@@ -2713,7 +3280,8 @@ export class FlexHarness<TScope = unknown> {
2713
3280
  if (pending?.kind !== 'apply') return;
2714
3281
  const segmentMap = new Map(stored.reversionSegments.map((segment) => [segment.runId, segment]));
2715
3282
  if (
2716
- pending.segmentRunIds.some((runId) => segmentMap.get(runId)?.workspaceCaptured)
3283
+ pending.segmentRunIds.some((runId) =>
3284
+ segmentMap.get(runId)?.disposition === 'revertible')
2717
3285
  && !this.turnReversionProvider
2718
3286
  ) {
2719
3287
  throw new FlexHarnessValidationError(
@@ -2723,18 +3291,25 @@ export class FlexHarness<TScope = unknown> {
2723
3291
  const orderedRunIds = pending.direction === 'undo'
2724
3292
  ? [...pending.segmentRunIds].reverse()
2725
3293
  : [...pending.segmentRunIds];
3294
+ let workspaceProgress = pending.appliedRunIds.some((runId) =>
3295
+ segmentMap.get(runId)?.disposition === 'revertible');
2726
3296
  for (const runId of orderedRunIds) {
2727
3297
  if (pending.appliedRunIds.includes(runId)) continue;
2728
- if (signal?.aborted) {
3298
+ if (signal?.aborted && !workspaceProgress) {
2729
3299
  await this.persistKnownNotApplied(state, stored, pending.operationId, runId);
2730
3300
  throw signal.reason;
2731
3301
  }
2732
3302
  const segment = segmentMap.get(runId);
2733
3303
  if (!segment) throw new FlexHarnessValidationError('Pending reversion references a missing segment.');
2734
- if (segment.workspaceCaptured) {
3304
+ if (segment.disposition === 'revertible') {
2735
3305
  if (!this.turnReversionProvider || !segment.captureId || segment.workspaceReference === undefined) {
2736
3306
  throw new FlexHarnessValidationError('Pending workspace reversion segment is incomplete.');
2737
3307
  }
3308
+ if (this.reversionProviderProtocolVersion() !== segment.protocolVersion) {
3309
+ throw new FlexHarnessValidationError(
3310
+ `Pending workspace reversion requires protocolVersion ${segment.protocolVersion}.`,
3311
+ );
3312
+ }
2738
3313
  const captureId = segment.captureId;
2739
3314
  const reference = segment.workspaceReference;
2740
3315
  const childOperationId = `${pending.operationId}:${sha256Hex(runId).slice(0, 16)}`;
@@ -2770,7 +3345,7 @@ export class FlexHarness<TScope = unknown> {
2770
3345
  }
2771
3346
  if (inspection.status === 'not-applied') {
2772
3347
  try {
2773
- if (signal) {
3348
+ if (signal && !workspaceProgress) {
2774
3349
  await this.turnReversionProvider.apply(createContext(signal));
2775
3350
  } else {
2776
3351
  await this.withReversionMaintenanceSignal((maintenanceSignal) =>
@@ -2811,6 +3386,7 @@ export class FlexHarness<TScope = unknown> {
2811
3386
  }
2812
3387
  if (!current.appliedRunIds.includes(runId)) current.appliedRunIds.push(runId);
2813
3388
  }, true);
3389
+ if (segment.disposition === 'revertible') workspaceProgress = true;
2814
3390
  }
2815
3391
  await this.mutateProjection(state, stored, () => {
2816
3392
  const current = stored.pendingReversion;
@@ -2834,7 +3410,10 @@ export class FlexHarness<TScope = unknown> {
2834
3410
  throw new FlexHarnessValidationError('Pending reversion changed while recording failed apply.');
2835
3411
  }
2836
3412
  current.appliedRunIds = current.appliedRunIds.filter((entry) => entry !== runId);
2837
- if (current.appliedRunIds.length === 0) {
3413
+ const hasWorkspaceProgress = current.appliedRunIds.some((appliedRunId) =>
3414
+ stored.reversionSegments.some((segment) =>
3415
+ segment.runId === appliedRunId && segment.disposition === 'revertible'));
3416
+ if (!hasWorkspaceProgress) {
2838
3417
  stored.revertCursor = current.fromCursor;
2839
3418
  delete stored.pendingReversion;
2840
3419
  }
@@ -2852,17 +3431,31 @@ export class FlexHarness<TScope = unknown> {
2852
3431
  if (pending?.kind !== 'capture' || !this.turnReversionProvider) return;
2853
3432
  const segment = stored.reversionSegments.find((entry) => entry.runId === pending.runId);
2854
3433
  if (!segment) throw new FlexHarnessValidationError('Pending capture references a missing segment.');
3434
+ if (this.reversionProviderProtocolVersion() !== pending.protocolVersion) {
3435
+ throw new FlexHarnessValidationError(
3436
+ `Pending capture recovery requires protocolVersion ${pending.protocolVersion}.`,
3437
+ );
3438
+ }
2855
3439
  const outcome = outcomes.get(pending.runId);
2856
- const reference = await this.resolveReversionCaptureReference(
3440
+ if (pending.state === 'prepared') {
3441
+ await this.mutateProjection(state, stored, () => {
3442
+ const current = stored.pendingReversion;
3443
+ if (current?.kind !== 'capture' || current.captureId !== pending.captureId) {
3444
+ throw new FlexHarnessValidationError('Pending capture changed before finalization.');
3445
+ }
3446
+ current.state = 'finalizing';
3447
+ }, true);
3448
+ }
3449
+ const finalizedOutcome = await this.resolveReversionCaptureOutcome(
2857
3450
  state,
2858
3451
  scopeId,
2859
3452
  scope,
2860
3453
  stored.session.sessionId,
2861
3454
  pending.runId,
2862
3455
  pending.captureId,
2863
- pending.state,
3456
+ pending.state === 'prepared' ? 'finalizing' : pending.state,
2864
3457
  );
2865
- if (reference === undefined) {
3458
+ if (finalizedOutcome === undefined) {
2866
3459
  await this.mutateProjection(state, stored, () => {
2867
3460
  stored.reversionSegments = stored.reversionSegments.filter(
2868
3461
  (entry) => entry.runId !== pending.runId,
@@ -2877,7 +3470,7 @@ export class FlexHarness<TScope = unknown> {
2877
3470
  : outcome === 'rejected'
2878
3471
  ? 'failed'
2879
3472
  : 'cancelled';
2880
- segment.workspaceReference = cloneSerializable(reference);
3473
+ this.applyFinalizedReversionOutcome(stored, segment, finalizedOutcome);
2881
3474
  segment.eventIds = stored.agentSession.getEvents()
2882
3475
  .filter((event) => event.generationId === pending.runId)
2883
3476
  .map((event) => event.id);
@@ -3017,10 +3610,7 @@ export class FlexHarness<TScope = unknown> {
3017
3610
  }
3018
3611
 
3019
3612
  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
- }
3613
+ this.assertNoAmbientSlashCommandTeardown({ scopeId });
3024
3614
  this.assertOpen();
3025
3615
  validateIdentifier(scopeId, 'scopeId');
3026
3616
  const existing = this.scopeRetirements.get(scopeId);
@@ -3039,13 +3629,8 @@ export class FlexHarness<TScope = unknown> {
3039
3629
  }
3040
3630
 
3041
3631
  public async dispose(): Promise<void> {
3632
+ this.assertNoAmbientSlashCommandTeardown();
3042
3633
  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
3634
  this.closed = true;
3050
3635
  let disposal!: Promise<void>;
3051
3636
  disposal = this.disposeInternal().catch((error) => {
@@ -3125,7 +3710,7 @@ export class FlexHarness<TScope = unknown> {
3125
3710
  }
3126
3711
  if (stored.session.agent !== undefined && !subagentAdmission) {
3127
3712
  throw new FlexHarnessValidationError(
3128
- 'Subagent sessions can only be prompted through the foreground task tool.',
3713
+ 'Subagent sessions can only be prompted through the foreground delegate tool.',
3129
3714
  );
3130
3715
  }
3131
3716
  if (stored.outstandingPromptsById.size >= this.promptQueueLimits.maxOutstandingPromptsPerSession) {
@@ -3339,6 +3924,7 @@ export class FlexHarness<TScope = unknown> {
3339
3924
  const captureId = this.turnReversionProvider
3340
3925
  ? this.reversionId('capture', run.state.storageKey, run.sessionId, run.runId)
3341
3926
  : undefined;
3927
+ const protocolVersion = this.reversionProviderProtocolVersion() ?? 1;
3342
3928
  run.stored.reversionSegments.push({
3343
3929
  runId: run.runId,
3344
3930
  userMessageId: reservation.userMessage.messageId,
@@ -3346,6 +3932,9 @@ export class FlexHarness<TScope = unknown> {
3346
3932
  contextAvailable: true,
3347
3933
  eventIds: [],
3348
3934
  workspaceCaptured: captureId !== undefined,
3935
+ protocolVersion,
3936
+ provenance: captureId === undefined ? 'transcript' : 'workspace',
3937
+ ...(captureId === undefined ? {} : { disposition: 'pending' }),
3349
3938
  ...(captureId === undefined ? {} : { captureId }),
3350
3939
  });
3351
3940
  if (captureId) {
@@ -3354,6 +3943,7 @@ export class FlexHarness<TScope = unknown> {
3354
3943
  runId: run.runId,
3355
3944
  captureId,
3356
3945
  state: 'preparing',
3946
+ protocolVersion,
3357
3947
  };
3358
3948
  }
3359
3949
  }
@@ -3727,32 +4317,438 @@ export class FlexHarness<TScope = unknown> {
3727
4317
  throw errors.length > 1 ? combineErrors(errors) : terminalError;
3728
4318
  }
3729
4319
 
3730
- private async prepareGeneration(
3731
- run: IActiveRun,
3732
- options: IFlexPromptOptions,
3733
- signal: AbortSignal,
3734
- ): Promise<plugins.IAgentGenerationLease> {
3735
- signal.throwIfAborted();
3736
- await this.prepareRunReversion(run, signal);
3737
- run.phase = 'running';
3738
- const queued = run.stored.outstandingPromptsById.get(run.queueId);
3739
- if (queued && (queued.status === 'starting' || queued.status === 'scheduled')) {
3740
- queued.status = 'running';
3741
- this.emitPromptQueueEvent(queued, 'prompt.running');
4320
+ private requireProjectManagementTools(): INormalizedProjectManagementTools {
4321
+ const projectManagement = this.builtInTools.projectManagement;
4322
+ if (!projectManagement) {
4323
+ throw new FlexHarnessValidationError('Project management is not configured for this FlexHarness.');
3742
4324
  }
3743
- const resolverRelationship = {
3744
- ...(run.stored.session.parentSessionId === undefined
3745
- ? {}
3746
- : { parentSessionId: run.stored.session.parentSessionId }),
3747
- ...(run.stored.session.agent === undefined ? {} : { agent: run.stored.session.agent }),
3748
- };
3749
- const modelOutcome = Promise.resolve()
3750
- .then(() => this.modelResolver.resolveModel(Object.freeze({
3751
- scopeId: run.scopeId,
3752
- scope: run.scope as TScope,
3753
- sessionId: run.sessionId,
3754
- runId: run.runId,
3755
- ...(options.modelHint ? { modelHint: options.modelHint } : {}),
4325
+ return projectManagement;
4326
+ }
4327
+
4328
+ private projectManagementKey(storageKey: string, sessionId: string): string {
4329
+ return JSON.stringify([storageKey, sessionId]);
4330
+ }
4331
+
4332
+ private queueProjectManagementOperation<TResult>(
4333
+ storageKey: string,
4334
+ sessionId: string,
4335
+ operation: () => Promise<TResult>,
4336
+ ): Promise<TResult> {
4337
+ const key = this.projectManagementKey(storageKey, sessionId);
4338
+ const previous = this.projectManagementQueues.get(key) ?? Promise.resolve();
4339
+ const result = previous.then(operation);
4340
+ const barrier = result.then(() => undefined, () => undefined);
4341
+ this.projectManagementQueues.set(key, barrier);
4342
+ void barrier.then(() => {
4343
+ if (this.projectManagementQueues.get(key) === barrier) {
4344
+ this.projectManagementQueues.delete(key);
4345
+ }
4346
+ });
4347
+ return result;
4348
+ }
4349
+
4350
+ private async awaitProjectManagementOperations(
4351
+ storageKey: string,
4352
+ sessionIds: Iterable<string>,
4353
+ ): Promise<void> {
4354
+ const barriers = [...new Set(sessionIds)]
4355
+ .map((sessionId) => this.projectManagementQueues.get(
4356
+ this.projectManagementKey(storageKey, sessionId),
4357
+ ))
4358
+ .filter((barrier): barrier is Promise<void> => barrier !== undefined);
4359
+ await Promise.all(barriers);
4360
+ }
4361
+
4362
+ private async resolveProjectManagementSession(
4363
+ scopeId: string,
4364
+ sessionId: string,
4365
+ ): Promise<{ state: IStorageState; stored: IStoredSessionState }> {
4366
+ this.requireProjectManagementTools();
4367
+ const { state } = await this.resolveState(scopeId);
4368
+ await state.scopeQueue;
4369
+ this.assertOpen();
4370
+ this.assertStateAcceptingWork(state);
4371
+ return { state, stored: this.requireSession(state, sessionId) };
4372
+ }
4373
+
4374
+ private assertProjectManagementSession(
4375
+ state: IStorageState,
4376
+ stored: IStoredSessionState,
4377
+ ): void {
4378
+ this.assertOpen();
4379
+ this.assertStateAcceptingWork(state);
4380
+ if (
4381
+ state.sessions.get(stored.session.sessionId) !== stored
4382
+ || state.tombstones.has(stored.session.sessionId)
4383
+ ) {
4384
+ throw new FlexHarnessNotFoundError('Session', stored.session.sessionId);
4385
+ }
4386
+ }
4387
+
4388
+ private async loadProjectManagementState(
4389
+ state: IStorageState,
4390
+ stored: IStoredSessionState,
4391
+ ): Promise<IFlexProjectManagementSnapshot> {
4392
+ const store = this.stores.projectManagement;
4393
+ const generation = requireSessionGeneration(stored.session);
4394
+ const record = await store.load(state.storageKey, stored.session.sessionId);
4395
+ if (record === undefined) {
4396
+ return createEmptyFlexProjectManagementSnapshot(
4397
+ generation.sessionGenerationId,
4398
+ generation.sessionGenerationSequence,
4399
+ );
4400
+ }
4401
+ assertFlexProjectManagementRecord(record);
4402
+ if (record.sessionGenerationId === generation.sessionGenerationId) {
4403
+ if (record.sessionGenerationSequence !== generation.sessionGenerationSequence) {
4404
+ throw new FlexHarnessStoreConflictError(
4405
+ this.projectManagementKey(state.storageKey, stored.session.sessionId),
4406
+ 0,
4407
+ record.revision,
4408
+ );
4409
+ }
4410
+ if (!('deletedAt' in record)) return cloneSerializable(record);
4411
+ throw new FlexHarnessNotFoundError('Project management state', stored.session.sessionId);
4412
+ }
4413
+ if (
4414
+ 'deletedAt' in record
4415
+ && record.sessionGenerationSequence < generation.sessionGenerationSequence
4416
+ ) {
4417
+ return createEmptyFlexProjectManagementSnapshot(
4418
+ generation.sessionGenerationId,
4419
+ generation.sessionGenerationSequence,
4420
+ );
4421
+ }
4422
+ throw new FlexHarnessStoreConflictError(
4423
+ this.projectManagementKey(state.storageKey, stored.session.sessionId),
4424
+ 0,
4425
+ record.revision,
4426
+ );
4427
+ }
4428
+
4429
+ private readProjectManagementState(
4430
+ state: IStorageState,
4431
+ stored: IStoredSessionState,
4432
+ ): Promise<IFlexProjectManagementSnapshot> {
4433
+ return this.queueProjectManagementOperation(
4434
+ state.storageKey,
4435
+ stored.session.sessionId,
4436
+ async () => {
4437
+ this.assertProjectManagementSession(state, stored);
4438
+ const snapshot = await this.loadProjectManagementState(state, stored);
4439
+ this.assertProjectManagementSession(state, stored);
4440
+ return publicSnapshot(snapshot);
4441
+ },
4442
+ );
4443
+ }
4444
+
4445
+ private mutateProjectManagementState<TResult>(
4446
+ state: IStorageState,
4447
+ stored: IStoredSessionState,
4448
+ writeContext: IFlexProjectManagementWriteContext,
4449
+ mutation: (
4450
+ snapshot: IFlexProjectManagementSnapshot,
4451
+ ) => { changed: boolean; value: TResult },
4452
+ ): Promise<{ snapshot: IFlexProjectManagementSnapshot; value: TResult }> {
4453
+ return this.queueProjectManagementOperation(
4454
+ state.storageKey,
4455
+ stored.session.sessionId,
4456
+ async () => {
4457
+ this.assertProjectManagementSession(state, stored);
4458
+ const snapshot = await this.loadProjectManagementState(state, stored);
4459
+ this.assertProjectManagementSession(state, stored);
4460
+ const expectedRevision = snapshot.revision;
4461
+ const result = mutation(snapshot);
4462
+ if (result.changed) {
4463
+ snapshot.revision = expectedRevision + 1;
4464
+ assertFlexProjectManagementSnapshot(snapshot);
4465
+ this.assertProjectManagementSession(state, stored);
4466
+ await this.stores.projectManagement.save(
4467
+ state.storageKey,
4468
+ stored.session.sessionId,
4469
+ cloneSerializable(snapshot),
4470
+ expectedRevision,
4471
+ Object.freeze(cloneSerializable(writeContext)),
4472
+ );
4473
+ }
4474
+ return {
4475
+ snapshot: publicSnapshot(snapshot),
4476
+ value: result.value === undefined ? result.value : publicSnapshot(result.value),
4477
+ };
4478
+ },
4479
+ );
4480
+ }
4481
+
4482
+ private projectStateResult(snapshot: IFlexProjectManagementSnapshot): IFlexProjectStateResult {
4483
+ return publicSnapshot({ revision: snapshot.revision, state: snapshot });
4484
+ }
4485
+
4486
+ private projectTaskResult(
4487
+ snapshot: IFlexProjectManagementSnapshot,
4488
+ task: IFlexProjectTask,
4489
+ ): IFlexProjectTaskResult {
4490
+ return publicSnapshot({ revision: snapshot.revision, task, state: snapshot });
4491
+ }
4492
+
4493
+ private projectTasksResult(
4494
+ snapshot: IFlexProjectManagementSnapshot,
4495
+ tasks: IFlexProjectTask[],
4496
+ ): IFlexProjectTasksResult {
4497
+ return publicSnapshot({ revision: snapshot.revision, tasks, state: snapshot });
4498
+ }
4499
+
4500
+ private projectGoalResult(snapshot: IFlexProjectManagementSnapshot): IFlexProjectGoalResult {
4501
+ return publicSnapshot({
4502
+ revision: snapshot.revision,
4503
+ goal: snapshot.goal ?? null,
4504
+ state: snapshot,
4505
+ });
4506
+ }
4507
+
4508
+ private projectScratchpadResult(
4509
+ snapshot: IFlexProjectManagementSnapshot,
4510
+ ): IFlexProjectScratchpadResult {
4511
+ return publicSnapshot({
4512
+ revision: snapshot.revision,
4513
+ scratchpad: snapshot.scratchpad,
4514
+ state: snapshot,
4515
+ });
4516
+ }
4517
+
4518
+ private async createProjectTaskInState(
4519
+ state: IStorageState,
4520
+ stored: IStoredSessionState,
4521
+ input: IFlexCreateProjectTaskInput,
4522
+ writeContext: IFlexProjectManagementWriteContext,
4523
+ ): Promise<IFlexProjectTaskResult> {
4524
+ const status = input.status ?? 'pending';
4525
+ const priority = input.priority ?? 'medium';
4526
+ const result = await this.mutateProjectManagementState(
4527
+ state,
4528
+ stored,
4529
+ writeContext,
4530
+ (snapshot) => {
4531
+ const existing = snapshot.tasks.find((task) => task.id === input.id);
4532
+ if (existing) {
4533
+ if (
4534
+ existing.content === input.content
4535
+ && existing.status === status
4536
+ && existing.priority === priority
4537
+ ) return { changed: false, value: existing };
4538
+ throw new FlexHarnessValidationError(
4539
+ `Project task "${input.id}" already exists with different creation data.`,
4540
+ );
4541
+ }
4542
+ if (snapshot.tasks.length >= FLEX_PROJECT_MANAGEMENT_LIMITS.maxTasks) {
4543
+ throw new FlexHarnessValidationError('Project task count has reached its limit.');
4544
+ }
4545
+ const timestamp = new Date().toISOString();
4546
+ const task: IFlexProjectTask = {
4547
+ id: input.id,
4548
+ content: input.content,
4549
+ status,
4550
+ priority,
4551
+ createdAt: timestamp,
4552
+ updatedAt: timestamp,
4553
+ };
4554
+ snapshot.tasks.push(task);
4555
+ return { changed: true, value: task };
4556
+ },
4557
+ );
4558
+ return this.projectTaskResult(result.snapshot, result.value);
4559
+ }
4560
+
4561
+ private async updateProjectTaskInState(
4562
+ state: IStorageState,
4563
+ stored: IStoredSessionState,
4564
+ input: IFlexUpdateProjectTaskInput,
4565
+ writeContext: IFlexProjectManagementWriteContext,
4566
+ ): Promise<IFlexProjectTaskResult> {
4567
+ const result = await this.mutateProjectManagementState(
4568
+ state,
4569
+ stored,
4570
+ writeContext,
4571
+ (snapshot) => {
4572
+ const task = snapshot.tasks.find((entry) => entry.id === input.id);
4573
+ if (!task) throw new FlexHarnessNotFoundError('Project task', input.id);
4574
+ const changed = (input.content !== undefined && input.content !== task.content)
4575
+ || (input.status !== undefined && input.status !== task.status)
4576
+ || (input.priority !== undefined && input.priority !== task.priority);
4577
+ if (!changed) return { changed: false, value: task };
4578
+ if (input.content !== undefined) task.content = input.content;
4579
+ if (input.status !== undefined) task.status = input.status;
4580
+ if (input.priority !== undefined) task.priority = input.priority;
4581
+ task.updatedAt = new Date().toISOString();
4582
+ return { changed: true, value: task };
4583
+ },
4584
+ );
4585
+ return this.projectTaskResult(result.snapshot, result.value);
4586
+ }
4587
+
4588
+ private async deleteProjectTaskInState(
4589
+ state: IStorageState,
4590
+ stored: IStoredSessionState,
4591
+ taskId: string,
4592
+ writeContext: IFlexProjectManagementWriteContext,
4593
+ ): Promise<IFlexProjectTaskResult> {
4594
+ const result = await this.mutateProjectManagementState(
4595
+ state,
4596
+ stored,
4597
+ writeContext,
4598
+ (snapshot) => {
4599
+ const index = snapshot.tasks.findIndex((task) => task.id === taskId);
4600
+ if (index < 0) throw new FlexHarnessNotFoundError('Project task', taskId);
4601
+ const [task] = snapshot.tasks.splice(index, 1);
4602
+ return { changed: true, value: task };
4603
+ },
4604
+ );
4605
+ return this.projectTaskResult(result.snapshot, result.value);
4606
+ }
4607
+
4608
+ private async clearProjectTasksInState(
4609
+ state: IStorageState,
4610
+ stored: IStoredSessionState,
4611
+ writeContext: IFlexProjectManagementWriteContext,
4612
+ ): Promise<IFlexProjectTasksResult> {
4613
+ const result = await this.mutateProjectManagementState(
4614
+ state,
4615
+ stored,
4616
+ writeContext,
4617
+ (snapshot) => {
4618
+ const tasks = snapshot.tasks;
4619
+ if (tasks.length === 0) return { changed: false, value: [] as IFlexProjectTask[] };
4620
+ snapshot.tasks = [];
4621
+ return { changed: true, value: tasks };
4622
+ },
4623
+ );
4624
+ return this.projectTasksResult(result.snapshot, result.value);
4625
+ }
4626
+
4627
+ private async setProjectGoalInState(
4628
+ state: IStorageState,
4629
+ stored: IStoredSessionState,
4630
+ goal: string,
4631
+ writeContext: IFlexProjectManagementWriteContext,
4632
+ ): Promise<IFlexProjectGoalResult> {
4633
+ const result = await this.mutateProjectManagementState(
4634
+ state,
4635
+ stored,
4636
+ writeContext,
4637
+ (snapshot) => {
4638
+ if (snapshot.goal === goal) return { changed: false, value: undefined };
4639
+ snapshot.goal = goal;
4640
+ return { changed: true, value: undefined };
4641
+ },
4642
+ );
4643
+ return this.projectGoalResult(result.snapshot);
4644
+ }
4645
+
4646
+ private async clearProjectGoalInState(
4647
+ state: IStorageState,
4648
+ stored: IStoredSessionState,
4649
+ writeContext: IFlexProjectManagementWriteContext,
4650
+ ): Promise<IFlexProjectGoalResult> {
4651
+ const result = await this.mutateProjectManagementState(
4652
+ state,
4653
+ stored,
4654
+ writeContext,
4655
+ (snapshot) => {
4656
+ if (snapshot.goal === undefined) return { changed: false, value: undefined };
4657
+ delete snapshot.goal;
4658
+ return { changed: true, value: undefined };
4659
+ },
4660
+ );
4661
+ return this.projectGoalResult(result.snapshot);
4662
+ }
4663
+
4664
+ private async setProjectScratchpadInState(
4665
+ state: IStorageState,
4666
+ stored: IStoredSessionState,
4667
+ content: string,
4668
+ writeContext: IFlexProjectManagementWriteContext,
4669
+ ): Promise<IFlexProjectScratchpadResult> {
4670
+ const result = await this.mutateProjectManagementState(
4671
+ state,
4672
+ stored,
4673
+ writeContext,
4674
+ (snapshot) => {
4675
+ if (snapshot.scratchpad === content) return { changed: false, value: undefined };
4676
+ snapshot.scratchpad = content;
4677
+ return { changed: true, value: undefined };
4678
+ },
4679
+ );
4680
+ return this.projectScratchpadResult(result.snapshot);
4681
+ }
4682
+
4683
+ private async appendProjectScratchpadInState(
4684
+ state: IStorageState,
4685
+ stored: IStoredSessionState,
4686
+ content: string,
4687
+ writeContext: IFlexProjectManagementWriteContext,
4688
+ ): Promise<IFlexProjectScratchpadResult> {
4689
+ const result = await this.mutateProjectManagementState(
4690
+ state,
4691
+ stored,
4692
+ writeContext,
4693
+ (snapshot) => {
4694
+ if (content.length === 0) return { changed: false, value: undefined };
4695
+ const appended = `${snapshot.scratchpad}${content}`;
4696
+ validateUtf8String(
4697
+ appended,
4698
+ 'project scratchpad',
4699
+ FLEX_PROJECT_MANAGEMENT_LIMITS.maxScratchpadBytes,
4700
+ );
4701
+ snapshot.scratchpad = appended;
4702
+ return { changed: true, value: undefined };
4703
+ },
4704
+ );
4705
+ return this.projectScratchpadResult(result.snapshot);
4706
+ }
4707
+
4708
+ private async clearProjectScratchpadInState(
4709
+ state: IStorageState,
4710
+ stored: IStoredSessionState,
4711
+ writeContext: IFlexProjectManagementWriteContext,
4712
+ ): Promise<IFlexProjectScratchpadResult> {
4713
+ const result = await this.mutateProjectManagementState(
4714
+ state,
4715
+ stored,
4716
+ writeContext,
4717
+ (snapshot) => {
4718
+ if (snapshot.scratchpad.length === 0) return { changed: false, value: undefined };
4719
+ snapshot.scratchpad = '';
4720
+ return { changed: true, value: undefined };
4721
+ },
4722
+ );
4723
+ return this.projectScratchpadResult(result.snapshot);
4724
+ }
4725
+
4726
+ private async prepareGeneration(
4727
+ run: IActiveRun,
4728
+ options: IFlexPromptOptions,
4729
+ signal: AbortSignal,
4730
+ ): Promise<plugins.IAgentGenerationLease> {
4731
+ signal.throwIfAborted();
4732
+ await this.prepareRunReversion(run, signal);
4733
+ run.phase = 'running';
4734
+ const queued = run.stored.outstandingPromptsById.get(run.queueId);
4735
+ if (queued && (queued.status === 'starting' || queued.status === 'scheduled')) {
4736
+ queued.status = 'running';
4737
+ this.emitPromptQueueEvent(queued, 'prompt.running');
4738
+ }
4739
+ const resolverRelationship = {
4740
+ ...(run.stored.session.parentSessionId === undefined
4741
+ ? {}
4742
+ : { parentSessionId: run.stored.session.parentSessionId }),
4743
+ ...(run.stored.session.agent === undefined ? {} : { agent: run.stored.session.agent }),
4744
+ };
4745
+ const modelOutcome = Promise.resolve()
4746
+ .then(() => this.modelResolver.resolveModel(Object.freeze({
4747
+ scopeId: run.scopeId,
4748
+ scope: run.scope as TScope,
4749
+ sessionId: run.sessionId,
4750
+ runId: run.runId,
4751
+ ...(options.modelHint ? { modelHint: options.modelHint } : {}),
3756
4752
  ...resolverRelationship,
3757
4753
  signal,
3758
4754
  })))
@@ -3839,17 +4835,8 @@ export class FlexHarness<TScope = unknown> {
3839
4835
  let tools: TFlexAgentToolSet | undefined;
3840
4836
  try {
3841
4837
  const providedTools = toolHandle?.tools;
3842
- if (
3843
- this.subagents.size > 0
3844
- && providedTools
3845
- && Object.prototype.hasOwnProperty.call(providedTools, 'task')
3846
- ) {
3847
- throw new FlexHarnessValidationError('The application tool provider cannot define reserved tool "task".');
3848
- }
3849
4838
  const combinedTools: Record<string, unknown> = { ...(providedTools ?? {}) };
3850
- if (this.subagents.size > 0 && (run.stored.session.depth ?? 0) < this.maxSubagentDepth) {
3851
- combinedTools.task = this.createSubagentTool(run);
3852
- }
4839
+ Object.assign(combinedTools, this.createBuiltInTools(run));
3853
4840
  tools = Object.keys(combinedTools).length > 0
3854
4841
  ? wrapToolSet(
3855
4842
  combinedTools as TFlexAgentToolSet,
@@ -3900,6 +4887,283 @@ export class FlexHarness<TScope = unknown> {
3900
4887
  }
3901
4888
  }
3902
4889
 
4890
+ private enabledBuiltInToolNames(run: IActiveRun): Set<string> {
4891
+ const names = new Set<string>();
4892
+ if (this.subagents.size > 0 && (run.stored.session.depth ?? 0) < this.maxSubagentDepth) {
4893
+ names.add('delegate');
4894
+ }
4895
+ if (this.builtInTools.renameSession) names.add('rename_session');
4896
+ const projectManagement = this.builtInTools.projectManagement;
4897
+ if (projectManagement?.task) names.add('task');
4898
+ if (projectManagement?.goal) names.add('goal');
4899
+ if (projectManagement?.scratchpad) names.add('scratchpad');
4900
+ return names;
4901
+ }
4902
+
4903
+ private assertNoBuiltInToolCollisions(run: IActiveRun, tools: TFlexAgentToolSet): void {
4904
+ const reserved = this.enabledBuiltInToolNames(run);
4905
+ const collision = Object.keys(tools).find((name) => reserved.has(name));
4906
+ if (collision) {
4907
+ throw new FlexHarnessValidationError(
4908
+ `Tool provider cannot define enabled built-in tool "${collision}".`,
4909
+ );
4910
+ }
4911
+ }
4912
+
4913
+ private createBuiltInTools(run: IActiveRun): Record<string, unknown> {
4914
+ const tools: Record<string, unknown> = {};
4915
+ if (this.subagents.size > 0 && (run.stored.session.depth ?? 0) < this.maxSubagentDepth) {
4916
+ tools.delegate = this.createSubagentTool(run);
4917
+ }
4918
+ if (this.builtInTools.renameSession) tools.rename_session = this.createRenameSessionTool(run);
4919
+ const projectManagement = this.builtInTools.projectManagement;
4920
+ if (projectManagement?.task) tools.task = this.createProjectTaskTool(run);
4921
+ if (projectManagement?.goal) tools.goal = this.createProjectGoalTool(run);
4922
+ if (projectManagement?.scratchpad) tools.scratchpad = this.createProjectScratchpadTool(run);
4923
+ return tools;
4924
+ }
4925
+
4926
+ private createRenameSessionTool(run: IActiveRun): unknown {
4927
+ return plugins.tool({
4928
+ description: 'Set the current FlexHarness session title.',
4929
+ inputSchema: plugins.z.object({
4930
+ title: plugins.z.string().min(1).max(FLEX_PROJECT_MANAGEMENT_LIMITS.maxTitleBytes),
4931
+ }).strict(),
4932
+ execute: async ({ title }: { title: string }) => {
4933
+ validateUtf8String(
4934
+ title,
4935
+ 'title',
4936
+ FLEX_PROJECT_MANAGEMENT_LIMITS.maxTitleBytes,
4937
+ true,
4938
+ );
4939
+ let session!: IFlexSession;
4940
+ await this.mutateScope(run.state, () => {
4941
+ if (run.state.sessions.get(run.sessionId) !== run.stored) {
4942
+ throw new FlexHarnessNotFoundError('Session', run.sessionId);
4943
+ }
4944
+ run.stored.session.title = title;
4945
+ run.stored.session.updatedAt = new Date().toISOString();
4946
+ session = publicSnapshot(run.stored.session);
4947
+ });
4948
+ this.emitEvent(run.scopeId, run.sessionId, {
4949
+ type: 'session.updated',
4950
+ session,
4951
+ });
4952
+ return { session };
4953
+ },
4954
+ });
4955
+ }
4956
+
4957
+ private createProjectTaskTool(run: IActiveRun): unknown {
4958
+ const status = plugins.z.enum(['pending', 'in_progress', 'completed', 'cancelled']);
4959
+ const priority = plugins.z.enum(['high', 'medium', 'low']);
4960
+ const id = plugins.z.string().min(1).max(FLEX_PROJECT_MANAGEMENT_LIMITS.maxTaskIdBytes);
4961
+ const content = plugins.z.string().min(1).max(FLEX_PROJECT_MANAGEMENT_LIMITS.maxTaskContentBytes);
4962
+ return plugins.tool({
4963
+ description: 'List, create, update, delete, or clear session-local project tasks.',
4964
+ inputSchema: plugins.z.discriminatedUnion('action', [
4965
+ plugins.z.object({ action: plugins.z.literal('list') }).strict(),
4966
+ plugins.z.object({
4967
+ action: plugins.z.literal('create'),
4968
+ id: id.optional(),
4969
+ content,
4970
+ status: status.optional(),
4971
+ priority: priority.optional(),
4972
+ }).strict(),
4973
+ plugins.z.object({
4974
+ action: plugins.z.literal('update'),
4975
+ id,
4976
+ content: content.optional(),
4977
+ status: status.optional(),
4978
+ priority: priority.optional(),
4979
+ }).strict().refine(
4980
+ (input) => input.content !== undefined
4981
+ || input.status !== undefined
4982
+ || input.priority !== undefined,
4983
+ { message: 'Task update requires content, status, or priority.' },
4984
+ ),
4985
+ plugins.z.object({ action: plugins.z.literal('delete'), id }).strict(),
4986
+ plugins.z.object({ action: plugins.z.literal('clear') }).strict(),
4987
+ ]),
4988
+ execute: (
4989
+ input: TProjectTaskToolInput,
4990
+ options?: { toolCallId?: string },
4991
+ ) => this.executeProjectTaskTool(run, input, options?.toolCallId),
4992
+ });
4993
+ }
4994
+
4995
+ private createProjectGoalTool(run: IActiveRun): unknown {
4996
+ return plugins.tool({
4997
+ description: 'Get, set, or clear the session-local project goal.',
4998
+ inputSchema: plugins.z.discriminatedUnion('action', [
4999
+ plugins.z.object({ action: plugins.z.literal('get') }).strict(),
5000
+ plugins.z.object({
5001
+ action: plugins.z.literal('set'),
5002
+ goal: plugins.z.string().min(1).max(FLEX_PROJECT_MANAGEMENT_LIMITS.maxGoalBytes),
5003
+ }).strict(),
5004
+ plugins.z.object({ action: plugins.z.literal('clear') }).strict(),
5005
+ ]),
5006
+ execute: (
5007
+ input: TProjectGoalToolInput,
5008
+ options?: { toolCallId?: string },
5009
+ ) => this.executeProjectGoalTool(run, input, options?.toolCallId),
5010
+ });
5011
+ }
5012
+
5013
+ private createProjectScratchpadTool(run: IActiveRun): unknown {
5014
+ const content = plugins.z.string().max(FLEX_PROJECT_MANAGEMENT_LIMITS.maxScratchpadBytes);
5015
+ return plugins.tool({
5016
+ description: 'Get, set, append to, or clear the session-local project scratchpad.',
5017
+ inputSchema: plugins.z.discriminatedUnion('action', [
5018
+ plugins.z.object({ action: plugins.z.literal('get') }).strict(),
5019
+ plugins.z.object({ action: plugins.z.literal('set'), content }).strict(),
5020
+ plugins.z.object({ action: plugins.z.literal('append'), content }).strict(),
5021
+ plugins.z.object({ action: plugins.z.literal('clear') }).strict(),
5022
+ ]),
5023
+ execute: (
5024
+ input: TProjectScratchpadToolInput,
5025
+ options?: { toolCallId?: string },
5026
+ ) => this.executeProjectScratchpadTool(run, input, options?.toolCallId),
5027
+ });
5028
+ }
5029
+
5030
+ private projectManagementAgentWriteContext(
5031
+ run: IActiveRun,
5032
+ toolCallId: string | undefined,
5033
+ ): IFlexProjectManagementWriteContext {
5034
+ return Object.freeze({
5035
+ actor: 'agent' as const,
5036
+ runId: run.runId,
5037
+ ...(run.stored.session.agent === undefined ? {} : { agent: run.stored.session.agent }),
5038
+ ...(toolCallId === undefined ? {} : { toolCallId }),
5039
+ });
5040
+ }
5041
+
5042
+ private async executeProjectTaskTool(
5043
+ run: IActiveRun,
5044
+ input: TProjectTaskToolInput,
5045
+ toolCallId: string | undefined,
5046
+ ): Promise<IFlexProjectStateResult | IFlexProjectTaskResult | IFlexProjectTasksResult> {
5047
+ this.assertProjectManagementSession(run.state, run.stored);
5048
+ if (input.action === 'list') {
5049
+ const snapshot = await this.readProjectManagementState(run.state, run.stored);
5050
+ return this.projectTasksResult(snapshot, snapshot.tasks);
5051
+ }
5052
+ const writeContext = this.projectManagementAgentWriteContext(run, toolCallId);
5053
+ if (input.action === 'create') {
5054
+ const id = input.id ?? (() => {
5055
+ validateUtf8String(
5056
+ toolCallId,
5057
+ 'task toolCallId',
5058
+ maxTransferIdentifierBytes,
5059
+ true,
5060
+ );
5061
+ return this.createProjectTaskId(
5062
+ run.state.storageKey,
5063
+ run.sessionId,
5064
+ run.runId,
5065
+ toolCallId,
5066
+ );
5067
+ })();
5068
+ const createInput: IFlexCreateProjectTaskInput = {
5069
+ id,
5070
+ content: input.content,
5071
+ ...(input.status === undefined ? {} : { status: input.status }),
5072
+ ...(input.priority === undefined ? {} : { priority: input.priority }),
5073
+ };
5074
+ validateCreateProjectTaskInput(createInput);
5075
+ return this.createProjectTaskInState(run.state, run.stored, createInput, writeContext);
5076
+ }
5077
+ if (input.action === 'update') {
5078
+ const updateInput: IFlexUpdateProjectTaskInput = {
5079
+ id: input.id,
5080
+ ...(input.content === undefined ? {} : { content: input.content }),
5081
+ ...(input.status === undefined ? {} : { status: input.status }),
5082
+ ...(input.priority === undefined ? {} : { priority: input.priority }),
5083
+ };
5084
+ validateUpdateProjectTaskInput(updateInput);
5085
+ return this.updateProjectTaskInState(run.state, run.stored, updateInput, writeContext);
5086
+ }
5087
+ if (input.action === 'delete') {
5088
+ validateProjectTaskId(input.id);
5089
+ return this.deleteProjectTaskInState(run.state, run.stored, input.id, writeContext);
5090
+ }
5091
+ return this.clearProjectTasksInState(run.state, run.stored, writeContext);
5092
+ }
5093
+
5094
+ private async executeProjectGoalTool(
5095
+ run: IActiveRun,
5096
+ input: TProjectGoalToolInput,
5097
+ toolCallId: string | undefined,
5098
+ ): Promise<IFlexProjectGoalResult> {
5099
+ this.assertProjectManagementSession(run.state, run.stored);
5100
+ if (input.action === 'get') {
5101
+ return this.projectGoalResult(await this.readProjectManagementState(run.state, run.stored));
5102
+ }
5103
+ const writeContext = this.projectManagementAgentWriteContext(run, toolCallId);
5104
+ if (input.action === 'set') {
5105
+ validateUtf8String(
5106
+ input.goal,
5107
+ 'project goal',
5108
+ FLEX_PROJECT_MANAGEMENT_LIMITS.maxGoalBytes,
5109
+ true,
5110
+ );
5111
+ return this.setProjectGoalInState(run.state, run.stored, input.goal, writeContext);
5112
+ }
5113
+ return this.clearProjectGoalInState(run.state, run.stored, writeContext);
5114
+ }
5115
+
5116
+ private async executeProjectScratchpadTool(
5117
+ run: IActiveRun,
5118
+ input: TProjectScratchpadToolInput,
5119
+ toolCallId: string | undefined,
5120
+ ): Promise<IFlexProjectScratchpadResult> {
5121
+ this.assertProjectManagementSession(run.state, run.stored);
5122
+ if (input.action === 'get') {
5123
+ return this.projectScratchpadResult(
5124
+ await this.readProjectManagementState(run.state, run.stored),
5125
+ );
5126
+ }
5127
+ const writeContext = this.projectManagementAgentWriteContext(run, toolCallId);
5128
+ if (input.action === 'set') {
5129
+ validateUtf8String(
5130
+ input.content,
5131
+ 'project scratchpad',
5132
+ FLEX_PROJECT_MANAGEMENT_LIMITS.maxScratchpadBytes,
5133
+ );
5134
+ return this.setProjectScratchpadInState(run.state, run.stored, input.content, writeContext);
5135
+ }
5136
+ if (input.action === 'append') {
5137
+ validateUtf8String(
5138
+ input.content,
5139
+ 'project scratchpad append',
5140
+ FLEX_PROJECT_MANAGEMENT_LIMITS.maxScratchpadBytes,
5141
+ );
5142
+ return this.appendProjectScratchpadInState(
5143
+ run.state,
5144
+ run.stored,
5145
+ input.content,
5146
+ writeContext,
5147
+ );
5148
+ }
5149
+ return this.clearProjectScratchpadInState(run.state, run.stored, writeContext);
5150
+ }
5151
+
5152
+ private createProjectTaskId(
5153
+ storageKey: string,
5154
+ sessionId: string,
5155
+ runId: string,
5156
+ toolCallId: string,
5157
+ ): string {
5158
+ return `task_${sha256Hex(JSON.stringify([
5159
+ 'flexharness-project-task-v1',
5160
+ storageKey,
5161
+ sessionId,
5162
+ runId,
5163
+ toolCallId,
5164
+ ]))}`;
5165
+ }
5166
+
3903
5167
  private createSubagentTool(run: IActiveRun): unknown {
3904
5168
  const available = [...this.subagents.values()]
3905
5169
  .map((definition) => `- ${definition.name}: ${definition.description}`)
@@ -3930,16 +5194,16 @@ export class FlexHarness<TScope = unknown> {
3930
5194
  }
3931
5195
  validateUtf8String(
3932
5196
  input.description,
3933
- 'task description',
5197
+ 'delegate description',
3934
5198
  maxSubagentTaskDescriptionBytes,
3935
5199
  true,
3936
5200
  );
3937
- validateUtf8String(input.prompt, 'task prompt', maxSubagentPromptBytes, true);
5201
+ validateUtf8String(input.prompt, 'delegate prompt', maxSubagentPromptBytes, true);
3938
5202
  validateUtf8String(input.subagentType, 'subagentType', maxSubagentNameBytes, true);
3939
5203
  if (input.taskId !== undefined) {
3940
5204
  validateUtf8String(input.taskId, 'taskId', maxSubagentTaskIdBytes, true);
3941
5205
  }
3942
- validateUtf8String(toolCallId, 'task toolCallId', maxTransferIdentifierBytes, true);
5206
+ validateUtf8String(toolCallId, 'delegate toolCallId', maxTransferIdentifierBytes, true);
3943
5207
  const definition = this.subagents.get(input.subagentType);
3944
5208
  if (!definition || (run.stored.session.depth ?? 0) >= this.maxSubagentDepth) {
3945
5209
  throw new FlexHarnessValidationError(`Subagent "${input.subagentType}" is not available.`);
@@ -4163,6 +5427,8 @@ export class FlexHarness<TScope = unknown> {
4163
5427
  metadata = {
4164
5428
  scopeId: run.scopeId,
4165
5429
  sessionId,
5430
+ sessionGenerationId: createSessionGenerationId(),
5431
+ sessionGenerationSequence: state.revision + 1,
4166
5432
  title: `Subagent: ${definition.name}`,
4167
5433
  createdAt: timestamp,
4168
5434
  updatedAt: timestamp,
@@ -4236,6 +5502,7 @@ export class FlexHarness<TScope = unknown> {
4236
5502
  state.sessions.delete(sessionId);
4237
5503
  state.tombstones.set(sessionId, {
4238
5504
  sessionId,
5505
+ ...requireSessionGeneration(metadata!),
4239
5506
  deletedAt: new Date().toISOString(),
4240
5507
  rootSessionId: sessionId,
4241
5508
  depth: 0,
@@ -4291,12 +5558,12 @@ export class FlexHarness<TScope = unknown> {
4291
5558
  const partId = run.toolPartIds.get(toolCallId);
4292
5559
  const part = run.callbackParts.find((entry) => entry.partId === partId && entry.type === 'tool');
4293
5560
  if (!part || part.type !== 'tool' || part.status !== 'running') {
4294
- throw new FlexHarnessValidationError(`Running task part "${toolCallId}" is unavailable.`);
5561
+ throw new FlexHarnessValidationError(`Running delegate part "${toolCallId}" is unavailable.`);
4295
5562
  }
4296
5563
  const bytes = Buffer.byteLength(childSessionId, 'utf8')
4297
5564
  + (model === undefined ? 0 : jsonBytes(model));
4298
5565
  if (!this.reserveCallbackCapacity(run, 1, bytes, 0)) {
4299
- throw run.callbackError ?? new FlexHarnessCallbackOverflowError('Task metadata exceeded callback limits.');
5566
+ throw run.callbackError ?? new FlexHarnessCallbackOverflowError('Delegate metadata exceeded callback limits.');
4300
5567
  }
4301
5568
  part.childSessionId = childSessionId;
4302
5569
  if (model !== undefined) part.model = cloneSerializable(model);
@@ -4445,6 +5712,11 @@ export class FlexHarness<TScope = unknown> {
4445
5712
  return `${kind}_${sha256Hex(JSON.stringify([storageKey, sessionId, value]))}`;
4446
5713
  }
4447
5714
 
5715
+ private reversionProviderProtocolVersion(): 1 | 2 | undefined {
5716
+ if (!this.turnReversionProvider) return undefined;
5717
+ return 'protocolVersion' in this.turnReversionProvider ? 2 : 1;
5718
+ }
5719
+
4448
5720
  private reversionCaptureContext(
4449
5721
  run: IActiveRun,
4450
5722
  captureId: string,
@@ -4491,9 +5763,10 @@ export class FlexHarness<TScope = unknown> {
4491
5763
  sessionId: string,
4492
5764
  runId: string,
4493
5765
  captureId: string,
4494
- ) {
4495
- return this.withReversionMaintenanceSignal((signal) =>
4496
- this.turnReversionProvider!.inspectCapture(Object.freeze({
5766
+ ): Promise<TNormalizedCaptureInspection> {
5767
+ return this.withReversionMaintenanceSignal(async (signal) => {
5768
+ const provider = this.turnReversionProvider!;
5769
+ const inspection = await provider.inspectCapture(Object.freeze({
4497
5770
  scopeId,
4498
5771
  scope,
4499
5772
  storageKey,
@@ -4501,7 +5774,29 @@ export class FlexHarness<TScope = unknown> {
4501
5774
  runId,
4502
5775
  captureId,
4503
5776
  signal,
4504
- })));
5777
+ }));
5778
+ if (inspection.status !== 'finalized') return { status: inspection.status };
5779
+ if (this.reversionProviderProtocolVersion() === 2) {
5780
+ if (!('outcome' in inspection)) {
5781
+ throw new FlexHarnessValidationError('Protocol-2 finalized inspection has no outcome.');
5782
+ }
5783
+ return {
5784
+ status: 'finalized',
5785
+ outcome: this.normalizeReversionOutcome(inspection.outcome),
5786
+ };
5787
+ }
5788
+ if (!('reference' in inspection) || inspection.reference === undefined) {
5789
+ throw new FlexHarnessValidationError('Protocol-1 finalized inspection has no reference.');
5790
+ }
5791
+ return {
5792
+ status: 'finalized',
5793
+ outcome: {
5794
+ disposition: 'revertible',
5795
+ reference: this.normalizeReversionReference(inspection.reference),
5796
+ affectedWorkspaces: [],
5797
+ },
5798
+ };
5799
+ });
4505
5800
  }
4506
5801
 
4507
5802
  private finalizeReversionCapture(
@@ -4511,9 +5806,9 @@ export class FlexHarness<TScope = unknown> {
4511
5806
  sessionId: string,
4512
5807
  runId: string,
4513
5808
  captureId: string,
4514
- ) {
4515
- return this.withReversionMaintenanceSignal((signal) =>
4516
- this.turnReversionProvider!.finalize(Object.freeze({
5809
+ ): Promise<TFlexTurnReversionFinalizedOutcome> {
5810
+ return this.withReversionMaintenanceSignal(async (signal) => {
5811
+ const result = await this.turnReversionProvider!.finalize(Object.freeze({
4517
5812
  scopeId,
4518
5813
  scope,
4519
5814
  storageKey,
@@ -4521,10 +5816,18 @@ export class FlexHarness<TScope = unknown> {
4521
5816
  runId,
4522
5817
  captureId,
4523
5818
  signal,
4524
- })));
5819
+ }));
5820
+ return this.reversionProviderProtocolVersion() === 2
5821
+ ? this.normalizeReversionOutcome(result)
5822
+ : {
5823
+ disposition: 'revertible',
5824
+ reference: this.normalizeReversionReference(result),
5825
+ affectedWorkspaces: [],
5826
+ };
5827
+ });
4525
5828
  }
4526
5829
 
4527
- private async resolveReversionCaptureReference(
5830
+ private async resolveReversionCaptureOutcome(
4528
5831
  state: IStorageState,
4529
5832
  scopeId: string,
4530
5833
  scope: TScope,
@@ -4532,7 +5835,7 @@ export class FlexHarness<TScope = unknown> {
4532
5835
  runId: string,
4533
5836
  captureId: string,
4534
5837
  durableState: 'preparing' | 'prepared' | 'finalizing',
4535
- ): Promise<TJsonValue | undefined> {
5838
+ ): Promise<TFlexTurnReversionFinalizedOutcome | undefined> {
4536
5839
  let inspection;
4537
5840
  try {
4538
5841
  inspection = await this.inspectReversionCapture(
@@ -4556,10 +5859,10 @@ export class FlexHarness<TScope = unknown> {
4556
5859
  state.lifecycle = 'fenced';
4557
5860
  throw new FlexHarnessValidationError('Prepared reversion capture is missing.');
4558
5861
  }
4559
- let reference = inspection.reference;
5862
+ let outcome = inspection.status === 'finalized' ? inspection.outcome : undefined;
4560
5863
  if (inspection.status === 'prepared') {
4561
5864
  try {
4562
- reference = await this.finalizeReversionCapture(
5865
+ outcome = await this.finalizeReversionCapture(
4563
5866
  scopeId,
4564
5867
  scope,
4565
5868
  state.storageKey,
@@ -4576,27 +5879,123 @@ export class FlexHarness<TScope = unknown> {
4576
5879
  runId,
4577
5880
  captureId,
4578
5881
  );
4579
- if (finalInspection.status !== 'finalized' || finalInspection.reference === undefined) {
5882
+ if (finalInspection.status !== 'finalized') {
4580
5883
  if (finalInspection.status === 'unknown') state.lifecycle = 'fenced';
4581
5884
  throw this.projectOperationError(error, 'turnReversion', scopeId, sessionId, captureId);
4582
5885
  }
4583
- reference = finalInspection.reference;
5886
+ outcome = finalInspection.outcome;
5887
+ }
5888
+ }
5889
+ if (outcome === undefined) {
5890
+ state.lifecycle = 'fenced';
5891
+ throw new FlexHarnessValidationError('Finalized reversion capture has no outcome.');
5892
+ }
5893
+ return outcome;
5894
+ }
5895
+
5896
+ private normalizeReversionReference(reference: unknown): TJsonValue {
5897
+ return normalizeJsonValue(reference, {
5898
+ maxDepth: this.toolOutputLimits.maxDepth,
5899
+ maxBytes: Math.min(
5900
+ this.toolOutputLimits.maxBytes,
5901
+ FLEX_REVERSION_REFERENCE_MAX_BYTES,
5902
+ ),
5903
+ });
5904
+ }
5905
+
5906
+ private normalizeReversionOutcome(value: unknown): TFlexTurnReversionFinalizedOutcome {
5907
+ if (
5908
+ !value
5909
+ || typeof value !== 'object'
5910
+ || Array.isArray(value)
5911
+ || (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
5912
+ ) throw new FlexHarnessValidationError('Turn reversion finalized outcome must be a plain object.');
5913
+ const outcome = value as Record<string, unknown>;
5914
+ const disposition = outcome.disposition;
5915
+ if (!['revertible', 'no-change', 'nonrevertible'].includes(String(disposition))) {
5916
+ throw new FlexHarnessValidationError('Turn reversion finalized outcome disposition is invalid.');
5917
+ }
5918
+ const allowedKeys = disposition === 'nonrevertible'
5919
+ ? ['disposition', 'reference', 'reasonCode', 'affectedWorkspaces']
5920
+ : ['disposition', 'reference', 'affectedWorkspaces'];
5921
+ const unsupported = Object.keys(outcome).find((key) => !allowedKeys.includes(key));
5922
+ if (unsupported) {
5923
+ throw new FlexHarnessValidationError(
5924
+ `Turn reversion finalized outcome does not support "${unsupported}".`,
5925
+ );
5926
+ }
5927
+ if (!Object.prototype.hasOwnProperty.call(outcome, 'reference')) {
5928
+ throw new FlexHarnessValidationError('Turn reversion finalized outcome requires a reference.');
5929
+ }
5930
+ const reference = this.normalizeReversionReference(outcome.reference);
5931
+ const affectedWorkspaces = outcome.affectedWorkspaces === undefined
5932
+ ? undefined
5933
+ : this.normalizeAffectedWorkspaces(outcome.affectedWorkspaces);
5934
+ if (disposition === 'revertible') {
5935
+ if (affectedWorkspaces === undefined) {
5936
+ throw new FlexHarnessValidationError('Revertible outcome requires affectedWorkspaces.');
4584
5937
  }
5938
+ return { disposition, reference, affectedWorkspaces };
4585
5939
  }
4586
- if (reference === undefined) {
4587
- state.lifecycle = 'fenced';
4588
- throw new FlexHarnessValidationError('Finalized reversion capture has no reference.');
5940
+ if (disposition === 'no-change') {
5941
+ if (affectedWorkspaces && affectedWorkspaces.length > 0) {
5942
+ throw new FlexHarnessValidationError('No-change outcome cannot affect a workspace.');
5943
+ }
5944
+ return {
5945
+ disposition,
5946
+ reference,
5947
+ ...(affectedWorkspaces === undefined ? {} : { affectedWorkspaces: [] }),
5948
+ };
4589
5949
  }
4590
- return this.normalizeReversionReference(reference);
5950
+ if (
5951
+ typeof outcome.reasonCode !== 'string'
5952
+ || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(outcome.reasonCode)
5953
+ || Buffer.byteLength(outcome.reasonCode, 'utf8') > FLEX_REVERSION_REASON_CODE_MAX_BYTES
5954
+ ) throw new FlexHarnessValidationError('Nonrevertible outcome reasonCode is invalid.');
5955
+ return {
5956
+ disposition: 'nonrevertible',
5957
+ reference,
5958
+ reasonCode: outcome.reasonCode,
5959
+ ...(affectedWorkspaces === undefined ? {} : { affectedWorkspaces }),
5960
+ };
4591
5961
  }
4592
5962
 
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
- ),
5963
+ private normalizeAffectedWorkspaces(value: unknown): IFlexAffectedWorkspace[] {
5964
+ if (!Array.isArray(value) || value.length > FLEX_REVERSION_MAX_AFFECTED_WORKSPACES) {
5965
+ throw new FlexHarnessValidationError(
5966
+ `affectedWorkspaces must contain at most ${FLEX_REVERSION_MAX_AFFECTED_WORKSPACES} entries.`,
5967
+ );
5968
+ }
5969
+ const ids = new Set<string>();
5970
+ return value.map((entry, index) => {
5971
+ if (
5972
+ !entry
5973
+ || typeof entry !== 'object'
5974
+ || Array.isArray(entry)
5975
+ || (Object.getPrototypeOf(entry) !== Object.prototype && Object.getPrototypeOf(entry) !== null)
5976
+ ) throw new FlexHarnessValidationError(`affectedWorkspaces[${index}] must be a plain object.`);
5977
+ const descriptor = entry as Record<string, unknown>;
5978
+ const unsupported = Object.keys(descriptor).find((key) => !['id', 'label'].includes(key));
5979
+ if (unsupported) {
5980
+ throw new FlexHarnessValidationError(
5981
+ `affectedWorkspaces[${index}] does not support "${unsupported}".`,
5982
+ );
5983
+ }
5984
+ if (
5985
+ typeof descriptor.id !== 'string'
5986
+ || !descriptor.id.trim()
5987
+ || Buffer.byteLength(descriptor.id, 'utf8') > FLEX_REVERSION_WORKSPACE_ID_MAX_BYTES
5988
+ ) throw new FlexHarnessValidationError(`affectedWorkspaces[${index}].id is invalid.`);
5989
+ if (
5990
+ typeof descriptor.label !== 'string'
5991
+ || !descriptor.label.trim()
5992
+ || Buffer.byteLength(descriptor.label, 'utf8') > FLEX_REVERSION_WORKSPACE_LABEL_MAX_BYTES
5993
+ ) throw new FlexHarnessValidationError(`affectedWorkspaces[${index}].label is invalid.`);
5994
+ if (ids.has(descriptor.id)) {
5995
+ throw new FlexHarnessValidationError(`affectedWorkspaces contains duplicate id "${descriptor.id}".`);
5996
+ }
5997
+ ids.add(descriptor.id);
5998
+ return { id: descriptor.id, label: descriptor.label };
4600
5999
  });
4601
6000
  }
4602
6001
 
@@ -4612,6 +6011,13 @@ export class FlexHarness<TScope = unknown> {
4612
6011
  );
4613
6012
  }
4614
6013
  const context = this.reversionCaptureContext(run, captureId, signal);
6014
+ if (this.reversionProviderProtocolVersion() !== segment.protocolVersion) {
6015
+ throw this.projectExternalError(
6016
+ run,
6017
+ new Error(`Root generation requires reversion protocolVersion ${segment.protocolVersion}.`),
6018
+ 'turnReversion',
6019
+ );
6020
+ }
4615
6021
  try {
4616
6022
  await this.turnReversionProvider.prepare(context);
4617
6023
  } catch (error) {
@@ -4653,7 +6059,7 @@ export class FlexHarness<TScope = unknown> {
4653
6059
  const eventIds = run.stored.agentSession.getEvents()
4654
6060
  .filter((event) => event.generationId === run.runId)
4655
6061
  .map((event) => event.id);
4656
- if (!this.turnReversionProvider || !segment.captureId) {
6062
+ if (!this.turnReversionProvider || segment.provenance === 'transcript') {
4657
6063
  await this.mutateProjection(run.state, run.stored, () => {
4658
6064
  const wasCompleted = segment.status === 'completed';
4659
6065
  segment.status = status;
@@ -4665,6 +6071,9 @@ export class FlexHarness<TScope = unknown> {
4665
6071
  }, true);
4666
6072
  return;
4667
6073
  }
6074
+ if (!segment.captureId) {
6075
+ throw new FlexHarnessValidationError('Workspace reversion segment has no capture ownership.');
6076
+ }
4668
6077
  const captureId = segment.captureId;
4669
6078
  if (segment.workspaceReference !== undefined) {
4670
6079
  await this.mutateProjection(run.state, run.stored, () => {
@@ -4681,16 +6090,30 @@ export class FlexHarness<TScope = unknown> {
4681
6090
  if (pending?.kind !== 'capture' || pending.runId !== run.runId) {
4682
6091
  throw new FlexHarnessValidationError('Reversion capture intent changed before finalization.');
4683
6092
  }
4684
- const reference = await this.resolveReversionCaptureReference(
6093
+ if (this.reversionProviderProtocolVersion() !== pending.protocolVersion) {
6094
+ throw new FlexHarnessValidationError(
6095
+ `Reversion capture finalization requires protocolVersion ${pending.protocolVersion}.`,
6096
+ );
6097
+ }
6098
+ if (pending.state === 'prepared') {
6099
+ await this.mutateProjection(run.state, run.stored, () => {
6100
+ const current = run.stored.pendingReversion;
6101
+ if (current?.kind !== 'capture' || current.captureId !== captureId) {
6102
+ throw new FlexHarnessValidationError('Reversion capture changed before finalization.');
6103
+ }
6104
+ current.state = 'finalizing';
6105
+ }, true);
6106
+ }
6107
+ const outcome = await this.resolveReversionCaptureOutcome(
4685
6108
  run.state,
4686
6109
  run.scopeId,
4687
6110
  run.scope as TScope,
4688
6111
  run.sessionId,
4689
6112
  run.runId,
4690
6113
  captureId,
4691
- pending.state,
6114
+ pending.state === 'prepared' ? 'finalizing' : pending.state,
4692
6115
  );
4693
- if (reference === undefined) {
6116
+ if (outcome === undefined) {
4694
6117
  await this.mutateProjection(run.state, run.stored, () => {
4695
6118
  run.stored.reversionSegments = run.stored.reversionSegments.filter(
4696
6119
  (entry) => entry.runId !== run.runId,
@@ -4707,7 +6130,7 @@ export class FlexHarness<TScope = unknown> {
4707
6130
  const wasCompleted = segment.status === 'completed';
4708
6131
  segment.status = status;
4709
6132
  segment.eventIds = eventIds;
4710
- segment.workspaceReference = reference;
6133
+ this.applyFinalizedReversionOutcome(run.stored, segment, outcome);
4711
6134
  if (!wasCompleted && status === 'completed') run.stored.revertCursor++;
4712
6135
  else if (wasCompleted && status !== 'completed') run.stored.revertCursor--;
4713
6136
  delete run.stored.pendingReversion;
@@ -4715,34 +6138,73 @@ export class FlexHarness<TScope = unknown> {
4715
6138
  }, true);
4716
6139
  }
4717
6140
 
6141
+ private applyFinalizedReversionOutcome(
6142
+ stored: IStoredSessionState,
6143
+ segment: IFlexReversionSegment,
6144
+ outcome: TFlexTurnReversionFinalizedOutcome,
6145
+ ): void {
6146
+ const captureId = segment.captureId;
6147
+ if (!captureId) {
6148
+ throw new FlexHarnessValidationError('Finalized workspace outcome has no capture ownership.');
6149
+ }
6150
+ segment.disposition = outcome.disposition;
6151
+ delete segment.affectedWorkspaces;
6152
+ delete segment.reasonCode;
6153
+ if (segment.protocolVersion === 2) {
6154
+ if (outcome.affectedWorkspaces !== undefined) {
6155
+ segment.affectedWorkspaces = cloneSerializable([...outcome.affectedWorkspaces]);
6156
+ }
6157
+ if (outcome.disposition === 'nonrevertible') segment.reasonCode = outcome.reasonCode;
6158
+ }
6159
+ if (outcome.disposition === 'revertible') {
6160
+ segment.workspaceReference = cloneSerializable(outcome.reference);
6161
+ return;
6162
+ }
6163
+ this.enqueueReversionReleaseReference(stored, {
6164
+ runId: segment.runId,
6165
+ captureId,
6166
+ reference: cloneSerializable(outcome.reference),
6167
+ protocolVersion: segment.protocolVersion,
6168
+ });
6169
+ delete segment.captureId;
6170
+ delete segment.workspaceReference;
6171
+ }
6172
+
4718
6173
  private hiddenReversionSegments(stored: IStoredSessionState): IFlexReversionSegment[] {
4719
- const candidates = this.completedReversionCandidates(stored);
4720
- const firstHidden = candidates[stored.revertCursor];
6174
+ const firstHidden = this.reversionGroups(stored)[stored.revertCursor]?.segments[0];
4721
6175
  if (!firstHidden) return [];
4722
6176
  const index = stored.reversionSegments.findIndex((segment) => segment.runId === firstHidden.runId);
4723
6177
  if (index < 0) return [];
4724
- return stored.reversionSegments.slice(firstHidden === candidates[0] ? 0 : index);
6178
+ return stored.reversionSegments.slice(index);
4725
6179
  }
4726
6180
 
4727
6181
  private enqueueReversionRelease(
4728
6182
  stored: IStoredSessionState,
4729
6183
  segment: IFlexReversionSegment,
4730
6184
  ): void {
4731
- if (!segment.workspaceCaptured) return;
6185
+ if (segment.disposition !== 'revertible') return;
4732
6186
  if (segment.captureId === undefined || segment.workspaceReference === undefined) {
4733
6187
  throw new FlexHarnessValidationError('Capture-backed segment has no releasable workspace reference.');
4734
6188
  }
4735
- if (stored.pendingReversionReleases.some((release) => release.captureId === segment.captureId)) {
6189
+ this.enqueueReversionReleaseReference(stored, {
6190
+ runId: segment.runId,
6191
+ captureId: segment.captureId,
6192
+ reference: cloneSerializable(segment.workspaceReference),
6193
+ protocolVersion: segment.protocolVersion,
6194
+ });
6195
+ }
6196
+
6197
+ private enqueueReversionReleaseReference(
6198
+ stored: IStoredSessionState,
6199
+ release: IFlexPendingReversionRelease,
6200
+ ): void {
6201
+ if (stored.pendingReversionReleases.some((entry) => entry.captureId === release.captureId)) {
4736
6202
  return;
4737
6203
  }
4738
6204
  if (stored.pendingReversionReleases.length >= this.reversionLimits.maxPendingReversionReleases) {
4739
6205
  throw new FlexHarnessValidationError('Pending reversion release limit was reached.');
4740
6206
  }
4741
- stored.pendingReversionReleases.push({
4742
- runId: segment.runId,
4743
- captureId: segment.captureId,
4744
- reference: cloneSerializable(segment.workspaceReference),
4745
- });
6207
+ stored.pendingReversionReleases.push(cloneSerializable(release));
4746
6208
  }
4747
6209
 
4748
6210
  private pruneReversionState(
@@ -4784,7 +6246,8 @@ export class FlexHarness<TScope = unknown> {
4784
6246
  }
4785
6247
  if (end <= 0) break;
4786
6248
  const prefix = stored.reversionSegments.slice(0, end);
4787
- if (prefix.some((segment) => segment.workspaceCaptured && segment.workspaceReference === undefined)) break;
6249
+ if (prefix.some((segment) =>
6250
+ segment.disposition === 'revertible' && segment.workspaceReference === undefined)) break;
4788
6251
  for (const segment of prefix) this.enqueueReversionRelease(stored, segment);
4789
6252
  stored.reversionSegments.splice(0, end);
4790
6253
  stored.revertCursor = Math.max(
@@ -4897,6 +6360,11 @@ export class FlexHarness<TScope = unknown> {
4897
6360
  }
4898
6361
  while (stored.pendingReversionReleases.length > 0) {
4899
6362
  const release = stored.pendingReversionReleases[0];
6363
+ if (this.reversionProviderProtocolVersion() !== release.protocolVersion) {
6364
+ throw new FlexHarnessValidationError(
6365
+ `Pending workspace release requires protocolVersion ${release.protocolVersion}.`,
6366
+ );
6367
+ }
4900
6368
  try {
4901
6369
  await this.withReversionMaintenanceSignal((signal) =>
4902
6370
  this.turnReversionProvider!.release(Object.freeze({
@@ -4947,9 +6415,33 @@ export class FlexHarness<TScope = unknown> {
4947
6415
  stored?: IStoredSessionState,
4948
6416
  ): Promise<void> {
4949
6417
  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;
6418
+ let durableProjection = await this.stores.projections.load(storageKey, sessionId);
6419
+ if (!durableProjection) return;
6420
+ let projection = this.upgradeProjectionSnapshot(durableProjection);
6421
+ const persistDirect = async (next: IFlexProjectionSnapshotCurrent): Promise<void> => {
6422
+ const before = durableProjection;
6423
+ try {
6424
+ await this.stores.projections.save(storageKey, sessionId, next, projection.revision);
6425
+ durableProjection = next;
6426
+ projection = next;
6427
+ } catch (error) {
6428
+ let current: TFlexProjectionSnapshot | undefined;
6429
+ let reconciliationError: unknown;
6430
+ try {
6431
+ current = await this.stores.projections.load(storageKey, sessionId);
6432
+ } catch (loadError) {
6433
+ reconciliationError = loadError;
6434
+ }
6435
+ if (current && JSON.stringify(current) === JSON.stringify(next)) {
6436
+ durableProjection = current;
6437
+ projection = next;
6438
+ return;
6439
+ }
6440
+ if (current && before && JSON.stringify(current) === JSON.stringify(before)) throw error;
6441
+ state.lifecycle = 'fenced';
6442
+ throw reconciliationError === undefined ? error : combineErrors([error, reconciliationError]);
6443
+ }
6444
+ };
4953
6445
  if (projection.pendingReversion?.kind === 'apply') {
4954
6446
  if (stored) {
4955
6447
  await this.resumePendingApply(state, stored, scopeId, scope);
@@ -4961,12 +6453,17 @@ export class FlexHarness<TScope = unknown> {
4961
6453
  for (const runId of orderedRunIds) {
4962
6454
  if (pending.appliedRunIds.includes(runId)) continue;
4963
6455
  const segment = projection.reversionSegments.find((entry) => entry.runId === runId)!;
4964
- if (segment.workspaceCaptured) {
6456
+ if (segment.disposition === 'revertible') {
4965
6457
  if (!this.turnReversionProvider || !segment.captureId || segment.workspaceReference === undefined) {
4966
6458
  throw new FlexHarnessValidationError(
4967
6459
  'Pending workspace reversion deletion requires its turn reversion provider.',
4968
6460
  );
4969
6461
  }
6462
+ if (this.reversionProviderProtocolVersion() !== segment.protocolVersion) {
6463
+ throw new FlexHarnessValidationError(
6464
+ `Pending workspace reversion deletion requires protocolVersion ${segment.protocolVersion}.`,
6465
+ );
6466
+ }
4970
6467
  const captureId = segment.captureId;
4971
6468
  const reference = segment.workspaceReference;
4972
6469
  const operationId = `${pending.operationId}:${sha256Hex(runId).slice(0, 16)}`;
@@ -4985,6 +6482,7 @@ export class FlexHarness<TScope = unknown> {
4985
6482
  let inspection = await this.withReversionMaintenanceSignal((signal) =>
4986
6483
  this.turnReversionProvider!.inspectApply(createContext(signal)));
4987
6484
  if (inspection.status === 'unknown') {
6485
+ state.lifecycle = 'fenced';
4988
6486
  throw new FlexHarnessValidationError('Workspace reversion apply outcome is unknown.');
4989
6487
  }
4990
6488
  if (inspection.status === 'not-applied') {
@@ -4994,14 +6492,15 @@ export class FlexHarness<TScope = unknown> {
4994
6492
  } catch (error) {
4995
6493
  inspection = await this.withReversionMaintenanceSignal((signal) =>
4996
6494
  this.turnReversionProvider!.inspectApply(createContext(signal)));
4997
- if (inspection.status !== 'applied') throw error;
6495
+ if (inspection.status !== 'applied') {
6496
+ if (inspection.status === 'unknown') state.lifecycle = 'fenced';
6497
+ throw error;
6498
+ }
4998
6499
  }
4999
6500
  }
5000
6501
  }
5001
6502
  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;
6503
+ await persistDirect({ ...projection, revision: projection.revision + 1 });
5005
6504
  }
5006
6505
  const { pendingReversion: _pendingReversion, ...completedProjection } = projection;
5007
6506
  const completed: IFlexProjectionSnapshotCurrent = {
@@ -5009,23 +6508,12 @@ export class FlexHarness<TScope = unknown> {
5009
6508
  revision: projection.revision + 1,
5010
6509
  revertCursor: pending.toCursor,
5011
6510
  };
5012
- await this.stores.projections.save(storageKey, sessionId, completed, projection.revision);
5013
- projection = completed;
6511
+ await persistDirect(completed);
5014
6512
  }
5015
6513
  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;
6514
+ if (!recoveredProjection) return;
6515
+ durableProjection = recoveredProjection;
6516
+ projection = this.upgradeProjectionSnapshot(recoveredProjection);
5029
6517
  }
5030
6518
  const releases = new Map<string, IFlexPendingReversionRelease>();
5031
6519
  for (const release of projection.pendingReversionReleases) {
@@ -5037,12 +6525,14 @@ export class FlexHarness<TScope = unknown> {
5037
6525
  runId: segment.runId,
5038
6526
  captureId: segment.captureId,
5039
6527
  reference: cloneSerializable(segment.workspaceReference),
6528
+ protocolVersion: segment.protocolVersion,
5040
6529
  });
5041
6530
  }
5042
6531
  }
5043
6532
  const unresolvedCaptures = new Map<string, {
5044
6533
  runId: string;
5045
6534
  captureId: string;
6535
+ protocolVersion: 1 | 2;
5046
6536
  durableState?: 'preparing' | 'prepared' | 'finalizing';
5047
6537
  }>();
5048
6538
  for (const segment of projection.reversionSegments) {
@@ -5050,6 +6540,7 @@ export class FlexHarness<TScope = unknown> {
5050
6540
  unresolvedCaptures.set(segment.captureId, {
5051
6541
  runId: segment.runId,
5052
6542
  captureId: segment.captureId,
6543
+ protocolVersion: segment.protocolVersion,
5053
6544
  });
5054
6545
  }
5055
6546
  }
@@ -5057,10 +6548,36 @@ export class FlexHarness<TScope = unknown> {
5057
6548
  unresolvedCaptures.set(projection.pendingReversion.captureId, {
5058
6549
  runId: projection.pendingReversion.runId,
5059
6550
  captureId: projection.pendingReversion.captureId,
6551
+ protocolVersion: projection.pendingReversion.protocolVersion,
5060
6552
  durableState: projection.pendingReversion.state,
5061
6553
  });
5062
6554
  }
6555
+ if (unresolvedCaptures.size > 0 && !this.turnReversionProvider) {
6556
+ throw new FlexHarnessValidationError(
6557
+ 'Capture-backed session deletion requires its turn reversion provider.',
6558
+ );
6559
+ }
5063
6560
  for (const capture of unresolvedCaptures.values()) {
6561
+ if (this.reversionProviderProtocolVersion() !== capture.protocolVersion) {
6562
+ throw new FlexHarnessValidationError(
6563
+ `Deleted session capture requires protocolVersion ${capture.protocolVersion}.`,
6564
+ );
6565
+ }
6566
+ if (capture.durableState === 'prepared') {
6567
+ if (stored) {
6568
+ await this.mutateProjection(state, stored, () => {
6569
+ const pending = stored.pendingReversion;
6570
+ if (pending?.kind !== 'capture' || pending.captureId !== capture.captureId) {
6571
+ throw new FlexHarnessValidationError('Deleted session capture changed before finalization.');
6572
+ }
6573
+ pending.state = 'finalizing';
6574
+ }, true);
6575
+ } else if (projection.pendingReversion?.kind === 'capture') {
6576
+ projection.pendingReversion.state = 'finalizing';
6577
+ await persistDirect({ ...projection, revision: projection.revision + 1 });
6578
+ }
6579
+ capture.durableState = 'finalizing';
6580
+ }
5064
6581
  let inspection;
5065
6582
  try {
5066
6583
  inspection = await this.inspectReversionCapture(
@@ -5081,6 +6598,7 @@ export class FlexHarness<TScope = unknown> {
5081
6598
  );
5082
6599
  }
5083
6600
  if (inspection.status === 'unknown') {
6601
+ state.lifecycle = 'fenced';
5084
6602
  throw new FlexHarnessValidationError('Deleted session capture outcome is unknown.');
5085
6603
  }
5086
6604
  if (inspection.status === 'missing') {
@@ -5088,10 +6606,10 @@ export class FlexHarness<TScope = unknown> {
5088
6606
  state.lifecycle = 'fenced';
5089
6607
  throw new FlexHarnessValidationError('Prepared deleted-session capture is missing.');
5090
6608
  }
5091
- let reference = inspection.reference;
6609
+ let outcome = inspection.status === 'finalized' ? inspection.outcome : undefined;
5092
6610
  if (inspection.status === 'prepared') {
5093
6611
  try {
5094
- reference = await this.finalizeReversionCapture(
6612
+ outcome = await this.finalizeReversionCapture(
5095
6613
  scopeId,
5096
6614
  scope,
5097
6615
  storageKey,
@@ -5108,7 +6626,8 @@ export class FlexHarness<TScope = unknown> {
5108
6626
  capture.runId,
5109
6627
  capture.captureId,
5110
6628
  );
5111
- if (finalInspection.status !== 'finalized' || finalInspection.reference === undefined) {
6629
+ if (finalInspection.status !== 'finalized') {
6630
+ if (finalInspection.status === 'unknown') state.lifecycle = 'fenced';
5112
6631
  throw this.projectOperationError(
5113
6632
  error,
5114
6633
  'turnReversion',
@@ -5117,63 +6636,77 @@ export class FlexHarness<TScope = unknown> {
5117
6636
  `release:${capture.captureId}`,
5118
6637
  );
5119
6638
  }
5120
- reference = finalInspection.reference;
6639
+ outcome = finalInspection.outcome;
5121
6640
  }
5122
6641
  }
5123
- if (reference === undefined) {
6642
+ if (outcome === undefined) {
5124
6643
  throw new FlexHarnessValidationError('Deleted session capture has no release reference.');
5125
6644
  }
5126
6645
  releases.set(capture.captureId, {
5127
6646
  runId: capture.runId,
5128
6647
  captureId: capture.captureId,
5129
- reference: this.normalizeReversionReference(reference),
6648
+ reference: cloneSerializable(outcome.reference),
6649
+ protocolVersion: capture.protocolVersion,
5130
6650
  });
5131
6651
  }
6652
+ if (releases.size === 0) return;
6653
+ if (!this.turnReversionProvider) {
6654
+ throw new FlexHarnessValidationError(
6655
+ 'Capture-backed session deletion requires its turn reversion provider.',
6656
+ );
6657
+ }
5132
6658
  if (stored) {
5133
6659
  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
- }
6660
+ stored.reversionSegments = [];
6661
+ stored.revertCursor = 0;
6662
+ delete stored.pendingReversion;
6663
+ stored.pendingReversionReleases = [...releases.values()].map((release) =>
6664
+ cloneSerializable(release));
5139
6665
  }, true);
6666
+ await this.drainReversionReleases(state, stored, scopeId, scope, true);
5140
6667
  } 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({
6668
+ const { pendingReversion: _pendingReversion, ...withoutPending } = projection;
6669
+ await persistDirect({
6670
+ ...withoutPending,
6671
+ revision: projection.revision + 1,
6672
+ reversionSegments: [],
6673
+ revertCursor: 0,
6674
+ pendingReversionReleases: [...releases.values()].map((release) =>
6675
+ cloneSerializable(release)),
6676
+ });
6677
+ while (projection.pendingReversionReleases.length > 0) {
6678
+ const release = projection.pendingReversionReleases[0];
6679
+ if (this.reversionProviderProtocolVersion() !== release.protocolVersion) {
6680
+ throw new FlexHarnessValidationError(
6681
+ `Deleted session release requires protocolVersion ${release.protocolVersion}.`,
6682
+ );
6683
+ }
6684
+ try {
6685
+ await this.withReversionMaintenanceSignal((signal) =>
6686
+ this.turnReversionProvider!.release(Object.freeze({
6687
+ scopeId,
6688
+ scope,
6689
+ storageKey,
6690
+ sessionId,
6691
+ runId: release.runId,
6692
+ captureId: release.captureId,
6693
+ reference: cloneSerializable(release.reference),
6694
+ signal,
6695
+ })));
6696
+ } catch (error) {
6697
+ throw this.projectOperationError(
6698
+ error,
6699
+ 'turnReversion',
5160
6700
  scopeId,
5161
- scope,
5162
- storageKey,
5163
6701
  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
- );
6702
+ `release:${release.captureId}`,
6703
+ );
6704
+ }
6705
+ await persistDirect({
6706
+ ...projection,
6707
+ revision: projection.revision + 1,
6708
+ pendingReversionReleases: projection.pendingReversionReleases.slice(1),
6709
+ });
5177
6710
  }
5178
6711
  }
5179
6712
  }
@@ -5831,6 +7364,7 @@ export class FlexHarness<TScope = unknown> {
5831
7364
  throw new FlexHarnessValidationError('Tool handle close must be a function.');
5832
7365
  }
5833
7366
  const tools = handle.tools;
7367
+ this.assertNoBuiltInToolCollisions(run, tools);
5834
7368
  const closeHandle = close;
5835
7369
  return {
5836
7370
  tools,
@@ -6170,6 +7704,28 @@ export class FlexHarness<TScope = unknown> {
6170
7704
  sessions: [],
6171
7705
  tombstones: [],
6172
7706
  } satisfies IFlexScopeSnapshot);
7707
+ let scopeChanged = false;
7708
+ const repairedGenerationSequence = snapshot.revision + 1;
7709
+ for (const session of snapshot.sessions) {
7710
+ if (session.sessionGenerationId !== undefined) continue;
7711
+ session.sessionGenerationId = deriveLegacySessionGenerationId(
7712
+ storageKey,
7713
+ session.sessionId,
7714
+ session.createdAt,
7715
+ );
7716
+ session.sessionGenerationSequence = repairedGenerationSequence;
7717
+ scopeChanged = true;
7718
+ }
7719
+ for (const tombstone of snapshot.tombstones) {
7720
+ if (tombstone.sessionGenerationId !== undefined) continue;
7721
+ tombstone.sessionGenerationId = deriveLegacySessionGenerationId(
7722
+ storageKey,
7723
+ tombstone.sessionId,
7724
+ tombstone.deletedAt,
7725
+ );
7726
+ tombstone.sessionGenerationSequence = repairedGenerationSequence;
7727
+ scopeChanged = true;
7728
+ }
6173
7729
  const state: IStorageState = {
6174
7730
  storageKey,
6175
7731
  compactorLifecycleController,
@@ -6192,7 +7748,6 @@ export class FlexHarness<TScope = unknown> {
6192
7748
  detachedCleanups: new Set(),
6193
7749
  };
6194
7750
  const loadedSessions: IStoredSessionState[] = [];
6195
- let scopeChanged = false;
6196
7751
  try {
6197
7752
  for (const metadata of snapshot.sessions) {
6198
7753
  if (state.tombstones.has(metadata.sessionId)) continue;
@@ -6244,12 +7799,16 @@ export class FlexHarness<TScope = unknown> {
6244
7799
  scopeId,
6245
7800
  scope,
6246
7801
  );
6247
- await this.cleanupSessionDomains(storageKey, tombstone.sessionId);
7802
+ await this.cleanupSessionDomains(storageKey, tombstone);
6248
7803
  }
6249
7804
  for (const tombstone of group) state.tombstones.delete(tombstone.sessionId);
6250
7805
  scopeChanged = true;
6251
- } catch {
7806
+ } catch (error) {
6252
7807
  // A retained tombstone is retried by the next load or explicit delete call.
7808
+ if (state.lifecycle === 'fenced') {
7809
+ this.deferNamespaceDrain(state, error, { scopeId, scope });
7810
+ break;
7811
+ }
6253
7812
  }
6254
7813
  }
6255
7814
  if (scopeChanged) {
@@ -6305,7 +7864,7 @@ export class FlexHarness<TScope = unknown> {
6305
7864
  revertCursor: 0,
6306
7865
  excludedRunIds: [],
6307
7866
  pendingReversionReleases: [],
6308
- projectionSchemaVersion: 2,
7867
+ projectionSchemaVersion: 3,
6309
7868
  projectionReconciliationRequired: false,
6310
7869
  projectionRevision: 0,
6311
7870
  projectionQueue: Promise.resolve(),
@@ -6373,7 +7932,28 @@ export class FlexHarness<TScope = unknown> {
6373
7932
  throw combineErrors(acquisitionErrors);
6374
7933
  }
6375
7934
  const projection = projectionResult.value;
6376
- const projectionV2 = projection?.schemaVersion === 2 ? projection : undefined;
7935
+ const legacyProjection = projection?.schemaVersion === 2 ? projection : undefined;
7936
+ const currentProjection = projection?.schemaVersion === 3 ? projection : undefined;
7937
+ const reversionSegments: IFlexReversionSegment[] = currentProjection
7938
+ ? cloneSerializable(currentProjection.reversionSegments)
7939
+ : (legacyProjection?.reversionSegments ?? []).map((segment): IFlexReversionSegment => ({
7940
+ ...cloneSerializable(segment),
7941
+ protocolVersion: 1,
7942
+ provenance: segment.workspaceCaptured ? 'workspace' : 'transcript',
7943
+ ...(segment.workspaceCaptured
7944
+ ? { disposition: segment.status === 'capturing' ? 'pending' : 'revertible' }
7945
+ : {}),
7946
+ }));
7947
+ const pendingReversion = currentProjection?.pendingReversion
7948
+ ?? (legacyProjection?.pendingReversion?.kind === 'capture'
7949
+ ? { ...legacyProjection.pendingReversion, protocolVersion: 1 as const }
7950
+ : legacyProjection?.pendingReversion);
7951
+ const pendingReversionReleases: IFlexPendingReversionRelease[] = currentProjection
7952
+ ? cloneSerializable(currentProjection.pendingReversionReleases)
7953
+ : (legacyProjection?.pendingReversionReleases ?? []).map((release) => ({
7954
+ ...cloneSerializable(release),
7955
+ protocolVersion: 1,
7956
+ }));
6377
7957
  const permission = permissionResult.value;
6378
7958
  const eventStore = eventStoreResult.value;
6379
7959
  const jobStore = jobStoreResult.value;
@@ -6404,18 +7984,18 @@ export class FlexHarness<TScope = unknown> {
6404
7984
  session: cloneSerializable(metadata),
6405
7985
  messages: cloneSerializable(projection?.messages ?? []),
6406
7986
  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 ?? [],
7987
+ reversionSegments,
7988
+ revertCursor: currentProjection?.revertCursor ?? legacyProjection?.revertCursor ?? 0,
7989
+ excludedRunIds: cloneSerializable(
7990
+ currentProjection?.excludedRunIds ?? legacyProjection?.excludedRunIds ?? [],
6415
7991
  ),
6416
- projectionSchemaVersion: projection?.schemaVersion ?? 2,
7992
+ ...(pendingReversion === undefined
7993
+ ? {}
7994
+ : { pendingReversion: cloneSerializable(pendingReversion) }),
7995
+ pendingReversionReleases,
7996
+ projectionSchemaVersion: projection?.schemaVersion ?? 3,
6417
7997
  projectionReconciliationRequired: false,
6418
- ...(projection?.schemaVersion === 1
7998
+ ...(projection && projection.schemaVersion !== 3
6419
7999
  ? { projectionBaseline: cloneSerializable(projection) }
6420
8000
  : {}),
6421
8001
  projectionRevision: projection?.revision ?? 0,
@@ -6585,6 +8165,31 @@ export class FlexHarness<TScope = unknown> {
6585
8165
  'Capture-backed session recovery requires its turn reversion provider.',
6586
8166
  );
6587
8167
  }
8168
+ const requiredProtocols = new Set<number>([
8169
+ ...stored.reversionSegments
8170
+ .filter((segment) => segment.captureId !== undefined)
8171
+ .map((segment) => segment.protocolVersion),
8172
+ ...stored.pendingReversionReleases.map((release) => release.protocolVersion),
8173
+ ...(stored.pendingReversion?.kind === 'capture'
8174
+ ? [stored.pendingReversion.protocolVersion]
8175
+ : []),
8176
+ ]);
8177
+ const providerProtocol = this.reversionProviderProtocolVersion();
8178
+ if (requiredProtocols.size > 0 && (
8179
+ requiredProtocols.size !== 1
8180
+ || !requiredProtocols.has(providerProtocol ?? 0)
8181
+ )) {
8182
+ throw new FlexHarnessValidationError(
8183
+ `Capture-backed session recovery requires protocolVersion ${[...requiredProtocols].join(', ')}.`,
8184
+ );
8185
+ }
8186
+ if (stored.pendingReversion?.kind === 'capture' && stored.pendingReversionReleases.length > 0) {
8187
+ if (!state || scopeId === undefined || scope === undefined) {
8188
+ throw new FlexHarnessValidationError('Pending capture recovery is missing its scope context.');
8189
+ }
8190
+ await this.drainReversionReleases(state, stored, scopeId, scope, false);
8191
+ this.assertStateAcceptingWork(state);
8192
+ }
6588
8193
  const outcomes = this.canonicalOutcomes(stored.agentSession.getEvents());
6589
8194
  const firstHiddenCandidate = this.completedReversionCandidates(stored)[stored.revertCursor];
6590
8195
  const reversionVisibilityBoundary = firstHiddenCandidate
@@ -6698,7 +8303,7 @@ export class FlexHarness<TScope = unknown> {
6698
8303
  snapshot,
6699
8304
  expected,
6700
8305
  );
6701
- stored.projectionSchemaVersion = 2;
8306
+ stored.projectionSchemaVersion = 3;
6702
8307
  delete stored.projectionBaseline;
6703
8308
  stored.projectionReconciliationRequired = false;
6704
8309
  stored.projectionRevision = snapshot.revision;
@@ -6875,7 +8480,7 @@ export class FlexHarness<TScope = unknown> {
6875
8480
  : cloneSerializable(stored.pendingReversion);
6876
8481
  const beforePendingReversionReleases = cloneSerializable(stored.pendingReversionReleases);
6877
8482
  const beforeSnapshot: TFlexProjectionSnapshot = beforeBaseline ?? {
6878
- schemaVersion: 2,
8483
+ schemaVersion: 3,
6879
8484
  revision: beforeRevision,
6880
8485
  messages: cloneSerializable(beforeMessages),
6881
8486
  stagedTerminals: cloneSerializable(beforeStages),
@@ -6917,7 +8522,7 @@ export class FlexHarness<TScope = unknown> {
6917
8522
  snapshot,
6918
8523
  beforeRevision,
6919
8524
  );
6920
- stored.projectionSchemaVersion = 2;
8525
+ stored.projectionSchemaVersion = 3;
6921
8526
  delete stored.projectionBaseline;
6922
8527
  stored.projectionReconciliationRequired = false;
6923
8528
  stored.projectionRevision = snapshot.revision;
@@ -6928,7 +8533,7 @@ export class FlexHarness<TScope = unknown> {
6928
8533
  throw error;
6929
8534
  }
6930
8535
  if (error instanceof FlexHarnessStoreCommitUncertainError) {
6931
- stored.projectionSchemaVersion = 2;
8536
+ stored.projectionSchemaVersion = 3;
6932
8537
  stored.projectionReconciliationRequired = true;
6933
8538
  stored.projectionRevision = snapshot.revision;
6934
8539
  state.lifecycle = 'fenced';
@@ -6945,7 +8550,7 @@ export class FlexHarness<TScope = unknown> {
6945
8550
  reconciliationError = loadError;
6946
8551
  }
6947
8552
  if (current && JSON.stringify(current) === JSON.stringify(snapshot)) {
6948
- stored.projectionSchemaVersion = 2;
8553
+ stored.projectionSchemaVersion = 3;
6949
8554
  delete stored.projectionBaseline;
6950
8555
  stored.projectionReconciliationRequired = false;
6951
8556
  stored.projectionRevision = snapshot.revision;
@@ -6958,7 +8563,7 @@ export class FlexHarness<TScope = unknown> {
6958
8563
  restore();
6959
8564
  throw error;
6960
8565
  }
6961
- stored.projectionSchemaVersion = 2;
8566
+ stored.projectionSchemaVersion = 3;
6962
8567
  stored.projectionReconciliationRequired = true;
6963
8568
  stored.projectionRevision = snapshot.revision;
6964
8569
  state.lifecycle = 'fenced';
@@ -7070,7 +8675,7 @@ export class FlexHarness<TScope = unknown> {
7070
8675
  revision: number,
7071
8676
  ): IFlexProjectionSnapshotCurrent {
7072
8677
  return {
7073
- schemaVersion: 2,
8678
+ schemaVersion: 3,
7074
8679
  revision,
7075
8680
  messages: cloneSerializable(stored.messages),
7076
8681
  stagedTerminals: cloneSerializable(stored.stagedTerminals),
@@ -7084,6 +8689,51 @@ export class FlexHarness<TScope = unknown> {
7084
8689
  };
7085
8690
  }
7086
8691
 
8692
+ private upgradeProjectionSnapshot(
8693
+ projection: TFlexProjectionSnapshot,
8694
+ ): IFlexProjectionSnapshotCurrent {
8695
+ if (projection.schemaVersion === 3) return cloneSerializable(projection);
8696
+ if (projection.schemaVersion === 1) {
8697
+ return {
8698
+ schemaVersion: 3,
8699
+ revision: projection.revision,
8700
+ messages: cloneSerializable(projection.messages),
8701
+ stagedTerminals: cloneSerializable(projection.stagedTerminals),
8702
+ reversionSegments: [],
8703
+ revertCursor: 0,
8704
+ excludedRunIds: [],
8705
+ pendingReversionReleases: [],
8706
+ };
8707
+ }
8708
+ return {
8709
+ schemaVersion: 3,
8710
+ revision: projection.revision,
8711
+ messages: cloneSerializable(projection.messages),
8712
+ stagedTerminals: cloneSerializable(projection.stagedTerminals),
8713
+ reversionSegments: projection.reversionSegments.map((segment) => ({
8714
+ ...cloneSerializable(segment),
8715
+ protocolVersion: 1,
8716
+ provenance: segment.workspaceCaptured ? 'workspace' as const : 'transcript' as const,
8717
+ ...(segment.workspaceCaptured
8718
+ ? { disposition: segment.status === 'capturing' ? 'pending' as const : 'revertible' as const }
8719
+ : {}),
8720
+ })),
8721
+ revertCursor: projection.revertCursor,
8722
+ excludedRunIds: cloneSerializable(projection.excludedRunIds),
8723
+ ...(projection.pendingReversion === undefined
8724
+ ? {}
8725
+ : {
8726
+ pendingReversion: projection.pendingReversion.kind === 'capture'
8727
+ ? { ...cloneSerializable(projection.pendingReversion), protocolVersion: 1 }
8728
+ : cloneSerializable(projection.pendingReversion),
8729
+ }),
8730
+ pendingReversionReleases: projection.pendingReversionReleases.map((release) => ({
8731
+ ...cloneSerializable(release),
8732
+ protocolVersion: 1,
8733
+ })),
8734
+ };
8735
+ }
8736
+
7087
8737
  private async reconcileProjectionFromStore(stored: IStoredSessionState): Promise<void> {
7088
8738
  if (!stored.projectionReconciliationRequired) return;
7089
8739
  const projection = await this.stores.projections.load(
@@ -7093,35 +8743,42 @@ export class FlexHarness<TScope = unknown> {
7093
8743
  if (!projection) {
7094
8744
  throw new FlexHarnessValidationError('Uncertain projection persistence could not be reloaded.');
7095
8745
  }
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
- }
8746
+ const current = this.upgradeProjectionSnapshot(projection);
8747
+ stored.messages = current.messages;
8748
+ stored.stagedTerminals = current.stagedTerminals;
8749
+ stored.reversionSegments = current.reversionSegments;
8750
+ stored.revertCursor = current.revertCursor;
8751
+ stored.excludedRunIds = current.excludedRunIds;
8752
+ if (current.pendingReversion === undefined) delete stored.pendingReversion;
8753
+ else stored.pendingReversion = current.pendingReversion;
8754
+ stored.pendingReversionReleases = current.pendingReversionReleases;
8755
+ if (projection.schemaVersion === 3) delete stored.projectionBaseline;
8756
+ else stored.projectionBaseline = cloneSerializable(projection);
7114
8757
  stored.projectionSchemaVersion = projection.schemaVersion;
7115
8758
  stored.projectionRevision = projection.revision;
7116
8759
  stored.projectionReconciliationRequired = false;
7117
8760
  }
7118
8761
 
7119
- private async cleanupSessionDomains(storageKey: string, sessionId: string): Promise<void> {
8762
+ private async cleanupSessionDomains(
8763
+ storageKey: string,
8764
+ tombstone: IFlexSessionTombstone,
8765
+ ): Promise<void> {
8766
+ const sessionId = tombstone.sessionId;
8767
+ const projectManagementCleanup = async (): Promise<void> => {
8768
+ await this.projectManagementQueues.get(
8769
+ this.projectManagementKey(storageKey, sessionId),
8770
+ );
8771
+ await this.tombstoneProjectManagementSession(
8772
+ storageKey,
8773
+ tombstone,
8774
+ );
8775
+ };
7120
8776
  const results = await Promise.allSettled([
7121
8777
  this.stores.agentEvents.deleteSession(storageKey, sessionId),
7122
8778
  this.stores.jobs.deleteSession(storageKey, sessionId),
7123
8779
  this.stores.projections.deleteSession(storageKey, sessionId),
7124
8780
  this.stores.permissions.deleteSession(storageKey, sessionId),
8781
+ projectManagementCleanup(),
7125
8782
  ]);
7126
8783
  const errors = results
7127
8784
  .filter((result): result is PromiseRejectedResult => result.status === 'rejected')
@@ -7129,6 +8786,66 @@ export class FlexHarness<TScope = unknown> {
7129
8786
  if (errors.length > 0) throw combineErrors(errors);
7130
8787
  }
7131
8788
 
8789
+ private async tombstoneProjectManagementSession(
8790
+ storageKey: string,
8791
+ sessionTombstone: IFlexSessionTombstone,
8792
+ ): Promise<void> {
8793
+ const store = this.stores.projectManagement;
8794
+ const sessionId = sessionTombstone.sessionId;
8795
+ const generation = requireSessionGeneration(sessionTombstone);
8796
+ let lastConflict: FlexHarnessStoreConflictError | undefined;
8797
+ for (let attempt = 0; attempt <= maxProjectManagementTombstoneConflicts; attempt++) {
8798
+ const current = await store.load(storageKey, sessionId);
8799
+ if (current !== undefined) {
8800
+ assertFlexProjectManagementRecord(current);
8801
+ if (current.sessionGenerationId === generation.sessionGenerationId) {
8802
+ if (current.sessionGenerationSequence !== generation.sessionGenerationSequence) {
8803
+ throw new FlexHarnessStoreConflictError(
8804
+ this.projectManagementKey(storageKey, sessionId),
8805
+ 0,
8806
+ current.revision,
8807
+ );
8808
+ }
8809
+ if ('deletedAt' in current) return;
8810
+ } else if (
8811
+ !('deletedAt' in current)
8812
+ || current.sessionGenerationSequence >= generation.sessionGenerationSequence
8813
+ ) {
8814
+ throw new FlexHarnessStoreConflictError(
8815
+ this.projectManagementKey(storageKey, sessionId),
8816
+ 0,
8817
+ current.revision,
8818
+ );
8819
+ }
8820
+ }
8821
+ if (attempt === maxProjectManagementTombstoneConflicts) break;
8822
+ const expectedRevision = current === undefined
8823
+ || current.sessionGenerationId !== generation.sessionGenerationId
8824
+ ? 0
8825
+ : current.revision;
8826
+ const tombstone: IFlexProjectManagementTombstone = {
8827
+ schemaVersion: 1,
8828
+ revision: expectedRevision + 1,
8829
+ ...generation,
8830
+ deletedAt: sessionTombstone.deletedAt,
8831
+ };
8832
+ try {
8833
+ await store.tombstoneSession(
8834
+ storageKey,
8835
+ sessionId,
8836
+ tombstone,
8837
+ expectedRevision,
8838
+ );
8839
+ } catch (error) {
8840
+ if (!(error instanceof FlexHarnessStoreConflictError)) throw error;
8841
+ lastConflict = error;
8842
+ }
8843
+ }
8844
+ throw lastConflict ?? new FlexHarnessValidationError(
8845
+ `Project management tombstone for session "${sessionId}" could not be durably confirmed.`,
8846
+ );
8847
+ }
8848
+
7132
8849
  private collectSessionSubtree(
7133
8850
  state: IStorageState,
7134
8851
  rootSessionId: string,
@@ -7324,7 +9041,7 @@ export class FlexHarness<TScope = unknown> {
7324
9041
  releaseContext.scope,
7325
9042
  retained?.stored,
7326
9043
  );
7327
- await this.cleanupSessionDomains(state.storageKey, sessionId);
9044
+ await this.cleanupSessionDomains(state.storageKey, tombstone);
7328
9045
  if (retained) retained.domainsCompleted = true;
7329
9046
  }
7330
9047
  }
@@ -7404,6 +9121,13 @@ export class FlexHarness<TScope = unknown> {
7404
9121
  await Promise.allSettled([...state.sessionInitializations.values()]);
7405
9122
  cleanupErrors.push(...await this.closeOrphanedResources(state.storageKey));
7406
9123
  await state.scopeQueue;
9124
+ await this.awaitProjectManagementOperations(
9125
+ state.storageKey,
9126
+ [
9127
+ ...state.sessions.keys(),
9128
+ ...state.retainedSessionCleanups.keys(),
9129
+ ],
9130
+ );
7407
9131
  const currentTombstoneRoot = state.tombstones.get(currentRun.sessionId)?.rootSessionId
7408
9132
  ?? currentRun.sessionId;
7409
9133
  const currentTombstoneCleanup = state.tombstoneCleanups.get(currentTombstoneRoot);
@@ -7807,12 +9531,7 @@ export class FlexHarness<TScope = unknown> {
7807
9531
  const scope = await this.scopeResolver.resolveScope(scopeId);
7808
9532
  this.assertOpen();
7809
9533
  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
- }
9534
+ this.assertNoAmbientSlashCommandTeardown({ storageKey: scope.storageKey });
7816
9535
  const errors: unknown[] = [];
7817
9536
  const listingSettlement = this.abortSlashCommandListings(
7818
9537
  scope.storageKey,
@@ -7895,8 +9614,11 @@ export class FlexHarness<TScope = unknown> {
7895
9614
  return;
7896
9615
  }
7897
9616
  if (state.lifecycle !== 'fenced') state.lifecycle = 'retiring';
7898
- const detachedCleanupAttempts = this.beginDetachedCleanupSettlement(state);
7899
9617
  const errors: unknown[] = [];
9618
+ const listingSettlement = this.abortSlashCommandListings(storageKey, reason);
9619
+ if (listingSettlement) await listingSettlement;
9620
+ await this.abortSlashCommandExecutions(storageKey, reason, errors);
9621
+ const detachedCleanupAttempts = this.beginDetachedCleanupSettlement(state);
7900
9622
  const contextFor = (sessionId: string) => invocation
7901
9623
  ? this.createCompactorContext(invocation.scopeId, invocation.scope, storageKey, sessionId)
7902
9624
  : undefined;
@@ -7939,6 +9661,13 @@ export class FlexHarness<TScope = unknown> {
7939
9661
  await Promise.allSettled([...state.sessionInitializations.values()]);
7940
9662
  errors.push(...await this.closeOrphanedResources(state.storageKey));
7941
9663
  await state.scopeQueue;
9664
+ await this.awaitProjectManagementOperations(
9665
+ state.storageKey,
9666
+ [
9667
+ ...state.sessions.keys(),
9668
+ ...state.retainedSessionCleanups.keys(),
9669
+ ],
9670
+ );
7942
9671
  await Promise.all([...state.sessions.values()].flatMap((stored) => [
7943
9672
  stored.projectionQueue,
7944
9673
  stored.permissionQueue,
@@ -8061,6 +9790,7 @@ export class FlexHarness<TScope = unknown> {
8061
9790
  if (errors.length > 0) throw combineErrors(errors);
8062
9791
  this.compactorInvocationContext.disable();
8063
9792
  this.slashCommandInvocationContext.disable();
9793
+ this.slashCommandActivityContext.disable();
8064
9794
  this.stateLoads.clear();
8065
9795
  this.scopeAdmissions.clear();
8066
9796
  this.scopeRetirements.clear();
@@ -8171,6 +9901,23 @@ export class FlexHarness<TScope = unknown> {
8171
9901
  }
8172
9902
  const state = await stateLoad;
8173
9903
  this.assertOpen();
9904
+ if (state.lifecycle === 'fenced') {
9905
+ const drain = this.storageDrains.get(scope.storageKey) ?? this.drainStorage(
9906
+ scope.storageKey,
9907
+ stateLoad,
9908
+ this.trustInternalError(new FlexHarnessAbortError('The session namespace was fenced.')),
9909
+ { scopeId, scope: scope.scope },
9910
+ );
9911
+ if (this.ambientSlashCommandOwner({ storageKey: scope.storageKey })) {
9912
+ throw this.createScopeRetirementError();
9913
+ }
9914
+ try {
9915
+ await drain;
9916
+ } catch {
9917
+ // Fenced cleanup ownership remains available to explicit retirement or disposal.
9918
+ }
9919
+ throw this.createScopeRetirementError();
9920
+ }
8174
9921
  if (
8175
9922
  admission.retiring
8176
9923
  || admission.generation !== generation
@@ -8205,6 +9952,29 @@ export class FlexHarness<TScope = unknown> {
8205
9952
  return this.trustInternalError(new FlexHarnessAbortError(scopeRetirementMessage));
8206
9953
  }
8207
9954
 
9955
+ private ambientSlashCommandOwner(
9956
+ criteria: { scopeId?: string; storageKey?: string; sessionId?: string } = {},
9957
+ ): ISlashCommandInvocationOwner | ISlashCommandActivityOwner | undefined {
9958
+ const matches = (owner: ISlashCommandInvocationOwner | ISlashCommandActivityOwner) => (
9959
+ (criteria.scopeId === undefined || owner.scopeId === criteria.scopeId)
9960
+ && (criteria.storageKey === undefined || owner.storageKey === criteria.storageKey)
9961
+ && (criteria.sessionId === undefined || owner.sessionId === criteria.sessionId)
9962
+ );
9963
+ const invocationOwner = this.slashCommandInvocationContext.getStore();
9964
+ if (invocationOwner && matches(invocationOwner)) return invocationOwner;
9965
+ const activityOwner = this.slashCommandActivityContext.getStore();
9966
+ return activityOwner && matches(activityOwner) ? activityOwner : undefined;
9967
+ }
9968
+
9969
+ private assertNoAmbientSlashCommandTeardown(
9970
+ criteria: { scopeId?: string; storageKey?: string; sessionId?: string } = {},
9971
+ ): void {
9972
+ const owner = this.ambientSlashCommandOwner(criteria);
9973
+ if (owner) {
9974
+ throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(owner.sessionId));
9975
+ }
9976
+ }
9977
+
8208
9978
  private assertStateAcceptingWork(state: IStorageState): void {
8209
9979
  if (state.lifecycle !== 'active' || this.storageDrains.has(state.storageKey)) {
8210
9980
  throw this.createScopeRetirementError();
@@ -8259,8 +10029,7 @@ export class FlexHarness<TScope = unknown> {
8259
10029
  }
8260
10030
 
8261
10031
  private visibleMessages(stored: IStoredSessionState): IFlexMessage[] {
8262
- const firstHiddenUnit = this.reversionUnit(stored, 'redo');
8263
- const firstHiddenSegment = firstHiddenUnit?.segments[0];
10032
+ const firstHiddenSegment = this.reversionGroups(stored)[stored.revertCursor]?.segments[0];
8264
10033
  if (!firstHiddenSegment) return stored.messages;
8265
10034
  const boundary = stored.messages.findIndex((message) => message.runId === firstHiddenSegment.runId);
8266
10035
  return boundary < 0 ? stored.messages : stored.messages.slice(0, boundary);