@modelprofile.com/flexharness 9.4.0 → 9.6.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.
@@ -4,6 +4,7 @@ import {
4
4
  FlexHarnessCallbackOverflowError,
5
5
  FlexHarnessClosedError,
6
6
  FlexHarnessExternalError,
7
+ FlexHarnessImportedSessionError,
7
8
  FlexHarnessNotFoundError,
8
9
  FlexHarnessPermissionRejectedError,
9
10
  FlexHarnessPermissionStateError,
@@ -25,6 +26,7 @@ import {
25
26
  FLEX_REVERSION_REFERENCE_MAX_BYTES,
26
27
  FLEX_REVERSION_WORKSPACE_ID_MAX_BYTES,
27
28
  FLEX_REVERSION_WORKSPACE_LABEL_MAX_BYTES,
29
+ FLEX_SESSION_IMPORT_MAX_DEPTH,
28
30
  } from './interfaces.js';
29
31
  import type {
30
32
  IFlexAgentContextCompactionOptions,
@@ -32,6 +34,7 @@ import type {
32
34
  IFlexAgentSessionPolicy,
33
35
  IFlexAffectedWorkspace,
34
36
  IFlexBackgroundExecution,
37
+ IFlexBeginSessionImportOptions,
35
38
  IFlexCallbackLimits,
36
39
  IFlexCompactSessionOptions,
37
40
  IFlexCreateProjectTaskInput,
@@ -45,6 +48,7 @@ import type {
45
48
  IFlexExecutionContextHandle,
46
49
  IFlexHarnessOptions,
47
50
  IFlexHarnessStores,
51
+ IFlexImportedRun,
48
52
  IFlexMessage,
49
53
  IFlexMessagePage,
50
54
  IFlexMessagePageOptions,
@@ -85,6 +89,10 @@ import type {
85
89
  IFlexSessionActivity,
86
90
  IFlexSessionGeneration,
87
91
  IFlexSessionGenerationCohortEntry,
92
+ IFlexSessionImportHandle,
93
+ IFlexSessionImportLimits,
94
+ IFlexSessionImportProgress,
95
+ IFlexSessionOrigin,
88
96
  IFlexSessionReversionGroup,
89
97
  IFlexSessionReversionInfo,
90
98
  IFlexSessionTombstone,
@@ -106,6 +114,7 @@ import type {
106
114
  TFlexAgentToolCallUpdateEvent,
107
115
  TFlexAgentToolCallFinishEvent,
108
116
  TFlexAgentToolSet,
117
+ TFlexBeginSessionImportResult,
109
118
  TFlexExternalErrorProjector,
110
119
  TFlexExternalErrorSource,
111
120
  TFlexHarnessEvent,
@@ -184,6 +193,14 @@ import {
184
193
  truncateUtf8,
185
194
  type IRunResultProjection,
186
195
  } from './utils.transfer.js';
196
+ import {
197
+ normalizeBeginSessionImportOptions,
198
+ normalizeImportedRunPage,
199
+ resolveSessionImportLimits,
200
+ sessionImportIdentity,
201
+ validateSessionImportHandle,
202
+ type INormalizedSessionImportOptions,
203
+ } from './utils.sessionimport.js';
187
204
  import { wrapToolSet } from './utils.tooloutput.js';
188
205
  import {
189
206
  validateCompactSessionOptions,
@@ -299,6 +316,25 @@ interface IOrphanedExecutionContextOwner {
299
316
  sessionId: string;
300
317
  }
301
318
 
319
+ /** A staged session import: its runs stay in memory until the import is committed. */
320
+ interface IStagedSessionImport {
321
+ importId: string;
322
+ scopeId: string;
323
+ state: IStorageState;
324
+ scope: unknown;
325
+ sessionId: string;
326
+ options: INormalizedSessionImportOptions;
327
+ identity: string;
328
+ runs: IFlexImportedRun[];
329
+ runIds: Set<string>;
330
+ messageIds: Set<string>;
331
+ messageCount: number;
332
+ bytes: number;
333
+ phase: 'staging' | 'committing';
334
+ /** Discards the import when it stays idle; cleared once the import commits or is released. */
335
+ idleTimer?: ReturnType<typeof setTimeout>;
336
+ }
337
+
302
338
  interface IStorageState {
303
339
  storageKey: string;
304
340
  compactorLifecycleController: AbortController;
@@ -310,6 +346,8 @@ interface IStorageState {
310
346
  sessionDeletions: Map<string, Promise<void>>;
311
347
  initializingSessions: Set<string>;
312
348
  sessionInitializations: Map<string, Promise<void>>;
349
+ /** Staged session imports by session id; their ids are also in `initializingSessions`. */
350
+ sessionImports: Map<string, IStagedSessionImport>;
313
351
  tombstones: Map<string, IFlexSessionTombstone>;
314
352
  tombstoneCleanups: Map<string, Promise<void>>;
315
353
  activeRuns: Map<string, IActiveRun>;
@@ -575,6 +613,7 @@ const resourceToolStemLength = 16;
575
613
  const repairCancellationMessage = 'The process stopped before this run completed.';
576
614
  const scopeRetirementMessage = 'The scope is being retired.';
577
615
  const slashCommandSessionUnavailableReason = 'Session must be idle with no queued prompts.';
616
+ const importedSessionUnavailableReason = 'Session is an imported conversation and is read-only.';
578
617
  const externalErrorFallback: IFlexErrorInfo = Object.freeze({
579
618
  name: 'FlexHarnessExternalError',
580
619
  message: 'The model operation failed.',
@@ -714,6 +753,7 @@ export class FlexHarness<TScope = unknown> {
714
753
  private readonly callbackLimits: Required<IFlexCallbackLimits>;
715
754
  private readonly promptQueueLimits: Required<IFlexPromptQueueLimits>;
716
755
  private readonly reversionLimits: Required<IFlexReversionLimits>;
756
+ private readonly sessionImportLimits: Required<IFlexSessionImportLimits>;
717
757
  private readonly reversionMaintenanceTimeoutMs: number;
718
758
  private readonly turnReversionProvider: IFlexHarnessOptions<TScope>['turnReversionProvider'];
719
759
  private readonly reversionPolicy: TFlexReversionPolicy;
@@ -743,6 +783,7 @@ export class FlexHarness<TScope = unknown> {
743
783
  private readonly projectManagementQueues = new Map<string, Promise<void>>();
744
784
  private readonly activeSlashCommandExecutions = new Map<string, IActiveSlashCommandExecution>();
745
785
  private readonly activeSlashCommandListings = new Set<IActiveSlashCommandListing>();
786
+ private readonly stagedSessionImports = new Map<string, IStagedSessionImport>();
746
787
  private readonly compactorInvocationContext = new plugins.AsyncLocalStorage<
747
788
  ICompactorInvocation<TScope>
748
789
  >();
@@ -779,6 +820,7 @@ export class FlexHarness<TScope = unknown> {
779
820
  this.callbackLimits = resolveCallbackLimits(options.callbackLimits);
780
821
  this.promptQueueLimits = resolvePromptQueueLimits(options.promptQueueLimits);
781
822
  this.reversionLimits = resolveReversionLimits(options.reversionLimits);
823
+ this.sessionImportLimits = resolveSessionImportLimits(options.sessionImportLimits);
782
824
  this.reversionMaintenanceTimeoutMs = resolveReversionMaintenanceTimeoutMs(options);
783
825
  this.turnReversionProvider = options.turnReversionProvider;
784
826
  this.reversionPolicy = options.reversionPolicy ?? 'transcript-optional';
@@ -996,6 +1038,272 @@ export class FlexHarness<TScope = unknown> {
996
1038
  return result;
997
1039
  }
998
1040
 
1041
+ /**
1042
+ * Starts a staged import of a finished conversation into a new read-only session. The session
1043
+ * stays hidden until `commitSessionImport()`. A committed session with the same source, source
1044
+ * id and part is reported instead of being imported twice.
1045
+ */
1046
+ public async beginSessionImport(
1047
+ scopeId: string,
1048
+ options: IFlexBeginSessionImportOptions,
1049
+ ): Promise<TFlexBeginSessionImportResult> {
1050
+ this.assertOpen();
1051
+ const normalized = normalizeBeginSessionImportOptions(options);
1052
+ const { scope, state } = await this.resolveState(scopeId);
1053
+ await state.scopeQueue;
1054
+ this.assertOpen();
1055
+ this.assertStateAcceptingWork(state);
1056
+ const { sessionId } = normalized;
1057
+ const identity = sessionImportIdentity(normalized.origin);
1058
+ for (const staged of state.sessionImports.values()) {
1059
+ if (staged.sessionId === sessionId || staged.identity === identity) {
1060
+ throw new FlexHarnessSessionBusyError(staged.sessionId, 'is already being imported');
1061
+ }
1062
+ }
1063
+ for (const stored of state.sessions.values()) {
1064
+ const origin = stored.session.origin;
1065
+ if (origin === undefined || sessionImportIdentity(origin) !== identity) continue;
1066
+ const existing: TFlexBeginSessionImportResult = {
1067
+ status: origin.digest === normalized.origin.digest ? 'already_imported' : 'digest_mismatch',
1068
+ session: stored.session,
1069
+ };
1070
+ return publicSnapshot(existing);
1071
+ }
1072
+ if (
1073
+ state.sessions.has(sessionId)
1074
+ || state.tombstones.has(sessionId)
1075
+ || state.initializingSessions.has(sessionId)
1076
+ ) {
1077
+ throw new FlexHarnessValidationError(`Session "${sessionId}" already exists or is being deleted.`);
1078
+ }
1079
+ if (normalized.parent) this.requireImportParent(state, sessionId, normalized.parent);
1080
+ if (this.stagedSessionImports.size >= this.sessionImportLimits.maxOpenImports) {
1081
+ throw new FlexHarnessQueueFullError('FlexHarness has reached its open session import limit.');
1082
+ }
1083
+ const staged: IStagedSessionImport = {
1084
+ importId: `import_${plugins.crypto.randomUUID()}`,
1085
+ scopeId,
1086
+ state,
1087
+ scope: scope.scope,
1088
+ sessionId,
1089
+ options: normalized,
1090
+ identity,
1091
+ runs: [],
1092
+ runIds: new Set(),
1093
+ messageIds: new Set(),
1094
+ messageCount: 0,
1095
+ bytes: 0,
1096
+ phase: 'staging',
1097
+ };
1098
+ this.stagedSessionImports.set(staged.importId, staged);
1099
+ this.armStagedImportIdleTimer(staged);
1100
+ state.sessionImports.set(sessionId, staged);
1101
+ state.initializingSessions.add(sessionId);
1102
+ const started: TFlexBeginSessionImportResult = {
1103
+ status: 'started',
1104
+ handle: { importId: staged.importId, scopeId, sessionId },
1105
+ };
1106
+ return publicSnapshot(started);
1107
+ }
1108
+
1109
+ /** Stages one page of runs, in order, for a started session import. A rejected page stages nothing. */
1110
+ public async appendImportedRuns(
1111
+ handle: IFlexSessionImportHandle,
1112
+ runs: readonly IFlexImportedRun[],
1113
+ ): Promise<IFlexSessionImportProgress> {
1114
+ const staged = this.requireStagedSessionImport(handle);
1115
+ this.assertOpen();
1116
+ this.assertStateAcceptingWork(staged.state);
1117
+ const page = normalizeImportedRunPage(runs, staged.sessionId, this.sessionImportLimits.maxPageBytes);
1118
+ const runIds = new Set<string>();
1119
+ const messageIds = new Set<string>();
1120
+ for (const run of page.runs) {
1121
+ if (staged.runIds.has(run.user.runId) || runIds.has(run.user.runId)) {
1122
+ throw new FlexHarnessValidationError(`Imported run "${run.user.runId}" is not unique.`);
1123
+ }
1124
+ runIds.add(run.user.runId);
1125
+ for (const message of [run.user, run.assistant]) {
1126
+ if (staged.messageIds.has(message.messageId) || messageIds.has(message.messageId)) {
1127
+ throw new FlexHarnessValidationError(`Imported message "${message.messageId}" is not unique.`);
1128
+ }
1129
+ messageIds.add(message.messageId);
1130
+ }
1131
+ }
1132
+ const messageCount = staged.messageCount + page.runs.length * 2;
1133
+ if (messageCount > this.sessionImportLimits.maxMessagesPerSession) {
1134
+ throw new FlexHarnessValidationError(
1135
+ `An imported session holds at most ${this.sessionImportLimits.maxMessagesPerSession} messages.`,
1136
+ );
1137
+ }
1138
+ const bytes = staged.bytes + page.bytes;
1139
+ if (bytes > this.sessionImportLimits.maxSessionBytes) {
1140
+ throw new FlexHarnessValidationError(
1141
+ `An imported session holds at most ${this.sessionImportLimits.maxSessionBytes} message bytes.`,
1142
+ );
1143
+ }
1144
+ staged.runs.push(...page.runs);
1145
+ for (const runId of runIds) staged.runIds.add(runId);
1146
+ for (const messageId of messageIds) staged.messageIds.add(messageId);
1147
+ staged.messageCount = messageCount;
1148
+ staged.bytes = bytes;
1149
+ this.armStagedImportIdleTimer(staged);
1150
+ return publicSnapshot({ runs: staged.runs.length, messages: messageCount, bytes });
1151
+ }
1152
+
1153
+ /**
1154
+ * Persists the staged runs and publishes the session: archived, read-only and marked with its
1155
+ * `origin`. A failed commit removes everything it persisted before it rejects.
1156
+ */
1157
+ public async commitSessionImport(handle: IFlexSessionImportHandle): Promise<IFlexSession> {
1158
+ const staged = this.requireStagedSessionImport(handle);
1159
+ this.assertOpen();
1160
+ const { state, scopeId, sessionId, options } = staged;
1161
+ this.assertStateAcceptingWork(state);
1162
+ if (staged.runs.length === 0) {
1163
+ throw new FlexHarnessValidationError('A session import needs at least one run before it is committed.');
1164
+ }
1165
+ staged.phase = 'committing';
1166
+ this.clearStagedImportIdleTimer(staged);
1167
+ const scope = staged.scope as TScope;
1168
+ let resolveInitialization!: () => void;
1169
+ const initialization = new Promise<void>((resolve) => {
1170
+ resolveInitialization = resolve;
1171
+ });
1172
+ let initializationCompleted = false;
1173
+ const completeInitialization = () => {
1174
+ if (initializationCompleted) return;
1175
+ initializationCompleted = true;
1176
+ if (state.sessionInitializations.get(sessionId) === initialization) {
1177
+ state.sessionInitializations.delete(sessionId);
1178
+ }
1179
+ resolveInitialization();
1180
+ };
1181
+ state.sessionInitializations.set(sessionId, initialization);
1182
+ const importedAt = new Date().toISOString();
1183
+ const origin: IFlexSessionOrigin = {
1184
+ kind: 'import',
1185
+ ...cloneSerializable(options.origin),
1186
+ importedAt,
1187
+ };
1188
+ let metadata!: IFlexSession;
1189
+ let reserved = false;
1190
+ let loaded: IStoredSessionState | undefined;
1191
+ try {
1192
+ // A tombstone reserves the id while the projection is written, so a crash before the session
1193
+ // is published leaves only data that the next load or deletion cleans up.
1194
+ await this.mutateScope(state, () => {
1195
+ if (state.sessions.has(sessionId) || state.tombstones.has(sessionId)) {
1196
+ throw this.trustInternalError(new FlexHarnessValidationError(
1197
+ `Session "${sessionId}" already exists or is being deleted.`,
1198
+ ));
1199
+ }
1200
+ const parent = options.parent
1201
+ ? this.requireImportParent(state, sessionId, options.parent)
1202
+ : undefined;
1203
+ metadata = {
1204
+ scopeId,
1205
+ sessionId,
1206
+ sessionGenerationId: createSessionGenerationId(),
1207
+ sessionGenerationSequence: state.revision + 1,
1208
+ ...(options.title === undefined ? {} : { title: options.title }),
1209
+ createdAt: options.createdAt,
1210
+ updatedAt: options.updatedAt,
1211
+ archivedAt: importedAt,
1212
+ status: 'idle',
1213
+ activity: { status: 'idle' },
1214
+ ...(options.parent === undefined
1215
+ ? {}
1216
+ : {
1217
+ parentSessionId: options.parent.sessionId,
1218
+ parentRunId: options.parent.runId,
1219
+ parentToolCallId: options.parent.toolCallId,
1220
+ agent: options.parent.agent,
1221
+ }),
1222
+ depth: parent === undefined ? 0 : (parent.session.depth ?? 0) + 1,
1223
+ origin,
1224
+ };
1225
+ state.tombstones.set(sessionId, {
1226
+ sessionId,
1227
+ ...requireSessionGeneration(metadata),
1228
+ deletedAt: importedAt,
1229
+ rootSessionId: sessionId,
1230
+ depth: 0,
1231
+ imported: true,
1232
+ });
1233
+ });
1234
+ reserved = true;
1235
+ await this.stores.projections.save(state.storageKey, sessionId, {
1236
+ schemaVersion: 3,
1237
+ revision: 1,
1238
+ messages: staged.runs.flatMap((run) => [run.user, run.assistant]),
1239
+ stagedTerminals: [],
1240
+ reversionSegments: [],
1241
+ revertCursor: 0,
1242
+ excludedRunIds: [],
1243
+ pendingReversionReleases: [],
1244
+ }, 0);
1245
+ const runtime = await this.loadSessionRuntime(state, metadata, scopeId, scope);
1246
+ loaded = runtime;
1247
+ await this.mutateScope(state, () => {
1248
+ if (
1249
+ state.sessions.has(sessionId)
1250
+ || state.tombstones.get(sessionId)?.sessionGenerationId !== metadata.sessionGenerationId
1251
+ ) {
1252
+ throw this.trustInternalError(new FlexHarnessAbortError(
1253
+ 'The session import lost its reservation before it was committed.',
1254
+ ));
1255
+ }
1256
+ if (options.parent) this.requireImportParent(state, sessionId, options.parent);
1257
+ state.tombstones.delete(sessionId);
1258
+ state.sessions.set(sessionId, runtime);
1259
+ });
1260
+ } catch (error) {
1261
+ const projected = this.projectOperationError(error, 'persistence', scopeId, sessionId, 'session-import');
1262
+ const errors: unknown[] = [projected];
1263
+ if (loaded && state.sessions.get(sessionId) !== loaded) {
1264
+ try {
1265
+ await this.closeStoredSession(loaded);
1266
+ } catch (closeError) {
1267
+ this.orphanedStoredSessions.add(loaded);
1268
+ errors.push(closeError);
1269
+ }
1270
+ }
1271
+ // A fenced namespace cannot tell whether its last save was persisted; its next load resolves
1272
+ // the reservation instead: a surviving tombstone is cleaned up, a published session is kept.
1273
+ if (reserved && state.tombstones.has(sessionId) && state.lifecycle !== 'fenced') {
1274
+ completeInitialization();
1275
+ try {
1276
+ await this.finishTombstoneCleanup(state, sessionId, scopeId, { scopeId, scope });
1277
+ } catch (cleanupError) {
1278
+ errors.push(this.projectOperationError(
1279
+ cleanupError,
1280
+ 'persistence',
1281
+ scopeId,
1282
+ sessionId,
1283
+ 'session-import-cleanup',
1284
+ ));
1285
+ }
1286
+ }
1287
+ throw combineErrors(errors);
1288
+ } finally {
1289
+ this.releaseStagedSessionImport(staged);
1290
+ completeInitialization();
1291
+ }
1292
+ const result = publicSnapshot(metadata);
1293
+ this.emitEvent(scopeId, metadata, { type: 'session.created', session: result });
1294
+ return result;
1295
+ }
1296
+
1297
+ /** Discards a staged import. Nothing of it was persisted; an unknown or finished handle is ignored. */
1298
+ public async abortSessionImport(handle: IFlexSessionImportHandle): Promise<void> {
1299
+ const staged = this.findStagedSessionImport(handle);
1300
+ if (!staged) return;
1301
+ if (staged.phase === 'committing') {
1302
+ throw new FlexHarnessSessionBusyError(staged.sessionId, 'is being committed');
1303
+ }
1304
+ this.releaseStagedSessionImport(staged);
1305
+ }
1306
+
999
1307
  public async getSession(scopeId: string, sessionId: string): Promise<IFlexSession> {
1000
1308
  const { state } = await this.resolveState(scopeId);
1001
1309
  await state.scopeQueue;
@@ -1068,7 +1376,7 @@ export class FlexHarness<TScope = unknown> {
1068
1376
  scopeId: string,
1069
1377
  sessionId: string,
1070
1378
  ): Promise<IFlexProjectStateResult> {
1071
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1379
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'read');
1072
1380
  const snapshot = await this.readProjectManagementState(state, stored);
1073
1381
  return this.projectStateResult(snapshot);
1074
1382
  }
@@ -1077,7 +1385,7 @@ export class FlexHarness<TScope = unknown> {
1077
1385
  scopeId: string,
1078
1386
  sessionId: string,
1079
1387
  ): Promise<IFlexProjectTasksResult> {
1080
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1388
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'read');
1081
1389
  const snapshot = await this.readProjectManagementState(state, stored);
1082
1390
  return this.projectTasksResult(snapshot, snapshot.tasks);
1083
1391
  }
@@ -1088,7 +1396,7 @@ export class FlexHarness<TScope = unknown> {
1088
1396
  input: IFlexCreateProjectTaskInput,
1089
1397
  ): Promise<IFlexProjectTaskResult> {
1090
1398
  validateCreateProjectTaskInput(input);
1091
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1399
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'write');
1092
1400
  return this.createProjectTaskInState(
1093
1401
  state,
1094
1402
  stored,
@@ -1103,7 +1411,7 @@ export class FlexHarness<TScope = unknown> {
1103
1411
  input: IFlexUpdateProjectTaskInput,
1104
1412
  ): Promise<IFlexProjectTaskResult> {
1105
1413
  validateUpdateProjectTaskInput(input);
1106
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1414
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'write');
1107
1415
  return this.updateProjectTaskInState(
1108
1416
  state,
1109
1417
  stored,
@@ -1118,7 +1426,7 @@ export class FlexHarness<TScope = unknown> {
1118
1426
  taskId: string,
1119
1427
  ): Promise<IFlexProjectTaskResult> {
1120
1428
  validateProjectTaskId(taskId);
1121
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1429
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'write');
1122
1430
  return this.deleteProjectTaskInState(
1123
1431
  state,
1124
1432
  stored,
@@ -1131,7 +1439,7 @@ export class FlexHarness<TScope = unknown> {
1131
1439
  scopeId: string,
1132
1440
  sessionId: string,
1133
1441
  ): Promise<IFlexProjectTasksResult> {
1134
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1442
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'write');
1135
1443
  return this.clearProjectTasksInState(
1136
1444
  state,
1137
1445
  stored,
@@ -1143,7 +1451,7 @@ export class FlexHarness<TScope = unknown> {
1143
1451
  scopeId: string,
1144
1452
  sessionId: string,
1145
1453
  ): Promise<IFlexProjectGoalResult> {
1146
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1454
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'read');
1147
1455
  const snapshot = await this.readProjectManagementState(state, stored);
1148
1456
  return this.projectGoalResult(snapshot);
1149
1457
  }
@@ -1159,7 +1467,7 @@ export class FlexHarness<TScope = unknown> {
1159
1467
  FLEX_PROJECT_MANAGEMENT_LIMITS.maxGoalBytes,
1160
1468
  true,
1161
1469
  );
1162
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1470
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'write');
1163
1471
  return this.setProjectGoalInState(
1164
1472
  state,
1165
1473
  stored,
@@ -1172,7 +1480,7 @@ export class FlexHarness<TScope = unknown> {
1172
1480
  scopeId: string,
1173
1481
  sessionId: string,
1174
1482
  ): Promise<IFlexProjectGoalResult> {
1175
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1483
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'write');
1176
1484
  return this.clearProjectGoalInState(
1177
1485
  state,
1178
1486
  stored,
@@ -1184,7 +1492,7 @@ export class FlexHarness<TScope = unknown> {
1184
1492
  scopeId: string,
1185
1493
  sessionId: string,
1186
1494
  ): Promise<IFlexProjectScratchpadResult> {
1187
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1495
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'read');
1188
1496
  const snapshot = await this.readProjectManagementState(state, stored);
1189
1497
  return this.projectScratchpadResult(snapshot);
1190
1498
  }
@@ -1199,7 +1507,7 @@ export class FlexHarness<TScope = unknown> {
1199
1507
  'project scratchpad',
1200
1508
  FLEX_PROJECT_MANAGEMENT_LIMITS.maxScratchpadBytes,
1201
1509
  );
1202
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1510
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'write');
1203
1511
  return this.setProjectScratchpadInState(
1204
1512
  state,
1205
1513
  stored,
@@ -1218,7 +1526,7 @@ export class FlexHarness<TScope = unknown> {
1218
1526
  'project scratchpad append',
1219
1527
  FLEX_PROJECT_MANAGEMENT_LIMITS.maxScratchpadBytes,
1220
1528
  );
1221
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1529
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'write');
1222
1530
  return this.appendProjectScratchpadInState(
1223
1531
  state,
1224
1532
  stored,
@@ -1231,7 +1539,7 @@ export class FlexHarness<TScope = unknown> {
1231
1539
  scopeId: string,
1232
1540
  sessionId: string,
1233
1541
  ): Promise<IFlexProjectScratchpadResult> {
1234
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1542
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'write');
1235
1543
  return this.clearProjectScratchpadInState(
1236
1544
  state,
1237
1545
  stored,
@@ -1295,6 +1603,7 @@ export class FlexHarness<TScope = unknown> {
1295
1603
  if (!tombstone || !matchesSessionGeneration(tombstone, validated.root)) {
1296
1604
  return { matched: false };
1297
1605
  }
1606
+ this.assertNoCommittingSessionImport(state, validated.root.sessionId);
1298
1607
  const rootSessionId = tombstone.rootSessionId ?? tombstone.sessionId;
1299
1608
  const descendantRoots = this.descendantTombstoneRoots(state, rootSessionId);
1300
1609
  this.assertAuthorizedSessionGenerationCohort(
@@ -1323,6 +1632,7 @@ export class FlexHarness<TScope = unknown> {
1323
1632
  this.assertNoAmbientSlashCommandTeardown({ storageKey: state.storageKey });
1324
1633
  validateIdentifier(sessionId, 'sessionId');
1325
1634
  await state.scopeQueue;
1635
+ this.assertNoCommittingSessionImport(state, sessionId);
1326
1636
  return this.startSessionDeletion(state, scopeId, scope.scope, sessionId);
1327
1637
  }
1328
1638
 
@@ -1429,6 +1739,7 @@ export class FlexHarness<TScope = unknown> {
1429
1739
  ? {}
1430
1740
  : { parentSessionId: entry.session.parentSessionId }),
1431
1741
  ...(subagent === undefined ? {} : { subagent: cloneSerializable(subagent) }),
1742
+ ...(entry.session.origin === undefined ? {} : { imported: true as const }),
1432
1743
  });
1433
1744
  }
1434
1745
  return {
@@ -1597,7 +1908,12 @@ export class FlexHarness<TScope = unknown> {
1597
1908
  const nextCursor = index > 0
1598
1909
  ? this.createMessageCursor(namespace, sessionId, candidate.messageId)
1599
1910
  : undefined;
1600
- if (jsonBytes({ messages: candidateMessages, ...(nextCursor ? { nextCursor } : {}) }) > maxTransferPageBytes) break;
1911
+ const candidatePage: IFlexMessagePage = {
1912
+ messages: candidateMessages,
1913
+ startIndex: index,
1914
+ ...(nextCursor ? { nextCursor } : {}),
1915
+ };
1916
+ if (jsonBytes(candidatePage) > maxTransferPageBytes) break;
1601
1917
  start = index;
1602
1918
  messages = candidateMessages;
1603
1919
  }
@@ -1607,7 +1923,7 @@ export class FlexHarness<TScope = unknown> {
1607
1923
  const nextCursor = start > 0
1608
1924
  ? this.createMessageCursor(namespace, sessionId, messages[0].messageId)
1609
1925
  : undefined;
1610
- return publicSnapshot({ messages, ...(nextCursor ? { nextCursor } : {}) });
1926
+ return publicSnapshot({ messages, startIndex: start, ...(nextCursor ? { nextCursor } : {}) });
1611
1927
  }
1612
1928
 
1613
1929
  public async getMessage(
@@ -1644,12 +1960,18 @@ export class FlexHarness<TScope = unknown> {
1644
1960
  signal,
1645
1961
  );
1646
1962
  signal.throwIfAborted();
1647
- return this.createSlashCommandDescriptors(
1963
+ const descriptors = this.createSlashCommandDescriptors(
1648
1964
  state,
1649
1965
  stored,
1650
1966
  sessionAvailable,
1651
1967
  reversionIdleReason,
1652
1968
  );
1969
+ if (stored.session.origin === undefined) return descriptors;
1970
+ return publicSnapshot(descriptors.map((descriptor): IFlexSlashCommandDescriptor => ({
1971
+ ...descriptor,
1972
+ available: false,
1973
+ unavailableReason: importedSessionUnavailableReason,
1974
+ })));
1653
1975
  });
1654
1976
  }
1655
1977
 
@@ -1788,6 +2110,7 @@ export class FlexHarness<TScope = unknown> {
1788
2110
  this.assertOpen();
1789
2111
  this.assertStateAcceptingWork(state);
1790
2112
  const stored = this.requireSession(state, sessionId);
2113
+ this.assertSessionWritable(stored);
1791
2114
  const sessionKey = this.slashCommandSessionKey(state.storageKey, sessionId);
1792
2115
  const invocationOwner = this.slashCommandInvocationContext.getStore();
1793
2116
  if (invocationOwner?.sessionKey === sessionKey) {
@@ -2383,6 +2706,7 @@ export class FlexHarness<TScope = unknown> {
2383
2706
  }
2384
2707
  const { scope, state } = await this.resolveState(scopeId);
2385
2708
  const stored = this.requireSession(state, sessionId);
2709
+ this.assertSessionWritable(stored);
2386
2710
  this.assertStateAcceptingWork(state);
2387
2711
  let canonical: plugins.TAgentToolExecutionReconciliationOptions;
2388
2712
  if (reconciliation.resolution === 'executed') {
@@ -2422,6 +2746,7 @@ export class FlexHarness<TScope = unknown> {
2422
2746
  this.validateRuntimeEvent(event);
2423
2747
  const { scope, state } = await this.resolveState(scopeId);
2424
2748
  const stored = this.requireSession(state, sessionId);
2749
+ this.assertSessionWritable(stored);
2425
2750
  this.assertStateAcceptingWork(state);
2426
2751
  try {
2427
2752
  await this.withCompactorContext(
@@ -2441,6 +2766,7 @@ export class FlexHarness<TScope = unknown> {
2441
2766
  const { modelHint } = validateCompactSessionOptions(options);
2442
2767
  const { scope, state } = await this.resolveState(scopeId);
2443
2768
  const stored = this.requireSession(state, sessionId);
2769
+ this.assertSessionWritable(stored);
2444
2770
  const invocationOwner = this.slashCommandInvocationContext.getStore();
2445
2771
  if (invocationOwner?.sessionKey === this.slashCommandSessionKey(state.storageKey, sessionId)) {
2446
2772
  throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(sessionId));
@@ -2688,6 +3014,7 @@ export class FlexHarness<TScope = unknown> {
2688
3014
  signal?: AbortSignal,
2689
3015
  ): Promise<string> {
2690
3016
  const stored = this.requireSession(state, sessionId);
3017
+ this.assertSessionWritable(stored);
2691
3018
  this.assertStateAcceptingWork(state);
2692
3019
  const invocationOwner = this.slashCommandInvocationContext.getStore();
2693
3020
  if (invocationOwner?.sessionKey === this.slashCommandSessionKey(state.storageKey, sessionId)) {
@@ -3040,6 +3367,7 @@ export class FlexHarness<TScope = unknown> {
3040
3367
  }
3041
3368
  const { scope, state } = await this.resolveState(scopeId);
3042
3369
  const stored = this.requireSession(state, sessionId);
3370
+ this.assertSessionWritable(stored);
3043
3371
  this.assertStateAcceptingWork(state);
3044
3372
  this.requireSessionAvailableForSlashCommand(state, stored, 'archive');
3045
3373
  const compaction = compactionEventId === undefined
@@ -3239,6 +3567,7 @@ export class FlexHarness<TScope = unknown> {
3239
3567
  }
3240
3568
  this.assertStateAcceptingWork(state);
3241
3569
  const stored = this.requireSession(state, sessionId);
3570
+ this.assertSessionWritable(stored);
3242
3571
  const sessionKey = this.slashCommandSessionKey(state.storageKey, sessionId);
3243
3572
  const invocationOwner = this.slashCommandInvocationContext.getStore();
3244
3573
  if (invocationOwner?.sessionKey === sessionKey) {
@@ -4209,13 +4538,16 @@ export class FlexHarness<TScope = unknown> {
4209
4538
  private async resolveProjectManagementSession(
4210
4539
  scopeId: string,
4211
4540
  sessionId: string,
4541
+ access: 'read' | 'write',
4212
4542
  ): Promise<{ state: IStorageState; stored: IStoredSessionState }> {
4213
4543
  this.requireProjectManagementTools();
4214
4544
  const { state } = await this.resolveState(scopeId);
4215
4545
  await state.scopeQueue;
4216
4546
  this.assertOpen();
4217
4547
  this.assertStateAcceptingWork(state);
4218
- return { state, stored: this.requireSession(state, sessionId) };
4548
+ const stored = this.requireSession(state, sessionId);
4549
+ if (access === 'write') this.assertSessionWritable(stored);
4550
+ return { state, stored };
4219
4551
  }
4220
4552
 
4221
4553
  private assertProjectManagementSession(
@@ -7984,6 +8316,7 @@ export class FlexHarness<TScope = unknown> {
7984
8316
  sessionDeletions: new Map(),
7985
8317
  initializingSessions: new Set(),
7986
8318
  sessionInitializations: new Map(),
8319
+ sessionImports: new Map(),
7987
8320
  tombstones: new Map(snapshot.tombstones.map((entry) => [entry.sessionId, entry])),
7988
8321
  tombstoneCleanups: new Map(),
7989
8322
  activeRuns: new Map(),
@@ -8397,6 +8730,10 @@ export class FlexHarness<TScope = unknown> {
8397
8730
  scopeId?: string,
8398
8731
  scope?: TScope,
8399
8732
  ): Promise<boolean> {
8733
+ if (stored.session.origin !== undefined) {
8734
+ this.assertImportedSessionState(stored);
8735
+ return false;
8736
+ }
8400
8737
  const beforeProjection = JSON.stringify({
8401
8738
  messages: stored.messages,
8402
8739
  stagedTerminals: stored.stagedTerminals,
@@ -9054,7 +9391,8 @@ export class FlexHarness<TScope = unknown> {
9054
9391
  this.stores.jobs.deleteSession(storageKey, sessionId),
9055
9392
  this.stores.projections.deleteSession(storageKey, sessionId),
9056
9393
  this.stores.permissions.deleteSession(storageKey, sessionId),
9057
- projectManagementCleanup(),
9394
+ // An imported session refuses every project-management write, so it has no state to fence.
9395
+ ...(tombstone.imported ? [] : [projectManagementCleanup()]),
9058
9396
  ]);
9059
9397
  const errors = results
9060
9398
  .filter((result): result is PromiseRejectedResult => result.status === 'rejected')
@@ -9916,6 +10254,9 @@ export class FlexHarness<TScope = unknown> {
9916
10254
  return;
9917
10255
  }
9918
10256
  if (state.lifecycle !== 'fenced') state.lifecycle = 'retiring';
10257
+ for (const staged of [...state.sessionImports.values()]) {
10258
+ if (staged.phase === 'staging') this.releaseStagedSessionImport(staged);
10259
+ }
9919
10260
  const errors: unknown[] = [];
9920
10261
  const listingSettlement = this.abortSlashCommandListings(storageKey, reason);
9921
10262
  if (listingSettlement) await listingSettlement;
@@ -10048,6 +10389,7 @@ export class FlexHarness<TScope = unknown> {
10048
10389
  state.pendingPermissions.clear();
10049
10390
  state.initializingSessions.clear();
10050
10391
  state.sessionInitializations.clear();
10392
+ state.sessionImports.clear();
10051
10393
  }
10052
10394
 
10053
10395
  private async disposeInternal(): Promise<void> {
@@ -10388,6 +10730,138 @@ export class FlexHarness<TScope = unknown> {
10388
10730
  return stored;
10389
10731
  }
10390
10732
 
10733
+ /** Refuses an operation that would change an imported session's history or model context. */
10734
+ private assertSessionWritable(stored: IStoredSessionState): void {
10735
+ if (stored.session.origin !== undefined) {
10736
+ throw this.trustInternalError(new FlexHarnessImportedSessionError(stored.session.sessionId));
10737
+ }
10738
+ }
10739
+
10740
+ /** An imported session never ran here, so it has no hidden stage and no reversion state. */
10741
+ private assertImportedSessionState(stored: IStoredSessionState): void {
10742
+ if (
10743
+ stored.stagedTerminals.length > 0
10744
+ || stored.reversionSegments.length > 0
10745
+ || stored.excludedRunIds.length > 0
10746
+ || stored.pendingReversion !== undefined
10747
+ || stored.pendingReversionReleases.length > 0
10748
+ || stored.messages.some((message) => message.status === 'streaming')
10749
+ ) {
10750
+ throw new FlexHarnessValidationError(
10751
+ `Imported session "${stored.session.sessionId}" has run state it cannot have.`,
10752
+ );
10753
+ }
10754
+ }
10755
+
10756
+ private assertNoCommittingSessionImport(state: IStorageState, sessionId: string): void {
10757
+ if (state.sessionImports.get(sessionId)?.phase === 'committing') {
10758
+ throw new FlexHarnessSessionBusyError(sessionId, 'is being imported');
10759
+ }
10760
+ }
10761
+
10762
+ private findStagedSessionImport(
10763
+ handle: IFlexSessionImportHandle,
10764
+ ): IStagedSessionImport | undefined {
10765
+ const validated = validateSessionImportHandle(handle);
10766
+ const staged = this.stagedSessionImports.get(validated.importId);
10767
+ if (
10768
+ staged
10769
+ && (staged.scopeId !== validated.scopeId || staged.sessionId !== validated.sessionId)
10770
+ ) {
10771
+ throw new FlexHarnessValidationError('The session import handle does not match its import.');
10772
+ }
10773
+ return staged;
10774
+ }
10775
+
10776
+ private requireStagedSessionImport(handle: IFlexSessionImportHandle): IStagedSessionImport {
10777
+ const staged = this.findStagedSessionImport(handle);
10778
+ if (!staged) throw new FlexHarnessNotFoundError('Session import', handle.importId);
10779
+ if (staged.phase !== 'staging') {
10780
+ throw new FlexHarnessSessionBusyError(staged.sessionId, 'is being committed');
10781
+ }
10782
+ return staged;
10783
+ }
10784
+
10785
+ private armStagedImportIdleTimer(staged: IStagedSessionImport): void {
10786
+ this.clearStagedImportIdleTimer(staged);
10787
+ const timer = setTimeout(() => {
10788
+ if (staged.idleTimer !== timer) return;
10789
+ delete staged.idleTimer;
10790
+ if (staged.phase === 'staging') this.releaseStagedSessionImport(staged);
10791
+ }, this.sessionImportLimits.stagedImportIdleTimeoutMs);
10792
+ timer.unref();
10793
+ staged.idleTimer = timer;
10794
+ }
10795
+
10796
+ private clearStagedImportIdleTimer(staged: IStagedSessionImport): void {
10797
+ if (staged.idleTimer === undefined) return;
10798
+ clearTimeout(staged.idleTimer);
10799
+ delete staged.idleTimer;
10800
+ }
10801
+
10802
+ private releaseStagedSessionImport(staged: IStagedSessionImport): void {
10803
+ this.clearStagedImportIdleTimer(staged);
10804
+ if (this.stagedSessionImports.get(staged.importId) === staged) {
10805
+ this.stagedSessionImports.delete(staged.importId);
10806
+ }
10807
+ if (staged.state.sessionImports.get(staged.sessionId) === staged) {
10808
+ staged.state.sessionImports.delete(staged.sessionId);
10809
+ staged.state.initializingSessions.delete(staged.sessionId);
10810
+ }
10811
+ staged.runs = [];
10812
+ staged.runIds.clear();
10813
+ staged.messageIds.clear();
10814
+ }
10815
+
10816
+ /**
10817
+ * An imported child session belongs to a committed imported parent whose run `parent.runId` holds
10818
+ * the tool call `parent.toolCallId` naming the child, and no other session claims that tool call.
10819
+ */
10820
+ private requireImportParent(
10821
+ state: IStorageState,
10822
+ sessionId: string,
10823
+ parent: NonNullable<INormalizedSessionImportOptions['parent']>,
10824
+ ): IStoredSessionState {
10825
+ const parentStored = state.initializingSessions.has(parent.sessionId)
10826
+ || state.tombstones.has(parent.sessionId)
10827
+ ? undefined
10828
+ : state.sessions.get(parent.sessionId);
10829
+ if (!parentStored?.session.origin) {
10830
+ throw this.trustInternalError(new FlexHarnessValidationError(
10831
+ `The parent of imported session "${sessionId}" must be a committed imported session.`,
10832
+ ));
10833
+ }
10834
+ if ((parentStored.session.depth ?? 0) + 1 > FLEX_SESSION_IMPORT_MAX_DEPTH) {
10835
+ throw this.trustInternalError(new FlexHarnessValidationError(
10836
+ `Imported sessions nest at most ${FLEX_SESSION_IMPORT_MAX_DEPTH} levels deep.`,
10837
+ ));
10838
+ }
10839
+ const referenced = parentStored.messages.some((message) =>
10840
+ message.role === 'assistant'
10841
+ && message.runId === parent.runId
10842
+ && message.parts.some((part) =>
10843
+ part.type === 'tool'
10844
+ && part.toolCallId === parent.toolCallId
10845
+ && part.childSessionId === sessionId));
10846
+ if (!referenced) {
10847
+ throw this.trustInternalError(new FlexHarnessValidationError(
10848
+ `Parent run "${parent.runId}" has no tool call "${parent.toolCallId}" whose childSessionId is "${sessionId}".`,
10849
+ ));
10850
+ }
10851
+ for (const stored of state.sessions.values()) {
10852
+ if (
10853
+ stored.session.parentSessionId === parent.sessionId
10854
+ && stored.session.parentRunId === parent.runId
10855
+ && stored.session.parentToolCallId === parent.toolCallId
10856
+ ) {
10857
+ throw this.trustInternalError(new FlexHarnessValidationError(
10858
+ `Session "${stored.session.sessionId}" already belongs to tool call "${parent.toolCallId}".`,
10859
+ ));
10860
+ }
10861
+ }
10862
+ return parentStored;
10863
+ }
10864
+
10391
10865
  private requireMessage(stored: IStoredSessionState, messageId: string): IFlexMessage {
10392
10866
  const message = this.visibleMessages(stored).find((entry) => entry.messageId === messageId);
10393
10867
  if (!message) throw new FlexHarnessNotFoundError('Message', messageId);