@modelprofile.com/flexharness 3.1.0 → 3.3.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.
@@ -7,6 +7,7 @@ import {
7
7
  FlexHarnessNotFoundError,
8
8
  FlexHarnessPermissionRejectedError,
9
9
  FlexHarnessPermissionStateError,
10
+ FlexHarnessQueueFullError,
10
11
  FlexHarnessRunError,
11
12
  FlexHarnessSessionBusyError,
12
13
  FlexHarnessStoreConflictError,
@@ -34,6 +35,9 @@ import type {
34
35
  IFlexPermissionRequestInput,
35
36
  IFlexPermissionSnapshot,
36
37
  IFlexPromptAdmission,
38
+ IFlexPromptQueueAdmission,
39
+ IFlexPromptQueueEntry,
40
+ IFlexPromptQueueLimits,
37
41
  IFlexPromptOptions,
38
42
  IFlexPromptResult,
39
43
  IFlexProjectionSnapshot,
@@ -64,6 +68,7 @@ import type {
64
68
  TFlexPermissionDecision,
65
69
  TFlexPrompt,
66
70
  TFlexPromptPart,
71
+ TFlexPromptQueueStatus,
67
72
  TFlexToolExecutionReconciliation,
68
73
  } from './interfaces.js';
69
74
  import { InMemoryFlexHarnessStores } from './classes.stores.js';
@@ -108,6 +113,11 @@ interface IStoredSessionState {
108
113
  executionContextCloseCompleted: boolean;
109
114
  jobStoreReleased: boolean;
110
115
  jobs?: NonNullable<plugins.IToolExecutionContext['jobs']>;
116
+ promptQueue: IQueuedPrompt[];
117
+ outstandingPromptsById: Map<string, IQueuedPrompt>;
118
+ terminalPromptQueueEntries: Map<string, IFlexPromptQueueEntry>;
119
+ outstandingPromptBytes: number;
120
+ promptQueueDrain?: Promise<void>;
111
121
  }
112
122
 
113
123
  interface IRetainedSessionCleanup {
@@ -160,6 +170,7 @@ interface IActiveRun {
160
170
  scopeId: string;
161
171
  scope: unknown;
162
172
  sessionId: string;
173
+ queueId: string;
163
174
  runId: string;
164
175
  userMessageId: string;
165
176
  assistantMessageId: string;
@@ -182,8 +193,37 @@ interface IActiveRun {
182
193
  reservedAssistantMessage?: IFlexMessage;
183
194
  modelResolution?: IFlexResolvedModel;
184
195
  completion: Promise<IFlexPromptResult>;
196
+ }
197
+
198
+ interface IQueuedPrompt {
199
+ state: IStorageState;
200
+ stored: IStoredSessionState;
201
+ scopeId: string;
202
+ scope: unknown;
203
+ sessionId: string;
204
+ queueId: string;
205
+ queueSequence: number;
206
+ status: Exclude<TFlexPromptQueueStatus, 'completed' | 'failed' | 'cancelled'>;
207
+ queuedAt: string;
208
+ runId?: string;
209
+ scheduleKey?: string;
210
+ debounceMs?: number;
211
+ startedAt?: string;
212
+ prompt?: INormalizedFlexPrompt;
213
+ options?: IFlexPromptOptions;
214
+ byteSize: number;
215
+ completion: Promise<IFlexPromptResult>;
185
216
  resolveCompletion: (result: IFlexPromptResult) => void;
186
217
  rejectCompletion: (error: unknown) => void;
218
+ started: Promise<IFlexPromptAdmission>;
219
+ resolveStarted: (admission: IFlexPromptAdmission) => void;
220
+ rejectStarted: (error: unknown) => void;
221
+ }
222
+
223
+ interface IPendingPromptAdmission {
224
+ controller: AbortController;
225
+ settled: Promise<void>;
226
+ resolveSettled: () => void;
187
227
  }
188
228
 
189
229
  interface IPendingPermission {
@@ -238,6 +278,13 @@ const DEFAULT_CALLBACK_LIMITS: Required<IFlexCallbackLimits> = {
238
278
  maxOutputBytes: 1024 * 1024,
239
279
  maxParts: 2_000,
240
280
  };
281
+ const DEFAULT_PROMPT_QUEUE_LIMITS: Required<IFlexPromptQueueLimits> = {
282
+ maxOutstandingPromptsPerSession: 16,
283
+ maxOutstandingBytesPerSession: 64 * 1024 * 1024,
284
+ maxPendingAdmissions: 64,
285
+ maxPendingAdmissionBytes: 128 * 1024 * 1024,
286
+ maxTerminalEntriesPerSession: 64,
287
+ };
241
288
  const maxMessagePageSize = 50;
242
289
  const maxMessagePageCursorBytes = 4096;
243
290
  const maxTransferIdentifierBytes = 512;
@@ -624,6 +671,29 @@ function resolveCallbackLimits(limits: IFlexCallbackLimits = {}): Required<IFlex
624
671
  return resolved;
625
672
  }
626
673
 
674
+ function resolvePromptQueueLimits(
675
+ limits: IFlexPromptQueueLimits = {},
676
+ ): Required<IFlexPromptQueueLimits> {
677
+ const resolved = {
678
+ maxOutstandingPromptsPerSession: limits.maxOutstandingPromptsPerSession
679
+ ?? DEFAULT_PROMPT_QUEUE_LIMITS.maxOutstandingPromptsPerSession,
680
+ maxOutstandingBytesPerSession: limits.maxOutstandingBytesPerSession
681
+ ?? DEFAULT_PROMPT_QUEUE_LIMITS.maxOutstandingBytesPerSession,
682
+ maxPendingAdmissions: limits.maxPendingAdmissions
683
+ ?? DEFAULT_PROMPT_QUEUE_LIMITS.maxPendingAdmissions,
684
+ maxPendingAdmissionBytes: limits.maxPendingAdmissionBytes
685
+ ?? DEFAULT_PROMPT_QUEUE_LIMITS.maxPendingAdmissionBytes,
686
+ maxTerminalEntriesPerSession: limits.maxTerminalEntriesPerSession
687
+ ?? DEFAULT_PROMPT_QUEUE_LIMITS.maxTerminalEntriesPerSession,
688
+ };
689
+ for (const [name, value] of Object.entries(resolved)) {
690
+ if (!Number.isSafeInteger(value) || value < 1) {
691
+ throw new FlexHarnessValidationError(`promptQueueLimits.${name} must be a positive integer.`);
692
+ }
693
+ }
694
+ return resolved;
695
+ }
696
+
627
697
  function normalizeAgentSessionPolicy(policy: IFlexAgentSessionPolicy = {}): IFlexAgentSessionPolicy {
628
698
  return {
629
699
  ...(policy.contextBuilder === undefined ? {} : { contextBuilder: policy.contextBuilder }),
@@ -656,6 +726,7 @@ export class FlexHarness<TScope = unknown> {
656
726
  private readonly agentSessionPolicy: IFlexAgentSessionPolicy;
657
727
  private readonly toolOutputLimits: Required<NonNullable<IFlexHarnessOptions<TScope>['toolOutputLimits']>>;
658
728
  private readonly callbackLimits: Required<IFlexCallbackLimits>;
729
+ private readonly promptQueueLimits: Required<IFlexPromptQueueLimits>;
659
730
  private readonly externalErrorProjector?: TFlexExternalErrorProjector;
660
731
  private readonly stateLoads = new Map<string, Promise<IStorageState>>();
661
732
  private readonly scopeAdmissions = new Map<string, IScopeAdmissionState>();
@@ -670,7 +741,11 @@ export class FlexHarness<TScope = unknown> {
670
741
  IOrphanedExecutionContextOwner
671
742
  >();
672
743
  private readonly orphanedProviderReleases = new Map<string, IOrphanedProviderRelease>();
744
+ private readonly pendingPromptAdmissionOwners = new Set<IPendingPromptAdmission>();
673
745
  private sequence = 0;
746
+ private promptQueueSequence = 0;
747
+ private pendingPromptAdmissions = 0;
748
+ private pendingPromptAdmissionBytes = 0;
674
749
  private closed = false;
675
750
  private disposePromise?: Promise<void>;
676
751
 
@@ -686,6 +761,7 @@ export class FlexHarness<TScope = unknown> {
686
761
  this.agentSessionPolicy = normalizeAgentSessionPolicy(options.agentSessionPolicy);
687
762
  this.toolOutputLimits = resolveJsonLimits(options.toolOutputLimits);
688
763
  this.callbackLimits = resolveCallbackLimits(options.callbackLimits);
764
+ this.promptQueueLimits = resolvePromptQueueLimits(options.promptQueueLimits);
689
765
  this.externalErrorProjector = options.externalErrorProjector;
690
766
  }
691
767
 
@@ -856,7 +932,9 @@ export class FlexHarness<TScope = unknown> {
856
932
  const { state } = await this.resolveState(scopeId);
857
933
  let result!: IFlexSession;
858
934
  await this.mutateScope(state, () => {
859
- const stored = this.requireMutableSession(state, sessionId);
935
+ const stored = Object.prototype.hasOwnProperty.call(options, 'archived')
936
+ ? this.requireMutableSession(state, sessionId)
937
+ : this.requireSession(state, sessionId);
860
938
  const timestamp = new Date().toISOString();
861
939
  if (Object.prototype.hasOwnProperty.call(options, 'title')) {
862
940
  if (options.title === null) delete stored.session.title;
@@ -1021,7 +1099,19 @@ export class FlexHarness<TScope = unknown> {
1021
1099
  options: IFlexPromptOptions = {},
1022
1100
  ): Promise<IFlexPromptAdmission> {
1023
1101
  validatePromptOptions(options, false);
1024
- return this.admitPrompt(scopeId, sessionId, normalizeFlexPrompt(prompt), options);
1102
+ const queued = await this.enqueuePromptInternal(scopeId, sessionId, prompt, options);
1103
+ return queued.started;
1104
+ }
1105
+
1106
+ public async enqueuePrompt(
1107
+ scopeId: string,
1108
+ sessionId: string,
1109
+ prompt: TFlexPrompt,
1110
+ options: IFlexPromptOptions = {},
1111
+ ): Promise<IFlexPromptQueueAdmission> {
1112
+ validatePromptOptions(options, false);
1113
+ const queued = await this.enqueuePromptInternal(scopeId, sessionId, prompt, options);
1114
+ return queued.admission;
1025
1115
  }
1026
1116
 
1027
1117
  public async schedulePrompt(
@@ -1040,17 +1130,66 @@ export class FlexHarness<TScope = unknown> {
1040
1130
  `debounceMs must be an integer from 0 through ${maxScheduleDebounceMs}.`,
1041
1131
  );
1042
1132
  }
1043
- const admission = await this.admitPrompt(
1133
+ const queued = await this.enqueuePromptInternal(
1044
1134
  scopeId,
1045
1135
  sessionId,
1046
- normalizeFlexPrompt(prompt),
1136
+ prompt,
1047
1137
  options,
1048
1138
  scheduleKey,
1049
1139
  debounceMs,
1050
1140
  );
1141
+ const admission = await queued.started;
1051
1142
  return Object.freeze({ ...admission, scheduleKey });
1052
1143
  }
1053
1144
 
1145
+ public async getPromptQueueEntry(
1146
+ scopeId: string,
1147
+ sessionId: string,
1148
+ queueId: string,
1149
+ ): Promise<IFlexPromptQueueEntry> {
1150
+ validateIdentifier(queueId, 'queueId');
1151
+ requireTransferIdentifier(queueId, 'queueId');
1152
+ const { state } = await this.resolveState(scopeId);
1153
+ this.assertStateAcceptingWork(state);
1154
+ const stored = this.requireSession(state, sessionId);
1155
+ const entry = this.promptQueueEntry(stored, queueId);
1156
+ if (!entry) throw new FlexHarnessNotFoundError('Prompt queue entry', queueId);
1157
+ return publicSnapshot(entry);
1158
+ }
1159
+
1160
+ public async listPromptQueueEntries(
1161
+ scopeId: string,
1162
+ sessionId: string,
1163
+ ): Promise<IFlexPromptQueueEntry[]> {
1164
+ const { state } = await this.resolveState(scopeId);
1165
+ this.assertStateAcceptingWork(state);
1166
+ const stored = this.requireSession(state, sessionId);
1167
+ return publicSnapshot([
1168
+ ...[...stored.outstandingPromptsById.values()].map((entry) => this.projectPromptQueueEntry(entry)),
1169
+ ...stored.terminalPromptQueueEntries.values(),
1170
+ ].sort((left, right) => left.queueSequence - right.queueSequence));
1171
+ }
1172
+
1173
+ public async cancelPrompt(
1174
+ scopeId: string,
1175
+ sessionId: string,
1176
+ queueId: string,
1177
+ ): Promise<boolean> {
1178
+ validateIdentifier(queueId, 'queueId');
1179
+ requireTransferIdentifier(queueId, 'queueId');
1180
+ const { state } = await this.resolveState(scopeId);
1181
+ const stored = this.requireSession(state, sessionId);
1182
+ const queued = stored.outstandingPromptsById.get(queueId);
1183
+ if (!queued) {
1184
+ if (stored.terminalPromptQueueEntries.has(queueId)) return false;
1185
+ throw new FlexHarnessNotFoundError('Prompt queue entry', queueId);
1186
+ }
1187
+ return this.cancelQueuedPrompt(
1188
+ queued,
1189
+ this.trustInternalError(new FlexHarnessAbortError('The queued prompt was cancelled.')),
1190
+ );
1191
+ }
1192
+
1054
1193
  public async cancelScheduledPrompt(
1055
1194
  scopeId: string,
1056
1195
  sessionId: string,
@@ -1058,18 +1197,12 @@ export class FlexHarness<TScope = unknown> {
1058
1197
  ): Promise<boolean> {
1059
1198
  validateIdentifier(scheduleKey, 'scheduleKey');
1060
1199
  const { state } = await this.resolveState(scopeId);
1061
- const run = state.activeRuns.get(sessionId);
1062
- if (!run || run.scheduleKey !== scheduleKey || run.phase === 'finalizing' || run.phase === 'promoting') {
1063
- return false;
1064
- }
1200
+ const stored = this.requireSession(state, sessionId);
1201
+ const queued = [...stored.outstandingPromptsById.values()]
1202
+ .find((entry) => entry.scheduleKey === scheduleKey);
1203
+ if (!queued) return false;
1065
1204
  const cancellation = this.trustInternalError(new FlexHarnessAbortError('The scheduled run was cancelled.'));
1066
- if (run.internalFailure === undefined) run.ownerCancellation ??= cancellation;
1067
- this.rejectRunPermissions(state, run, cancellation);
1068
- if (!run.controller.signal.aborted) run.controller.abort(cancellation);
1069
- run.stored.agentSession.cancelScheduledGeneration(scheduleKey, cancellation);
1070
- const settled = await Promise.allSettled([run.completion]);
1071
- if (settled[0].status === 'rejected' && !isAbortError(settled[0].reason)) throw settled[0].reason;
1072
- return true;
1205
+ return this.cancelQueuedPrompt(queued, cancellation);
1073
1206
  }
1074
1207
 
1075
1208
  public async abort(scopeId: string, sessionId: string): Promise<boolean> {
@@ -1321,45 +1454,199 @@ export class FlexHarness<TScope = unknown> {
1321
1454
  return disposal;
1322
1455
  }
1323
1456
 
1324
- private async admitPrompt(
1457
+ private async enqueuePromptInternal(
1325
1458
  scopeId: string,
1326
1459
  sessionId: string,
1327
- prompt: INormalizedFlexPrompt,
1460
+ prompt: TFlexPrompt,
1328
1461
  options: IFlexPromptOptions,
1329
1462
  scheduleKey?: string,
1330
1463
  debounceMs?: number,
1331
- ): Promise<IFlexPromptAdmission> {
1332
- const resolved = await this.resolveState(scopeId);
1333
- const state = resolved.state;
1334
- await state.scopeQueue;
1335
- this.assertStateAcceptingWork(state);
1336
- const stored = this.requireSession(state, sessionId);
1337
- await stored.projectionQueue;
1338
- if (state.activeRuns.has(sessionId)) {
1339
- const active = state.activeRuns.get(sessionId)!;
1340
- const reason = scheduleKey && active.scheduleKey === scheduleKey
1341
- ? `already has scheduled key "${scheduleKey}"`
1342
- : 'already has an active run';
1343
- throw new FlexHarnessSessionBusyError(sessionId, reason);
1344
- }
1345
- let resolveCompletion!: (result: IFlexPromptResult) => void;
1346
- let rejectCompletion!: (error: unknown) => void;
1347
- const completion = new Promise<IFlexPromptResult>((resolve, reject) => {
1348
- resolveCompletion = resolve;
1349
- rejectCompletion = reject;
1464
+ ): Promise<{ admission: IFlexPromptQueueAdmission; started: Promise<IFlexPromptAdmission> }> {
1465
+ this.assertOpen();
1466
+ const normalizedPrompt = normalizeFlexPrompt(prompt);
1467
+ const normalizedOptions = cloneSerializable(options);
1468
+ const byteSize = jsonBytes({
1469
+ prompt: normalizedPrompt,
1470
+ options: normalizedOptions,
1471
+ scheduleKey,
1472
+ debounceMs,
1350
1473
  });
1351
- void completion.catch(() => undefined);
1352
- const run: IActiveRun = {
1353
- state,
1354
- stored,
1355
- scopeId,
1356
- scope: resolved.scope.scope,
1357
- sessionId,
1358
- runId: plugins.crypto.randomUUID(),
1474
+ const releasePendingAdmission = this.reservePendingPromptAdmission(byteSize);
1475
+ let resolveSettled!: () => void;
1476
+ const pendingOwner: IPendingPromptAdmission = {
1477
+ controller: new AbortController(),
1478
+ settled: new Promise<void>((resolve) => {
1479
+ resolveSettled = resolve;
1480
+ }),
1481
+ resolveSettled: () => resolveSettled(),
1482
+ };
1483
+ this.pendingPromptAdmissionOwners.add(pendingOwner);
1484
+ let abortAdmission!: () => void;
1485
+ const abortPromise = new Promise<never>((_resolve, reject) => {
1486
+ abortAdmission = () => reject(
1487
+ pendingOwner.controller.signal.reason ?? new FlexHarnessAbortError(),
1488
+ );
1489
+ pendingOwner.controller.signal.addEventListener('abort', abortAdmission, { once: true });
1490
+ if (pendingOwner.controller.signal.aborted) abortAdmission();
1491
+ });
1492
+ try {
1493
+ const resolved = await Promise.race([this.resolveState(scopeId), abortPromise]);
1494
+ const state = resolved.state;
1495
+ await Promise.race([state.scopeQueue, abortPromise]);
1496
+ this.assertStateAcceptingWork(state);
1497
+ const stored = this.requireSession(state, sessionId);
1498
+ if (stored.outstandingPromptsById.size >= this.promptQueueLimits.maxOutstandingPromptsPerSession) {
1499
+ throw new FlexHarnessQueueFullError(
1500
+ `Session "${sessionId}" has reached its outstanding prompt limit.`,
1501
+ );
1502
+ }
1503
+ if (
1504
+ stored.outstandingPromptBytes + byteSize
1505
+ > this.promptQueueLimits.maxOutstandingBytesPerSession
1506
+ ) {
1507
+ throw new FlexHarnessQueueFullError(
1508
+ `Session "${sessionId}" has reached its outstanding prompt byte limit.`,
1509
+ );
1510
+ }
1511
+ if (
1512
+ scheduleKey
1513
+ && [...stored.outstandingPromptsById.values()]
1514
+ .some((entry) => entry.scheduleKey === scheduleKey)
1515
+ ) {
1516
+ throw new FlexHarnessSessionBusyError(sessionId, `already has scheduled key "${scheduleKey}"`);
1517
+ }
1518
+ let resolveCompletion!: (result: IFlexPromptResult) => void;
1519
+ let rejectCompletion!: (error: unknown) => void;
1520
+ const completion = new Promise<IFlexPromptResult>((resolve, reject) => {
1521
+ resolveCompletion = resolve;
1522
+ rejectCompletion = reject;
1523
+ });
1524
+ void completion.catch(() => undefined);
1525
+ let resolveStarted!: (admission: IFlexPromptAdmission) => void;
1526
+ let rejectStarted!: (error: unknown) => void;
1527
+ const started = new Promise<IFlexPromptAdmission>((resolve, reject) => {
1528
+ resolveStarted = resolve;
1529
+ rejectStarted = reject;
1530
+ });
1531
+ void started.catch(() => undefined);
1532
+ const queued: IQueuedPrompt = {
1533
+ state,
1534
+ stored,
1535
+ scopeId,
1536
+ scope: resolved.scope.scope,
1537
+ sessionId,
1538
+ queueId: plugins.crypto.randomUUID(),
1539
+ queueSequence: ++this.promptQueueSequence,
1540
+ status: 'queued',
1541
+ queuedAt: new Date().toISOString(),
1542
+ ...(scheduleKey === undefined ? {} : { scheduleKey, debounceMs }),
1543
+ prompt: normalizedPrompt,
1544
+ options: normalizedOptions,
1545
+ byteSize,
1546
+ completion,
1547
+ resolveCompletion,
1548
+ rejectCompletion,
1549
+ started,
1550
+ resolveStarted,
1551
+ rejectStarted,
1552
+ };
1553
+ stored.promptQueue.push(queued);
1554
+ stored.outstandingPromptsById.set(queued.queueId, queued);
1555
+ stored.outstandingPromptBytes += byteSize;
1556
+ this.emitPromptQueueEvent(queued, 'prompt.queued');
1557
+ this.schedulePromptQueueDrain(state, stored);
1558
+ return {
1559
+ admission: Object.freeze({ queueId: queued.queueId, completion }),
1560
+ started,
1561
+ };
1562
+ } finally {
1563
+ pendingOwner.controller.signal.removeEventListener('abort', abortAdmission);
1564
+ this.pendingPromptAdmissionOwners.delete(pendingOwner);
1565
+ releasePendingAdmission();
1566
+ pendingOwner.resolveSettled();
1567
+ }
1568
+ }
1569
+
1570
+ private reservePendingPromptAdmission(byteSize: number): () => void {
1571
+ if (this.pendingPromptAdmissions >= this.promptQueueLimits.maxPendingAdmissions) {
1572
+ throw new FlexHarnessQueueFullError('FlexHarness has reached its pending prompt admission limit.');
1573
+ }
1574
+ if (
1575
+ this.pendingPromptAdmissionBytes + byteSize
1576
+ > this.promptQueueLimits.maxPendingAdmissionBytes
1577
+ ) {
1578
+ throw new FlexHarnessQueueFullError('FlexHarness has reached its pending prompt admission byte limit.');
1579
+ }
1580
+ this.pendingPromptAdmissions++;
1581
+ this.pendingPromptAdmissionBytes += byteSize;
1582
+ let released = false;
1583
+ return () => {
1584
+ if (released) return;
1585
+ released = true;
1586
+ this.pendingPromptAdmissions--;
1587
+ this.pendingPromptAdmissionBytes -= byteSize;
1588
+ };
1589
+ }
1590
+
1591
+ private schedulePromptQueueDrain(state: IStorageState, stored: IStoredSessionState): void {
1592
+ if (stored.promptQueueDrain) return;
1593
+ let drain!: Promise<void>;
1594
+ drain = Promise.resolve()
1595
+ .then(() => this.drainPromptQueue(state, stored))
1596
+ .finally(() => {
1597
+ if (stored.promptQueueDrain === drain) delete stored.promptQueueDrain;
1598
+ if (
1599
+ stored.promptQueue.length > 0
1600
+ && !state.activeRuns.has(stored.session.sessionId)
1601
+ && state.lifecycle === 'active'
1602
+ && state.sessions.get(stored.session.sessionId) === stored
1603
+ ) this.schedulePromptQueueDrain(state, stored);
1604
+ });
1605
+ stored.promptQueueDrain = drain;
1606
+ void drain.catch(() => undefined);
1607
+ }
1608
+
1609
+ private async drainPromptQueue(state: IStorageState, stored: IStoredSessionState): Promise<void> {
1610
+ while (
1611
+ state.lifecycle === 'active'
1612
+ && state.sessions.get(stored.session.sessionId) === stored
1613
+ && !state.activeRuns.has(stored.session.sessionId)
1614
+ ) {
1615
+ const queued = stored.promptQueue.shift();
1616
+ if (!queued) return;
1617
+ if (stored.outstandingPromptsById.get(queued.queueId) !== queued) continue;
1618
+ queued.status = 'starting';
1619
+ queued.runId = plugins.crypto.randomUUID();
1620
+ queued.startedAt = new Date().toISOString();
1621
+ const run = this.createActiveRun(queued);
1622
+ state.activeRuns.set(queued.sessionId, run);
1623
+ try {
1624
+ await this.promoteQueuedPrompt(queued, run);
1625
+ return;
1626
+ } catch (error) {
1627
+ if (state.activeRuns.get(queued.sessionId) === run) state.activeRuns.delete(queued.sessionId);
1628
+ const cancellation = isAbortError(error);
1629
+ this.finishQueuedPrompt(queued, cancellation ? 'cancelled' : 'failed', error);
1630
+ if (state.lifecycle !== 'active') return;
1631
+ }
1632
+ }
1633
+ }
1634
+
1635
+ private createActiveRun(queued: IQueuedPrompt): IActiveRun {
1636
+ return {
1637
+ state: queued.state,
1638
+ stored: queued.stored,
1639
+ scopeId: queued.scopeId,
1640
+ scope: queued.scope,
1641
+ sessionId: queued.sessionId,
1642
+ queueId: queued.queueId,
1643
+ runId: queued.runId!,
1359
1644
  userMessageId: plugins.crypto.randomUUID(),
1360
1645
  assistantMessageId: plugins.crypto.randomUUID(),
1361
1646
  controller: new AbortController(),
1362
- ...(scheduleKey === undefined ? {} : { scheduleKey, debounceMs }),
1647
+ ...(queued.scheduleKey === undefined
1648
+ ? {}
1649
+ : { scheduleKey: queued.scheduleKey, debounceMs: queued.debounceMs }),
1363
1650
  callbackEventCount: 0,
1364
1651
  callbackOutputBytes: 0,
1365
1652
  callbackParts: [],
@@ -1368,40 +1655,59 @@ export class FlexHarness<TScope = unknown> {
1368
1655
  toolPartIds: new Map(),
1369
1656
  pendingPermissionIds: new Set(),
1370
1657
  phase: 'admitting',
1371
- completion,
1372
- resolveCompletion,
1373
- rejectCompletion,
1658
+ completion: queued.completion,
1374
1659
  };
1375
- state.activeRuns.set(sessionId, run);
1660
+ }
1661
+
1662
+ private assertPromptPromotion(queued: IQueuedPrompt, run: IActiveRun): void {
1663
+ if (run.controller.signal.aborted) {
1664
+ throw run.controller.signal.reason ?? new FlexHarnessAbortError();
1665
+ }
1666
+ this.assertStateAcceptingWork(run.state);
1667
+ if (
1668
+ run.state.sessions.get(run.sessionId) !== run.stored
1669
+ || run.stored.outstandingPromptsById.get(run.queueId) !== queued
1670
+ || run.state.activeRuns.get(run.sessionId) !== run
1671
+ ) throw this.trustInternalError(new FlexHarnessAbortError('The prompt was retired during admission.'));
1672
+ }
1673
+
1674
+ private async promoteQueuedPrompt(queued: IQueuedPrompt, run: IActiveRun): Promise<void> {
1675
+ const prompt = queued.prompt!;
1676
+ const options = queued.options!;
1376
1677
  let projectionReserved = false;
1377
1678
  try {
1378
- run.transaction = await stored.agentSession.beginGeneration(
1679
+ run.transaction = await run.stored.agentSession.beginGeneration(
1379
1680
  cloneSerializable(prompt.modelMessage.content) as Parameters<
1380
1681
  plugins.IAgentSession['beginGeneration']
1381
1682
  >[0],
1382
1683
  { generationId: run.runId },
1383
1684
  );
1685
+ this.assertPromptPromotion(queued, run);
1384
1686
  const reservation = this.createReservation(run, prompt);
1385
- await this.mutateProjection(state, stored, () => {
1386
- stored.messages.push(reservation.userMessage, reservation.assistantMessage);
1687
+ await this.mutateProjection(run.state, run.stored, () => {
1688
+ run.stored.messages.push(reservation.userMessage, reservation.assistantMessage);
1387
1689
  });
1388
1690
  projectionReserved = true;
1389
1691
  run.reservedUserMessage = publicSnapshot(reservation.userMessage);
1390
1692
  run.reservedAssistantMessage = publicSnapshot(reservation.assistantMessage);
1391
- await this.mutateScope(state, () => {
1392
- if (state.tombstones.has(sessionId) || state.sessions.get(sessionId) !== stored) {
1693
+ this.assertPromptPromotion(queued, run);
1694
+ await this.mutateScope(run.state, () => {
1695
+ if (run.controller.signal.aborted) {
1696
+ throw run.controller.signal.reason ?? new FlexHarnessAbortError();
1697
+ }
1698
+ if (run.state.tombstones.has(run.sessionId) || run.state.sessions.get(run.sessionId) !== run.stored) {
1393
1699
  throw new FlexHarnessAbortError('The session was deleted during prompt admission.');
1394
1700
  }
1395
- stored.session.status = scheduleKey ? 'scheduled' : 'running';
1396
- stored.session.activity = {
1701
+ run.stored.session.status = run.scheduleKey ? 'scheduled' : 'running';
1702
+ run.stored.session.activity = {
1397
1703
  runId: run.runId,
1398
- status: scheduleKey ? 'scheduled' : 'running',
1704
+ status: run.scheduleKey ? 'scheduled' : 'running',
1399
1705
  startedAt: reservation.userMessage.createdAt,
1400
1706
  };
1401
- stored.session.updatedAt = reservation.userMessage.createdAt;
1707
+ run.stored.session.updatedAt = reservation.userMessage.createdAt;
1402
1708
  });
1403
1709
  } catch (error) {
1404
- if (containsStoreCommitUncertainty(error)) state.lifecycle = 'fenced';
1710
+ if (containsStoreCommitUncertainty(error)) run.state.lifecycle = 'fenced';
1405
1711
  const safeError = this.projectExternalError(run, error, projectionReserved ? 'persistence' : 'agentSession');
1406
1712
  let cleanupErrors: unknown[] = [];
1407
1713
  let cleanupFailure: unknown;
@@ -1410,30 +1716,28 @@ export class FlexHarness<TScope = unknown> {
1410
1716
  } catch (rollbackError) {
1411
1717
  cleanupFailure = rollbackError;
1412
1718
  }
1413
- if (state.lifecycle === 'fenced' && cleanupFailure === undefined) {
1719
+ if (run.state.lifecycle === 'fenced' && cleanupFailure === undefined) {
1414
1720
  const combined = combineErrors([safeError, ...cleanupErrors]);
1415
1721
  try {
1416
- await this.fenceNamespace(state, run, combined);
1722
+ await this.fenceNamespace(run.state, run, combined);
1417
1723
  } catch (fenceError) {
1418
1724
  cleanupFailure = fenceError;
1419
1725
  }
1420
1726
  }
1421
- if (state.activeRuns.get(sessionId) === run) state.activeRuns.delete(sessionId);
1422
1727
  const rejection = cleanupFailure
1423
1728
  ?? (cleanupErrors.length > 0 ? combineErrors([safeError, ...cleanupErrors]) : safeError);
1424
- run.rejectCompletion(rejection);
1425
1729
  throw rejection;
1426
1730
  }
1427
1731
 
1428
- const runType = scheduleKey ? 'run.scheduled' : 'run.started';
1429
- this.emitEvent(scopeId, sessionId, {
1732
+ const runType = run.scheduleKey ? 'run.scheduled' : 'run.started';
1733
+ this.emitEvent(run.scopeId, run.sessionId, {
1430
1734
  type: runType,
1431
1735
  runId: run.runId,
1432
1736
  messageId: run.userMessageId,
1433
- session: publicSnapshot(stored.session),
1737
+ session: publicSnapshot(run.stored.session),
1434
1738
  });
1435
1739
  for (const message of [run.reservedUserMessage!, run.reservedAssistantMessage!]) {
1436
- this.emitEvent(scopeId, sessionId, {
1740
+ this.emitEvent(run.scopeId, run.sessionId, {
1437
1741
  type: 'message.created',
1438
1742
  runId: run.runId,
1439
1743
  messageId: message.messageId,
@@ -1441,22 +1745,144 @@ export class FlexHarness<TScope = unknown> {
1441
1745
  });
1442
1746
  }
1443
1747
 
1444
- run.phase = scheduleKey ? 'scheduled' : 'running';
1748
+ queued.status = run.scheduleKey ? 'scheduled' : 'starting';
1749
+ this.emitPromptQueueEvent(queued, 'prompt.started');
1750
+ queued.resolveStarted(Object.freeze({
1751
+ queueId: queued.queueId,
1752
+ runId: run.runId,
1753
+ completion: queued.completion,
1754
+ }));
1755
+ run.phase = run.scheduleKey ? 'scheduled' : 'running';
1445
1756
  const generateOptions = {
1446
1757
  transaction: run.transaction,
1447
1758
  abort: run.controller.signal,
1448
1759
  prepare: (context: { generationId: string; abortSignal: AbortSignal }) =>
1449
1760
  this.prepareGeneration(run, options, context.abortSignal),
1450
1761
  };
1451
- const generation = scheduleKey
1452
- ? stored.agentSession.scheduleGenerate({
1453
- key: scheduleKey,
1454
- debounceMs,
1762
+ const generation = run.scheduleKey
1763
+ ? run.stored.agentSession.scheduleGenerate({
1764
+ key: run.scheduleKey,
1765
+ debounceMs: run.debounceMs,
1455
1766
  ...generateOptions,
1456
1767
  })
1457
- : stored.agentSession.generate(generateOptions);
1458
- void this.executeRun(run, generation).then(run.resolveCompletion, run.rejectCompletion);
1459
- return Object.freeze({ runId: run.runId, completion: run.completion });
1768
+ : run.stored.agentSession.generate(generateOptions);
1769
+ const execution = this.executeRun(run, generation).then(
1770
+ (result) => this.finishQueuedPrompt(queued, 'completed', undefined, result),
1771
+ (error) => this.finishQueuedPrompt(
1772
+ queued,
1773
+ isAbortError(error) ? 'cancelled' : 'failed',
1774
+ error,
1775
+ ),
1776
+ ).finally(() => this.schedulePromptQueueDrain(run.state, run.stored));
1777
+ void execution.catch(() => undefined);
1778
+ }
1779
+
1780
+ private projectPromptQueueEntry(queued: IQueuedPrompt): IFlexPromptQueueEntry {
1781
+ return {
1782
+ queueId: queued.queueId,
1783
+ queueSequence: queued.queueSequence,
1784
+ scopeId: queued.scopeId,
1785
+ sessionId: queued.sessionId,
1786
+ status: queued.status,
1787
+ queuedAt: queued.queuedAt,
1788
+ ...(queued.runId ? { runId: queued.runId } : {}),
1789
+ ...(queued.scheduleKey ? { scheduleKey: queued.scheduleKey } : {}),
1790
+ ...(queued.startedAt ? { startedAt: queued.startedAt } : {}),
1791
+ };
1792
+ }
1793
+
1794
+ private promptQueueEntry(
1795
+ stored: IStoredSessionState,
1796
+ queueId: string,
1797
+ ): IFlexPromptQueueEntry | undefined {
1798
+ const outstanding = stored.outstandingPromptsById.get(queueId);
1799
+ return outstanding
1800
+ ? this.projectPromptQueueEntry(outstanding)
1801
+ : stored.terminalPromptQueueEntries.get(queueId);
1802
+ }
1803
+
1804
+ private emitPromptQueueEvent(
1805
+ queued: IQueuedPrompt,
1806
+ type: 'prompt.queued' | 'prompt.started' | 'prompt.running' | 'prompt.finished',
1807
+ entry: IFlexPromptQueueEntry = this.projectPromptQueueEntry(queued),
1808
+ ): void {
1809
+ this.emitEvent(queued.scopeId, queued.sessionId, {
1810
+ type,
1811
+ queueId: queued.queueId,
1812
+ ...(entry.runId ? { runId: entry.runId } : {}),
1813
+ entry: publicSnapshot(entry),
1814
+ });
1815
+ }
1816
+
1817
+ private finishQueuedPrompt(
1818
+ queued: IQueuedPrompt,
1819
+ status: 'completed' | 'failed' | 'cancelled',
1820
+ error?: unknown,
1821
+ result?: IFlexPromptResult,
1822
+ ): void {
1823
+ const stored = queued.stored;
1824
+ if (stored.outstandingPromptsById.get(queued.queueId) !== queued) return;
1825
+ stored.promptQueue = stored.promptQueue.filter((entry) => entry !== queued);
1826
+ stored.outstandingPromptsById.delete(queued.queueId);
1827
+ stored.outstandingPromptBytes = Math.max(0, stored.outstandingPromptBytes - queued.byteSize);
1828
+ delete queued.prompt;
1829
+ delete queued.options;
1830
+ const terminal: IFlexPromptQueueEntry = {
1831
+ ...this.projectPromptQueueEntry(queued),
1832
+ status,
1833
+ finishedAt: new Date().toISOString(),
1834
+ ...(error === undefined ? {} : { error: errorToInfo(error) }),
1835
+ };
1836
+ stored.terminalPromptQueueEntries.set(queued.queueId, terminal);
1837
+ while (
1838
+ stored.terminalPromptQueueEntries.size
1839
+ > this.promptQueueLimits.maxTerminalEntriesPerSession
1840
+ ) {
1841
+ const oldest = stored.terminalPromptQueueEntries.keys().next().value as string | undefined;
1842
+ if (!oldest) break;
1843
+ stored.terminalPromptQueueEntries.delete(oldest);
1844
+ }
1845
+ this.emitPromptQueueEvent(queued, 'prompt.finished', terminal);
1846
+ if (status === 'completed') {
1847
+ if (!result) throw new Error('A completed queued prompt requires a result.');
1848
+ queued.resolveCompletion(result);
1849
+ } else {
1850
+ const rejection = error ?? this.trustInternalError(new FlexHarnessAbortError());
1851
+ queued.rejectStarted(rejection);
1852
+ queued.rejectCompletion(rejection);
1853
+ }
1854
+ }
1855
+
1856
+ private cancelQueuedPrompt(queued: IQueuedPrompt, reason: FlexHarnessAbortError): boolean {
1857
+ if (queued.stored.outstandingPromptsById.get(queued.queueId) !== queued) return false;
1858
+ if (queued.status === 'queued') {
1859
+ this.finishQueuedPrompt(queued, 'cancelled', reason);
1860
+ this.schedulePromptQueueDrain(queued.state, queued.stored);
1861
+ return true;
1862
+ }
1863
+ const run = queued.state.activeRuns.get(queued.sessionId);
1864
+ if (!run || run.queueId !== queued.queueId) return false;
1865
+ if (run.phase === 'finalizing' || run.phase === 'promoting') return false;
1866
+ this.cancelRun(run, reason);
1867
+ return true;
1868
+ }
1869
+
1870
+ private cancelStoredPromptQueue(
1871
+ stored: IStoredSessionState,
1872
+ reason: FlexHarnessAbortError,
1873
+ excludedQueueId?: string,
1874
+ ): void {
1875
+ for (const queued of [...stored.outstandingPromptsById.values()]) {
1876
+ if (queued.queueId === excludedQueueId) continue;
1877
+ this.cancelQueuedPrompt(queued, reason);
1878
+ }
1879
+ }
1880
+
1881
+ private purgeStoredPromptQueue(stored: IStoredSessionState): void {
1882
+ stored.promptQueue.length = 0;
1883
+ stored.outstandingPromptsById.clear();
1884
+ stored.terminalPromptQueueEntries.clear();
1885
+ stored.outstandingPromptBytes = 0;
1460
1886
  }
1461
1887
 
1462
1888
  private async executeRun(
@@ -1597,7 +2023,13 @@ export class FlexHarness<TScope = unknown> {
1597
2023
  options: IFlexPromptOptions,
1598
2024
  signal: AbortSignal,
1599
2025
  ): Promise<plugins.IAgentGenerationLease> {
2026
+ signal.throwIfAborted();
1600
2027
  run.phase = 'running';
2028
+ const queued = run.stored.outstandingPromptsById.get(run.queueId);
2029
+ if (queued && (queued.status === 'starting' || queued.status === 'scheduled')) {
2030
+ queued.status = 'running';
2031
+ this.emitPromptQueueEvent(queued, 'prompt.running');
2032
+ }
1601
2033
  const modelOutcome = Promise.resolve()
1602
2034
  .then(() => this.modelResolver.resolveModel({
1603
2035
  scopeId: run.scopeId,
@@ -2390,6 +2822,7 @@ export class FlexHarness<TScope = unknown> {
2390
2822
  if (run.internalFailure === undefined) run.ownerCancellation ??= reason;
2391
2823
  this.rejectRunPermissions(run.state, run, reason);
2392
2824
  if (!run.controller.signal.aborted) run.controller.abort(reason);
2825
+ if (run.phase === 'admitting') return;
2393
2826
  if (run.scheduleKey) run.stored.agentSession.cancelScheduledGeneration(run.scheduleKey, reason);
2394
2827
  else run.stored.agentSession.abortCurrentGeneration(reason);
2395
2828
  }
@@ -2731,6 +3164,10 @@ export class FlexHarness<TScope = unknown> {
2731
3164
  agentEventStoreReleased: true,
2732
3165
  executionContextCloseCompleted: true,
2733
3166
  jobStoreReleased: true,
3167
+ promptQueue: [],
3168
+ outstandingPromptsById: new Map(),
3169
+ terminalPromptQueueEntries: new Map(),
3170
+ outstandingPromptBytes: 0,
2734
3171
  };
2735
3172
  }
2736
3173
 
@@ -2813,6 +3250,10 @@ export class FlexHarness<TScope = unknown> {
2813
3250
  executionContextCloseCompleted: executionContextHandle?.close === undefined,
2814
3251
  jobStoreReleased: this.stores.jobs.releaseSession === undefined,
2815
3252
  jobs: executionContextHandle?.context.jobs,
3253
+ promptQueue: [],
3254
+ outstandingPromptsById: new Map(),
3255
+ terminalPromptQueueEntries: new Map(),
3256
+ outstandingPromptBytes: 0,
2816
3257
  };
2817
3258
  const agentSessionOptions: plugins.IAgentSessionOptions & {
2818
3259
  transactionOutcomeErrorProjector: (error: unknown) => string;
@@ -3325,6 +3766,7 @@ export class FlexHarness<TScope = unknown> {
3325
3766
  if (retained) {
3326
3767
  const errors: unknown[] = [];
3327
3768
  const reason = this.trustInternalError(new FlexHarnessAbortError('The session was deleted.'));
3769
+ this.cancelStoredPromptQueue(retained.stored, reason);
3328
3770
  const run = state.activeRuns.get(sessionId);
3329
3771
  if (run) this.cancelRun(run, reason);
3330
3772
  if (!retained.stored.agentSessionAbortCompleted) {
@@ -3346,6 +3788,10 @@ export class FlexHarness<TScope = unknown> {
3346
3788
  errors.push(settled[0].reason);
3347
3789
  }
3348
3790
  }
3791
+ if (retained.stored.promptQueueDrain) {
3792
+ const settled = await Promise.allSettled([retained.stored.promptQueueDrain]);
3793
+ if (settled[0].status === 'rejected') errors.push(settled[0].reason);
3794
+ }
3349
3795
  try {
3350
3796
  await this.closeStoredSession(retained.stored);
3351
3797
  } catch (error) {
@@ -3358,6 +3804,7 @@ export class FlexHarness<TScope = unknown> {
3358
3804
  ));
3359
3805
  }
3360
3806
  if (errors.length > 0) throw combineErrors(errors);
3807
+ this.purgeStoredPromptQueue(retained.stored);
3361
3808
  }
3362
3809
  if (!retained?.domainsCompleted) {
3363
3810
  if (this.hasOrphanedSessionResources(state.storageKey, sessionId)) {
@@ -3384,6 +3831,13 @@ export class FlexHarness<TScope = unknown> {
3384
3831
  const reason = this.trustInternalError(new FlexHarnessAbortError('The session namespace was fenced.'));
3385
3832
  const cleanupErrors: unknown[] = [];
3386
3833
  try {
3834
+ for (const stored of state.sessions.values()) {
3835
+ this.cancelStoredPromptQueue(
3836
+ stored,
3837
+ reason,
3838
+ stored === currentRun.stored ? currentRun.queueId : undefined,
3839
+ );
3840
+ }
3387
3841
  const otherRuns = [...state.activeRuns.values()].filter((run) => run !== currentRun);
3388
3842
  for (const run of otherRuns) this.cancelRun(run, reason);
3389
3843
  const runResults = await Promise.allSettled(otherRuns.map((run) => run.completion));
@@ -3432,6 +3886,7 @@ export class FlexHarness<TScope = unknown> {
3432
3886
  ));
3433
3887
  }
3434
3888
  }
3889
+ if (stored !== currentRun.stored) this.purgeStoredPromptQueue(stored);
3435
3890
  }
3436
3891
  cleanupErrors.push(...await this.settleDetachedCleanups(state, detachedCleanupAttempts));
3437
3892
  state.fenceAdditionalErrors.length = 0;
@@ -3677,6 +4132,7 @@ export class FlexHarness<TScope = unknown> {
3677
4132
  if (state.lifecycle !== 'fenced') state.lifecycle = 'retiring';
3678
4133
  const detachedCleanupAttempts = this.beginDetachedCleanupSettlement(state);
3679
4134
  const errors: unknown[] = [];
4135
+ for (const stored of state.sessions.values()) this.cancelStoredPromptQueue(stored, reason);
3680
4136
  for (const run of state.activeRuns.values()) {
3681
4137
  if (run.phase !== 'finalizing' && run.phase !== 'promoting') this.cancelRun(run, reason);
3682
4138
  }
@@ -3704,6 +4160,7 @@ export class FlexHarness<TScope = unknown> {
3704
4160
  await Promise.all([...state.sessions.values()].flatMap((stored) => [
3705
4161
  stored.projectionQueue,
3706
4162
  stored.permissionQueue,
4163
+ ...(stored.promptQueueDrain ? [stored.promptQueueDrain] : []),
3707
4164
  ]));
3708
4165
  const tombstoneAttempts = [...state.tombstoneCleanups.entries()];
3709
4166
  const tombstoneResults = await Promise.allSettled(
@@ -3726,6 +4183,7 @@ export class FlexHarness<TScope = unknown> {
3726
4183
  'lifecycle-close',
3727
4184
  ));
3728
4185
  }
4186
+ this.purgeStoredPromptQueue(stored);
3729
4187
  }
3730
4188
  for (const sessionId of [...state.tombstones.keys()]) {
3731
4189
  if (attemptedTombstones.has(sessionId)) continue;
@@ -3753,6 +4211,14 @@ export class FlexHarness<TScope = unknown> {
3753
4211
  }
3754
4212
 
3755
4213
  private async disposeInternal(): Promise<void> {
4214
+ const pendingAdmissions = [...this.pendingPromptAdmissionOwners];
4215
+ const pendingAdmissionReason = this.trustInternalError(new FlexHarnessAbortError(
4216
+ 'The prompt admission was aborted because FlexHarness was disposed.',
4217
+ ));
4218
+ for (const pending of pendingAdmissions) {
4219
+ if (!pending.controller.signal.aborted) pending.controller.abort(pendingAdmissionReason);
4220
+ }
4221
+ await Promise.all(pendingAdmissions.map((pending) => pending.settled));
3756
4222
  const loads = [...this.stateLoads.entries()];
3757
4223
  const results = await Promise.allSettled(loads.map(([storageKey, stateLoad]) =>
3758
4224
  this.drainStorage(
@@ -3878,10 +4344,10 @@ export class FlexHarness<TScope = unknown> {
3878
4344
  const stored = this.requireSession(state, sessionId);
3879
4345
  const pending = [...state.pendingPermissions.values()].some((entry) =>
3880
4346
  !entry.settled && entry.request.sessionId === sessionId);
3881
- if (state.activeRuns.has(sessionId) || pending) {
4347
+ if (stored.outstandingPromptsById.size > 0 || pending) {
3882
4348
  throw new FlexHarnessSessionBusyError(
3883
4349
  sessionId,
3884
- 'cannot be changed while it has an active run or pending permission',
4350
+ 'cannot be changed while it has an outstanding prompt or pending permission',
3885
4351
  );
3886
4352
  }
3887
4353
  return stored;