@modelprofile.com/flexharness 5.3.0 → 5.3.1

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.
package/readme.md CHANGED
@@ -250,11 +250,15 @@ Public writes use `{ actor: 'application' }`. Tool writes use `{ actor: 'agent',
250
250
 
251
251
  `FLEX_PROJECT_MANAGEMENT_LIMITS` exports the hard UTF-8 and aggregate limits: goal 8 KiB, scratchpad 128 KiB, task content 8 KiB, task ID 512 bytes, title 2048 bytes, 512 tasks, and a 1 MiB serialized snapshot. The aggregate bound leaves room for worst-case JSON escaping of a controller-valid scratchpad. Loaded snapshots reject extra fields, duplicate IDs, invalid status/priority/timestamps, non-JSON data, wrong schema/revision, and every exceeded bound before use.
252
252
 
253
- `IFlexProjectManagementStore` is exact per `(storageKey, sessionId)`: `load`, CAS `save`, CAS `tombstoneSession`, and `purgeNamespace` must not collapse multiple sessions or storage namespaces. `load()` returns `TFlexProjectManagementRecord | undefined` and receives the current session generation as an optional third argument; FlexHarness always supplies it, while two-argument callers and stores remain compatible. Same-generation live saves use normal revision CAS, and a same-generation tombstone permanently rejects later live saves. A higher `sessionGenerationSequence` with a different `sessionGenerationId` may replace only an older tombstone using expected revision `0`; it cannot replace a live record. This resets the PM revision for a recreated core session while stale saves and tombstones from older generations remain fenced. Deleting a recreated session that made no PM writes still replaces the prior-generation tombstone with a revision-1 tombstone for the new generation.
253
+ `IFlexProjectManagementStore` is exact per `(storageKey, sessionId)`: `load`, CAS `save`, CAS `tombstoneSession`, and `purgeNamespace` must not collapse multiple sessions or storage namespaces. `load()` returns `TFlexProjectManagementRecord | undefined` and receives an optional `IFlexProjectManagementSessionContext` as its third argument; `tombstoneSession()` receives the same optional context as its fifth argument. FlexHarness always supplies both, while existing two-argument loads, four-argument tombstones, and shorter store implementations remain compatible.
254
+
255
+ `IFlexProjectManagementSessionContext` contains `sessionGenerationId`, `sessionGenerationSequence`, and optional `subagent`. The atomic `IFlexSubagentProvenance` block contains `parentSessionId`, `parentSessionGenerationId`, `parentSessionGenerationSequence`, `originParentRunId`, `originParentToolCallId`, `agent`, and actual session `depth`. Both the context and its separately cloned nested block are frozen.
256
+
257
+ Same-generation live saves use normal revision CAS, and a same-generation tombstone permanently rejects later live saves. A higher `sessionGenerationSequence` with a different `sessionGenerationId` may replace only an older tombstone using expected revision `0`; it cannot replace a live record. This resets the PM revision for a recreated core session while stale saves and tombstones from older generations remain fenced. Deleting a recreated session that made no PM writes still replaces the prior-generation tombstone with a revision-1 tombstone for the new generation.
254
258
 
255
259
  Every newly created core session exposes and persists a `sessionGenerationId` plus its monotonic `sessionGenerationSequence`. FlexHarness generates a strong random ID when `sessionGenerationId` is omitted. Applications may supply the ID to `createSession()` when they need to persist creation authority before dispatch; a supplied ID must be nonblank, contain no control characters, and fit within `FLEX_SESSION_GENERATION_ID_MAX_BYTES` (128 UTF-8 bytes). FlexHarness still assigns the sequence atomically. A legacy scope session without those fields is assigned a deterministic bounded ID derived from its immutable `storageKey`, `sessionId`, and `createdAt`; FlexHarness persists the repaired scope snapshot before accepting work. Grouped core deletion tombstones retain both fields after live metadata is removed.
256
260
 
257
- Normal Flex session cleanup always waits in-flight local project operations, then loads and CAS-tombstones `stores.projectManagement`, regardless of whether PM tools are enabled in that harness. If a concurrent same-generation save wins first, cleanup reloads and retries within a bounded attempt count; unresolved conflict or store failure retains the core Flex session cleanup tombstone for a later retry. The durable PM tombstone is not physically removed during normal session cleanup.
261
+ Normal Flex session cleanup always waits in-flight local project operations, then loads and CAS-tombstones `stores.projectManagement`, regardless of whether PM tools are enabled in that harness. Child cleanup persists one complete `IFlexSubagentProvenance` block on its core tombstone before live metadata is removed. One cleanup invocation reuses the identical doubly frozen context object across bounded CAS retries; restart or a later cleanup invocation reconstructs a new frozen context from the persisted provenance. If a concurrent same-generation save wins first, cleanup reloads and retries; unresolved conflict or store failure retains the core Flex session cleanup tombstone for a later retry. The durable PM tombstone is not physically removed during normal session cleanup.
258
262
 
259
263
  `purgeNamespace(storageKey)` is the explicit destructive reclamation operation and physically removes every live record and tombstone in that exact PM namespace. Applications may call it only after serializing every scope alias, preventing new admission, awaiting `retireScope()` on every harness owner, and deleting or purging the application-owned core scope namespace. `retireScope()` itself remains non-destructive and never calls `purgeNamespace()`. Purging PM first, purging only one alias, or racing a stale harness can remove the fence that makes session-generation reuse safe.
260
264
 
@@ -275,7 +279,7 @@ interface IDelegateInput {
275
279
  }
276
280
  ```
277
281
 
278
- Before creating or resuming a child, FlexHarness requests permission on the parent run with `kind: 'subagent.start'`, the parent `toolCallId`, and bounded agent/task metadata. The controller answers it through the normal permission APIs. This request has no `rememberKey`, so `always` is invalid; controllers use `once` or `reject`.
282
+ Before creating or resuming a child, FlexHarness requests permission on the parent run with `kind: 'subagent.start'`, the parent `toolCallId`, and separately bounded harness-owned agent/task metadata. Its metadata includes `childSessionId`, the exact ID reserved for this call: FlexHarness derives it deterministically when `taskId` is omitted and copies the supplied candidate when `taskId` is present. A resume also retains that candidate as `taskId`. This block is not truncated by `toolOutputLimits`. The reserved ID binds permission handling before child creation but does not prove that the child exists or is owned: applications must treat the later delegated admission context as authoritative. The controller answers through the normal permission APIs. This request has no `rememberKey`, so `always` is invalid; controllers use `once` or `reject`.
279
283
 
280
284
  Each new invocation creates a durable child `IFlexSession` with immutable `parentSessionId`, origin `parentRunId`, origin `parentToolCallId`, `agent`, and `depth`. New public roots persist `depth: 0`; legacy schema-1 roots may omit it. These fields are harness-owned; public `createSession()` remains limited to `sessionId`, `sessionGenerationId`, and `title`. Child sessions reject direct `prompt()`, `startPrompt()`, `enqueuePrompt()`, and `schedulePrompt()` calls and run only through the foreground `delegate` tool. The model and tool resolver contexts receive optional immutable `parentSessionId` and `agent` values so integrations can apply agent-specific model and tool policy. Child prompts use the definition's `modelHint`, `system`, and `maxSteps`.
281
285
 
@@ -294,7 +298,7 @@ Omitting `taskId` creates a deterministic child for the parent session, run, and
294
298
 
295
299
  Supplying `taskId` deliberately resumes an idle, live child from a later run of the same immutable parent session and the same configured agent. The caller must use the exact ID returned by an earlier completed delegate call; an unknown ID fails with safe corrective guidance and never creates a child under the supplied label. Resume starts a new child prompt while retaining the child's original parent run and tool-call origin. A child owned by another parent or agent, a deleted child, an active child, a same-run resume, or a second acquisition of the same child within one later parent run is rejected. Parent cancellation propagates only to the exact child run started by that delegate call.
296
300
 
297
- `delegatedRunAdmissionProvider` optionally adds an application-owned admission lease around each internally delegated child run. The public contracts are `IFlexDelegatedRunAdmissionProvider<TScope>`, `IFlexDelegatedRunAdmissionContext<TScope>`, and `IFlexDelegatedRunAdmissionLease`. The provider is never called for root prompts or direct public prompt APIs. It receives a frozen context containing the resolved `scopeId`, exact captured `scope`, and `storageKey`; the exact child `sessionId`, session generation, queue, and run; the exact current parent session generation, queue, run, and delegate `toolCallId`; the child agent and depth; and the child run `AbortSignal`. For `taskId` resume, `parentQueueId`, `parentRunId`, and `parentToolCallId` identify the current delegate invocation, while the durable child session retains its original `parentRunId` and `parentToolCallId` creation metadata.
301
+ `delegatedRunAdmissionProvider` optionally adds an application-owned admission lease around each internally delegated child run. The public contracts are `IFlexDelegatedRunAdmissionProvider<TScope>`, `IFlexDelegatedRunAdmissionContext<TScope>`, and `IFlexDelegatedRunAdmissionLease`. The provider is never called for root prompts or direct public prompt APIs. It receives a frozen context containing the resolved `scopeId`, exact captured `scope`, and `storageKey`; the exact child `sessionId`, session generation, queue, and run; the exact current parent session generation, queue, run, and delegate `toolCallId`; immutable `originParentRunId` and `originParentToolCallId`; the child agent and depth; and the child run `AbortSignal`. For `taskId` resume, the current parent queue/run/tool-call fields identify this delegate invocation, while the origin fields remain fixed to the invocation that created the durable child.
298
302
 
299
303
  ```typescript
300
304
  delegatedRunAdmissionProvider: {
@@ -314,6 +318,8 @@ delegatedRunAdmissionProvider: {
314
318
  queueId: context.parentQueueId,
315
319
  runId: context.parentRunId,
316
320
  toolCallId: context.parentToolCallId,
321
+ originRunId: context.originParentRunId,
322
+ originToolCallId: context.originParentToolCallId,
317
323
  },
318
324
  signal: context.signal,
319
325
  });
@@ -328,7 +334,9 @@ Acquisition completes before generation-side branch reversion, context compactio
328
334
 
329
335
  For a normally acquired lease, FlexHarness gives `close()` an awaited attempt after SmartAgent generation and before canonical accepted, rejected, or interrupted finalization and the terminal `prompt.finished` event. If an abort detaches an acquisition that ignores its signal, the run may settle before acquisition returns; FlexHarness retains that owner and closes any late lease. `close()` must be idempotent and safe to retry after rejection. A close failure prevents successful child acceptance and remains owned by the exact child session generation for retry by later exact-session deletion, scope retirement, or disposal. Retirement and disposal truthfully wait for late acquisition and lease cleanup.
330
336
 
331
- Limits are validated and frozen at construction: at most 32 unique definitions; names are non-empty and at most 128 UTF-8 bytes; descriptions 2048 bytes; optional model hints 512 bytes; optional system prompts 64 KiB; and optional `maxSteps` a positive safe integer. `maxSubagentDepth` defaults to 1 and must be a positive safe integer at most 8. `maxSubagentCallsPerRun` defaults to 32 and must be a positive safe integer at most 128. A call slot is consumed synchronously at the start of every schema-valid delegate execution, before semantic bounds, subagent type/depth validation, permission, or child work. Inputs rejected by the tool schema never start delegate execution and do not consume a slot. After successful semantic validation, the child ID is reserved for the rest of the parent run, including after permission rejection or later failure. Permission rejection creates no child session. Omitting `taskId` reserves a deterministic new child ID; supplying `taskId` reserves and resumes that existing child after permission. Delegate descriptions are non-empty and at most 256 UTF-8 bytes, prompts non-empty and at most 64 KiB, subagent types at most 128 bytes, and task IDs at most 512 bytes.
337
+ Limits are validated and frozen at construction: at most 32 unique definitions; names are non-empty and at most 128 UTF-8 bytes; descriptions 2048 bytes; optional model hints 512 bytes; optional system prompts 64 KiB; and optional `maxSteps` a positive safe integer. `maxSubagentDepth` defaults to 1 and must be a positive safe integer at most 8. `maxSubagentCallsPerRun` defaults to 32 and must be a positive safe integer at most 128. A call slot is consumed synchronously at the start of every schema-valid delegate execution, before semantic bounds, subagent type/depth validation, permission, or child work. Inputs rejected by the tool schema never start delegate execution and do not consume a slot. After successful semantic validation, the child ID candidate is reserved for the rest of the parent run, including after permission rejection or later failure. Permission rejection creates no child session. Omitting `taskId` reserves a deterministic new child ID; supplying `taskId` reserves that unverified candidate and attempts resume after permission only if it identifies a resumable child. Delegate descriptions are non-empty and at most 256 UTF-8 bytes, prompts non-empty and at most 64 KiB, subagent types at most 128 bytes, and task IDs at most 512 bytes.
338
+
339
+ Scope snapshots remain schema 1 and legacy child tombstones without `subagent` provenance continue to load. New child tombstones write the atomic provenance block and reject partial, parent-generation-mismatched, depth-mismatched, or duplicate-origin records. FlexHarness versions before this provenance addition reject that new optional key under their strict reader, so downgrading or mixing old readers with newly written scope snapshots is unsupported.
332
340
 
333
341
  ## Sessions And Prompts
334
342
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@modelprofile.com/flexharness',
6
- version: '5.3.0',
6
+ version: '5.3.1',
7
7
  description: 'Provider-neutral model-session runtime with durable history, permissions, typed events, and pluggable local or remote tool execution.'
8
8
  }
@@ -51,6 +51,7 @@ import type {
51
51
  IFlexPermissionRequestInput,
52
52
  IFlexPermissionSnapshot,
53
53
  IFlexProjectGoalResult,
54
+ IFlexProjectManagementSessionContext,
54
55
  IFlexProjectManagementSnapshot,
55
56
  IFlexProjectManagementTombstone,
56
57
  IFlexProjectManagementWriteContext,
@@ -87,6 +88,7 @@ import type {
87
88
  IFlexSlashCommandExecutionOptions,
88
89
  IFlexSlashCommandHandlerRegistration,
89
90
  IFlexSubagentDefinition,
91
+ IFlexSubagentProvenance,
90
92
  IFlexTerminalProjection,
91
93
  IFlexToolHandle,
92
94
  IFlexToolMessagePart,
@@ -545,6 +547,7 @@ const maxBackgroundExecutions = 100;
545
547
  const maxSubagentTaskDescriptionBytes = 256;
546
548
  const maxSubagentPromptBytes = 64 * 1024;
547
549
  const maxSubagentTaskIdBytes = 512;
550
+ const maxSubagentPermissionMetadataBytes = 16 * 1024;
548
551
  const maxSubagentResultTextBytes = 64 * 1024;
549
552
  const defaultMaxSubagentDepth = 1;
550
553
  const maximumMaxSubagentDepth = 8;
@@ -1380,8 +1383,13 @@ export class FlexHarness<TScope = unknown> {
1380
1383
  const rootDepth = stored.session.depth ?? 0;
1381
1384
  const deletedAt = new Date().toISOString();
1382
1385
  const deletedSessions = subtree.map((entry) => cloneSerializable(entry.session));
1386
+ const provenanceBySessionId = new Map(subtree.map((entry) => [
1387
+ entry.session.sessionId,
1388
+ this.createSubagentProvenance(state, entry.session),
1389
+ ]));
1383
1390
  for (const entry of subtree) {
1384
1391
  const entrySessionId = entry.session.sessionId;
1392
+ const subagent = provenanceBySessionId.get(entrySessionId);
1385
1393
  state.sessions.delete(entrySessionId);
1386
1394
  state.retainedSessionCleanups.set(entrySessionId, {
1387
1395
  stored: entry,
@@ -1396,6 +1404,7 @@ export class FlexHarness<TScope = unknown> {
1396
1404
  ...(entry.session.parentSessionId === undefined
1397
1405
  ? {}
1398
1406
  : { parentSessionId: entry.session.parentSessionId }),
1407
+ ...(subagent === undefined ? {} : { subagent: cloneSerializable(subagent) }),
1399
1408
  });
1400
1409
  }
1401
1410
  return {
@@ -3491,6 +3500,8 @@ export class FlexHarness<TScope = unknown> {
3491
3500
  parentQueueId: seed.parentQueueId,
3492
3501
  parentRunId: seed.parentRunId,
3493
3502
  parentToolCallId: seed.parentToolCallId,
3503
+ originParentRunId: seed.originParentRunId,
3504
+ originParentToolCallId: seed.originParentToolCallId,
3494
3505
  agent: seed.agent,
3495
3506
  depth: seed.depth,
3496
3507
  signal: run.controller.signal,
@@ -4173,10 +4184,11 @@ export class FlexHarness<TScope = unknown> {
4173
4184
  ): Promise<IFlexProjectManagementSnapshot> {
4174
4185
  const store = this.stores.projectManagement;
4175
4186
  const generation = requireSessionGeneration(stored.session);
4187
+ const sessionContext = this.createProjectManagementSessionContext(state, stored.session);
4176
4188
  const record = await store.load(
4177
4189
  state.storageKey,
4178
4190
  stored.session.sessionId,
4179
- Object.freeze({ ...generation }),
4191
+ sessionContext,
4180
4192
  );
4181
4193
  if (record === undefined) {
4182
4194
  return createEmptyFlexProjectManagementSnapshot(
@@ -4269,6 +4281,52 @@ export class FlexHarness<TScope = unknown> {
4269
4281
  return publicSnapshot({ revision: snapshot.revision, state: snapshot });
4270
4282
  }
4271
4283
 
4284
+ private createSubagentProvenance(
4285
+ state: IStorageState,
4286
+ session: Readonly<IFlexSession>,
4287
+ ): Readonly<IFlexSubagentProvenance> | undefined {
4288
+ if (session.parentSessionId === undefined) return undefined;
4289
+ if (
4290
+ session.parentRunId === undefined
4291
+ || session.parentToolCallId === undefined
4292
+ || session.agent === undefined
4293
+ || session.depth === undefined
4294
+ ) throw new FlexHarnessValidationError('Subagent session provenance is incomplete.');
4295
+ const parent = state.sessions.get(session.parentSessionId)?.session
4296
+ ?? state.tombstones.get(session.parentSessionId);
4297
+ if (!parent) {
4298
+ throw new FlexHarnessValidationError(
4299
+ `Subagent session "${session.sessionId}" has no exact parent provenance.`,
4300
+ );
4301
+ }
4302
+ const parentGeneration = requireSessionGeneration(parent);
4303
+ return Object.freeze({
4304
+ parentSessionId: session.parentSessionId,
4305
+ parentSessionGenerationId: parentGeneration.sessionGenerationId,
4306
+ parentSessionGenerationSequence: parentGeneration.sessionGenerationSequence,
4307
+ originParentRunId: session.parentRunId,
4308
+ originParentToolCallId: session.parentToolCallId,
4309
+ agent: session.agent,
4310
+ depth: session.depth,
4311
+ });
4312
+ }
4313
+
4314
+ private createProjectManagementSessionContext(
4315
+ state: IStorageState,
4316
+ session: Readonly<IFlexSession | IFlexSessionTombstone>,
4317
+ ): Readonly<IFlexProjectManagementSessionContext> {
4318
+ const source = 'deletedAt' in session
4319
+ ? session.subagent
4320
+ : this.createSubagentProvenance(state, session);
4321
+ const subagent = source === undefined
4322
+ ? undefined
4323
+ : Object.freeze({ ...cloneSerializable(source) });
4324
+ return Object.freeze({
4325
+ ...requireSessionGeneration(session),
4326
+ ...(subagent === undefined ? {} : { subagent }),
4327
+ });
4328
+ }
4329
+
4272
4330
  private projectTaskResult(
4273
4331
  snapshot: IFlexProjectManagementSnapshot,
4274
4332
  task: IFlexProjectTask,
@@ -5046,16 +5104,20 @@ export class FlexHarness<TScope = unknown> {
5046
5104
  run.controller.signal.addEventListener('abort', abortChild, { once: true });
5047
5105
  if (run.controller.signal.aborted) abortChild();
5048
5106
  try {
5107
+ const permissionMetadata = {
5108
+ agent: definition.name,
5109
+ description: input.description,
5110
+ childSessionId: reservedSessionId,
5111
+ ...(input.taskId === undefined ? {} : { taskId: input.taskId }),
5112
+ };
5113
+ if (jsonBytes(permissionMetadata) > maxSubagentPermissionMetadataBytes) {
5114
+ throw new FlexHarnessValidationError('Subagent permission metadata exceeds its byte limit.');
5115
+ }
5049
5116
  await this.requestPermission(run.state, run, {
5050
5117
  kind: 'subagent.start',
5051
5118
  description: `Start foreground subagent "${definition.name}": ${input.description}`,
5052
5119
  toolCallId,
5053
- metadata: {
5054
- agent: definition.name,
5055
- description: input.description,
5056
- ...(input.taskId === undefined ? {} : { taskId: input.taskId }),
5057
- },
5058
- });
5120
+ }, Object.freeze(permissionMetadata));
5059
5121
  const acquired = await this.acquireSubagentSession(
5060
5122
  run,
5061
5123
  definition,
@@ -5343,6 +5405,7 @@ export class FlexHarness<TScope = unknown> {
5343
5405
  ) throw projected;
5344
5406
  if (state.tombstones.has(sessionId)) throw projected;
5345
5407
  try {
5408
+ const subagent = this.createSubagentProvenance(state, metadata);
5346
5409
  await this.mutateScope(state, () => {
5347
5410
  if (state.sessions.get(sessionId) !== placeholder) return;
5348
5411
  state.sessions.delete(sessionId);
@@ -5353,6 +5416,7 @@ export class FlexHarness<TScope = unknown> {
5353
5416
  rootSessionId: sessionId,
5354
5417
  depth: 0,
5355
5418
  parentSessionId: run.sessionId,
5419
+ ...(subagent === undefined ? {} : { subagent: cloneSerializable(subagent) }),
5356
5420
  });
5357
5421
  }, true);
5358
5422
  completeInitialization();
@@ -6920,6 +6984,7 @@ export class FlexHarness<TScope = unknown> {
6920
6984
  state: IStorageState,
6921
6985
  run: IActiveRun,
6922
6986
  input: IFlexPermissionRequestInput,
6987
+ exactMetadata?: TJsonValue,
6923
6988
  ): Promise<void> {
6924
6989
  validateIdentifier(input.kind, 'permission kind');
6925
6990
  validateIdentifier(input.description, 'permission description');
@@ -6942,9 +7007,11 @@ export class FlexHarness<TScope = unknown> {
6942
7007
  description: truncateUtf8(input.description, maxTransferMetadataBytes),
6943
7008
  ...(input.toolCallId ? { toolCallId: input.toolCallId } : {}),
6944
7009
  ...(input.rememberKey ? { rememberKey: input.rememberKey } : {}),
6945
- ...(input.metadata === undefined
6946
- ? {}
6947
- : { metadata: normalizeJsonValue(input.metadata, this.toolOutputLimits) }),
7010
+ ...(exactMetadata !== undefined
7011
+ ? { metadata: cloneSerializable(exactMetadata) }
7012
+ : input.metadata === undefined
7013
+ ? {}
7014
+ : { metadata: normalizeJsonValue(input.metadata, this.toolOutputLimits) }),
6948
7015
  createdAt: new Date().toISOString(),
6949
7016
  };
6950
7017
  let resolvePermission!: () => void;
@@ -7785,7 +7852,7 @@ export class FlexHarness<TScope = unknown> {
7785
7852
  scopeId,
7786
7853
  scope,
7787
7854
  );
7788
- await this.cleanupSessionDomains(storageKey, tombstone);
7855
+ await this.cleanupSessionDomains(state, tombstone);
7789
7856
  }
7790
7857
  for (const tombstone of group) state.tombstones.delete(tombstone.sessionId);
7791
7858
  scopeChanged = true;
@@ -8766,16 +8833,17 @@ export class FlexHarness<TScope = unknown> {
8766
8833
  }
8767
8834
 
8768
8835
  private async cleanupSessionDomains(
8769
- storageKey: string,
8836
+ state: IStorageState,
8770
8837
  tombstone: IFlexSessionTombstone,
8771
8838
  ): Promise<void> {
8839
+ const storageKey = state.storageKey;
8772
8840
  const sessionId = tombstone.sessionId;
8773
8841
  const projectManagementCleanup = async (): Promise<void> => {
8774
8842
  await this.projectManagementQueues.get(
8775
8843
  this.projectManagementKey(storageKey, sessionId),
8776
8844
  );
8777
8845
  await this.tombstoneProjectManagementSession(
8778
- storageKey,
8846
+ state,
8779
8847
  tombstone,
8780
8848
  );
8781
8849
  };
@@ -8793,18 +8861,20 @@ export class FlexHarness<TScope = unknown> {
8793
8861
  }
8794
8862
 
8795
8863
  private async tombstoneProjectManagementSession(
8796
- storageKey: string,
8864
+ state: IStorageState,
8797
8865
  sessionTombstone: IFlexSessionTombstone,
8798
8866
  ): Promise<void> {
8867
+ const storageKey = state.storageKey;
8799
8868
  const store = this.stores.projectManagement;
8800
8869
  const sessionId = sessionTombstone.sessionId;
8801
8870
  const generation = requireSessionGeneration(sessionTombstone);
8871
+ const sessionContext = this.createProjectManagementSessionContext(state, sessionTombstone);
8802
8872
  let lastConflict: FlexHarnessStoreConflictError | undefined;
8803
8873
  for (let attempt = 0; attempt <= maxProjectManagementTombstoneConflicts; attempt++) {
8804
8874
  const current = await store.load(
8805
8875
  storageKey,
8806
8876
  sessionId,
8807
- Object.freeze({ ...generation }),
8877
+ sessionContext,
8808
8878
  );
8809
8879
  if (current !== undefined) {
8810
8880
  assertFlexProjectManagementRecord(current);
@@ -8845,6 +8915,7 @@ export class FlexHarness<TScope = unknown> {
8845
8915
  sessionId,
8846
8916
  tombstone,
8847
8917
  expectedRevision,
8918
+ sessionContext,
8848
8919
  );
8849
8920
  } catch (error) {
8850
8921
  if (!(error instanceof FlexHarnessStoreConflictError)) throw error;
@@ -9062,7 +9133,7 @@ export class FlexHarness<TScope = unknown> {
9062
9133
  releaseContext.scope,
9063
9134
  retained?.stored,
9064
9135
  );
9065
- await this.cleanupSessionDomains(state.storageKey, tombstone);
9136
+ await this.cleanupSessionDomains(state, tombstone);
9066
9137
  if (retained) retained.domainsCompleted = true;
9067
9138
  }
9068
9139
  }
package/ts/interfaces.ts CHANGED
@@ -441,6 +441,7 @@ export interface IFlexSessionTombstone {
441
441
  rootSessionId?: string;
442
442
  depth?: number;
443
443
  parentSessionId?: string;
444
+ subagent?: IFlexSubagentProvenance;
444
445
  }
445
446
 
446
447
  export interface IFlexScopeSnapshot {
@@ -582,6 +583,16 @@ export interface IFlexSessionGeneration {
582
583
  sessionGenerationSequence: number;
583
584
  }
584
585
 
586
+ export interface IFlexSubagentProvenance {
587
+ readonly parentSessionId: string;
588
+ readonly parentSessionGenerationId: string;
589
+ readonly parentSessionGenerationSequence: number;
590
+ readonly originParentRunId: string;
591
+ readonly originParentToolCallId: string;
592
+ readonly agent: string;
593
+ readonly depth: number;
594
+ }
595
+
585
596
  export interface IFlexSessionGenerationCohortEntry extends IFlexSessionGeneration {
586
597
  sessionId: string;
587
598
  }
@@ -649,11 +660,15 @@ export interface IFlexProjectManagementWriteContext {
649
660
  toolCallId?: string;
650
661
  }
651
662
 
663
+ export interface IFlexProjectManagementSessionContext extends IFlexSessionGeneration {
664
+ readonly subagent?: Readonly<IFlexSubagentProvenance>;
665
+ }
666
+
652
667
  export interface IFlexProjectManagementStore {
653
668
  load(
654
669
  storageKey: string,
655
670
  sessionId: string,
656
- sessionGeneration?: Readonly<IFlexSessionGeneration>,
671
+ sessionContext?: Readonly<IFlexProjectManagementSessionContext>,
657
672
  ): Promise<TFlexProjectManagementRecord | undefined>;
658
673
  save(
659
674
  storageKey: string,
@@ -667,6 +682,7 @@ export interface IFlexProjectManagementStore {
667
682
  sessionId: string,
668
683
  tombstone: IFlexProjectManagementTombstone,
669
684
  expectedRevision: number,
685
+ sessionContext?: Readonly<IFlexProjectManagementSessionContext>,
670
686
  ): Promise<void>;
671
687
  purgeNamespace(storageKey: string): Promise<void>;
672
688
  }
@@ -980,6 +996,8 @@ export interface IFlexDelegatedRunAdmissionContext<TScope> extends IFlexSessionG
980
996
  readonly parentQueueId: string;
981
997
  readonly parentRunId: string;
982
998
  readonly parentToolCallId: string;
999
+ readonly originParentRunId: string;
1000
+ readonly originParentToolCallId: string;
983
1001
  readonly agent: string;
984
1002
  readonly depth: number;
985
1003
  readonly signal: AbortSignal;
package/ts/utils.json.ts CHANGED
@@ -15,6 +15,7 @@ import type {
15
15
  IFlexProjectionSnapshotV2,
16
16
  IFlexProjectionSnapshotV3,
17
17
  IFlexScopeSnapshot,
18
+ IFlexSubagentProvenance,
18
19
  TFlexAgentModelMessage,
19
20
  TJsonValue,
20
21
  } from './interfaces.js';
@@ -546,6 +547,68 @@ function validateSession(value: unknown, path: string): IValidatedSessionIdentit
546
547
  };
547
548
  }
548
549
 
550
+ function validateSubagentProvenance(
551
+ value: unknown,
552
+ path: string,
553
+ ): IFlexSubagentProvenance {
554
+ const provenance = requireRecord(value, path);
555
+ requireOnlyKeys(provenance, [
556
+ 'parentSessionId',
557
+ 'parentSessionGenerationId',
558
+ 'parentSessionGenerationSequence',
559
+ 'originParentRunId',
560
+ 'originParentToolCallId',
561
+ 'agent',
562
+ 'depth',
563
+ ], path);
564
+ const parentSessionId = requireString(
565
+ provenance.parentSessionId,
566
+ `${path}.parentSessionId`,
567
+ );
568
+ const parentSessionGenerationId = requireString(
569
+ provenance.parentSessionGenerationId,
570
+ `${path}.parentSessionGenerationId`,
571
+ );
572
+ if (
573
+ Buffer.byteLength(parentSessionGenerationId, 'utf8')
574
+ > FLEX_SESSION_GENERATION_ID_MAX_BYTES
575
+ ) {
576
+ throw new FlexHarnessStoreFormatError(
577
+ `${path}.parentSessionGenerationId exceeds ${FLEX_SESSION_GENERATION_ID_MAX_BYTES} UTF-8 bytes.`,
578
+ );
579
+ }
580
+ if (
581
+ !Number.isSafeInteger(provenance.parentSessionGenerationSequence)
582
+ || Number(provenance.parentSessionGenerationSequence) < 1
583
+ ) {
584
+ throw new FlexHarnessStoreFormatError(
585
+ `${path}.parentSessionGenerationSequence must be a positive integer.`,
586
+ );
587
+ }
588
+ const originParentRunId = requireString(
589
+ provenance.originParentRunId,
590
+ `${path}.originParentRunId`,
591
+ );
592
+ const originParentToolCallId = requireString(
593
+ provenance.originParentToolCallId,
594
+ `${path}.originParentToolCallId`,
595
+ );
596
+ const agent = requireString(provenance.agent, `${path}.agent`);
597
+ requireNonNegativeInteger(provenance.depth, `${path}.depth`);
598
+ if (provenance.depth === 0) {
599
+ throw new FlexHarnessStoreFormatError(`${path}.depth must be positive.`);
600
+ }
601
+ return {
602
+ parentSessionId,
603
+ parentSessionGenerationId,
604
+ parentSessionGenerationSequence: provenance.parentSessionGenerationSequence as number,
605
+ originParentRunId,
606
+ originParentToolCallId,
607
+ agent,
608
+ depth: provenance.depth as number,
609
+ };
610
+ }
611
+
549
612
  function validateModel(value: unknown, path: string): void {
550
613
  const model = requireRecord(value, path);
551
614
  requireOnlyKeys(model, ['provider', 'model', 'displayName', 'variant'], path);
@@ -811,9 +874,12 @@ export function assertFlexScopeSnapshot(value: unknown): asserts value is IFlexS
811
874
  const tombstoneIds = new Set<string>();
812
875
  const tombstones = new Map<string, {
813
876
  sessionId: string;
877
+ sessionGenerationId?: string;
878
+ sessionGenerationSequence?: number;
814
879
  rootSessionId?: string;
815
880
  depth?: number;
816
881
  parentSessionId?: string;
882
+ subagent?: IFlexSubagentProvenance;
817
883
  }>();
818
884
  for (let index = 0; index < snapshot.tombstones.length; index++) {
819
885
  const path = `$snapshot.tombstones[${index}]`;
@@ -828,11 +894,12 @@ export function assertFlexScopeSnapshot(value: unknown): asserts value is IFlexS
828
894
  'rootSessionId',
829
895
  'depth',
830
896
  'parentSessionId',
897
+ 'subagent',
831
898
  ],
832
899
  path,
833
900
  );
834
901
  const sessionId = requireString(tombstone.sessionId, `${path}.sessionId`);
835
- validateOptionalSessionGeneration(tombstone, path);
902
+ const generation = validateOptionalSessionGeneration(tombstone, path);
836
903
  requireString(tombstone.deletedAt, `${path}.deletedAt`);
837
904
  const parentSessionId = tombstone.parentSessionId === undefined
838
905
  ? undefined
@@ -840,6 +907,19 @@ export function assertFlexScopeSnapshot(value: unknown): asserts value is IFlexS
840
907
  if (parentSessionId === sessionId) {
841
908
  throw new FlexHarnessStoreFormatError(`${path} cannot be its own parent.`);
842
909
  }
910
+ const subagent = tombstone.subagent === undefined
911
+ ? undefined
912
+ : validateSubagentProvenance(tombstone.subagent, `${path}.subagent`);
913
+ if (subagent && generation.sessionGenerationId === undefined) {
914
+ throw new FlexHarnessStoreFormatError(
915
+ `${path}.subagent requires exact session generation fields.`,
916
+ );
917
+ }
918
+ if (subagent && subagent.parentSessionId !== parentSessionId) {
919
+ throw new FlexHarnessStoreFormatError(
920
+ `${path}.subagent does not match its parentSessionId.`,
921
+ );
922
+ }
843
923
  const hasRoot = tombstone.rootSessionId !== undefined;
844
924
  const hasDepth = tombstone.depth !== undefined;
845
925
  if (hasRoot !== hasDepth) {
@@ -864,11 +944,26 @@ export function assertFlexScopeSnapshot(value: unknown): asserts value is IFlexS
864
944
  );
865
945
  }
866
946
  tombstoneIds.add(sessionId);
947
+ if (subagent) {
948
+ const origin = JSON.stringify([
949
+ subagent.parentSessionId,
950
+ subagent.originParentRunId,
951
+ subagent.originParentToolCallId,
952
+ ]);
953
+ if (origins.has(origin)) {
954
+ throw new FlexHarnessStoreFormatError(
955
+ 'Snapshot contains duplicate subagent invocation origins.',
956
+ );
957
+ }
958
+ origins.add(origin);
959
+ }
867
960
  tombstones.set(sessionId, {
868
961
  sessionId,
962
+ ...generation,
869
963
  ...(hasRoot ? { rootSessionId: tombstone.rootSessionId as string } : {}),
870
964
  ...(hasDepth ? { depth: tombstone.depth as number } : {}),
871
965
  ...(parentSessionId === undefined ? {} : { parentSessionId }),
966
+ ...(subagent === undefined ? {} : { subagent }),
872
967
  });
873
968
  }
874
969
  for (const [index, tombstone] of snapshot.tombstones.entries()) {
@@ -894,6 +989,26 @@ export function assertFlexScopeSnapshot(value: unknown): asserts value is IFlexS
894
989
  `Snapshot tombstone "${tombstone.sessionId}" has no parent "${tombstone.parentSessionId}".`,
895
990
  );
896
991
  }
992
+ if (tombstone.subagent) {
993
+ const parent = parentSession ?? parentTombstone!;
994
+ if (
995
+ parent.sessionGenerationId !== tombstone.subagent.parentSessionGenerationId
996
+ || parent.sessionGenerationSequence
997
+ !== tombstone.subagent.parentSessionGenerationSequence
998
+ ) {
999
+ throw new FlexHarnessStoreFormatError(
1000
+ `Snapshot tombstone "${tombstone.sessionId}" has inconsistent parent generation provenance.`,
1001
+ );
1002
+ }
1003
+ const parentDepth = parent.parentSessionId === undefined
1004
+ ? 0
1005
+ : parentSession?.depth ?? parentTombstone?.subagent?.depth;
1006
+ if (parentDepth === undefined || tombstone.subagent.depth !== parentDepth + 1) {
1007
+ throw new FlexHarnessStoreFormatError(
1008
+ `Snapshot tombstone "${tombstone.sessionId}" has inconsistent session depth provenance.`,
1009
+ );
1010
+ }
1011
+ }
897
1012
  if (tombstone.depth !== undefined && tombstone.depth > 0) {
898
1013
  if (
899
1014
  !parentTombstone