@modelprofile.com/flexharness 3.0.1 → 3.2.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
 
@@ -1021,7 +1097,19 @@ export class FlexHarness<TScope = unknown> {
1021
1097
  options: IFlexPromptOptions = {},
1022
1098
  ): Promise<IFlexPromptAdmission> {
1023
1099
  validatePromptOptions(options, false);
1024
- return this.admitPrompt(scopeId, sessionId, normalizeFlexPrompt(prompt), options);
1100
+ const queued = await this.enqueuePromptInternal(scopeId, sessionId, prompt, options);
1101
+ return queued.started;
1102
+ }
1103
+
1104
+ public async enqueuePrompt(
1105
+ scopeId: string,
1106
+ sessionId: string,
1107
+ prompt: TFlexPrompt,
1108
+ options: IFlexPromptOptions = {},
1109
+ ): Promise<IFlexPromptQueueAdmission> {
1110
+ validatePromptOptions(options, false);
1111
+ const queued = await this.enqueuePromptInternal(scopeId, sessionId, prompt, options);
1112
+ return queued.admission;
1025
1113
  }
1026
1114
 
1027
1115
  public async schedulePrompt(
@@ -1040,17 +1128,66 @@ export class FlexHarness<TScope = unknown> {
1040
1128
  `debounceMs must be an integer from 0 through ${maxScheduleDebounceMs}.`,
1041
1129
  );
1042
1130
  }
1043
- const admission = await this.admitPrompt(
1131
+ const queued = await this.enqueuePromptInternal(
1044
1132
  scopeId,
1045
1133
  sessionId,
1046
- normalizeFlexPrompt(prompt),
1134
+ prompt,
1047
1135
  options,
1048
1136
  scheduleKey,
1049
1137
  debounceMs,
1050
1138
  );
1139
+ const admission = await queued.started;
1051
1140
  return Object.freeze({ ...admission, scheduleKey });
1052
1141
  }
1053
1142
 
1143
+ public async getPromptQueueEntry(
1144
+ scopeId: string,
1145
+ sessionId: string,
1146
+ queueId: string,
1147
+ ): Promise<IFlexPromptQueueEntry> {
1148
+ validateIdentifier(queueId, 'queueId');
1149
+ requireTransferIdentifier(queueId, 'queueId');
1150
+ const { state } = await this.resolveState(scopeId);
1151
+ this.assertStateAcceptingWork(state);
1152
+ const stored = this.requireSession(state, sessionId);
1153
+ const entry = this.promptQueueEntry(stored, queueId);
1154
+ if (!entry) throw new FlexHarnessNotFoundError('Prompt queue entry', queueId);
1155
+ return publicSnapshot(entry);
1156
+ }
1157
+
1158
+ public async listPromptQueueEntries(
1159
+ scopeId: string,
1160
+ sessionId: string,
1161
+ ): Promise<IFlexPromptQueueEntry[]> {
1162
+ const { state } = await this.resolveState(scopeId);
1163
+ this.assertStateAcceptingWork(state);
1164
+ const stored = this.requireSession(state, sessionId);
1165
+ return publicSnapshot([
1166
+ ...[...stored.outstandingPromptsById.values()].map((entry) => this.projectPromptQueueEntry(entry)),
1167
+ ...stored.terminalPromptQueueEntries.values(),
1168
+ ].sort((left, right) => left.queueSequence - right.queueSequence));
1169
+ }
1170
+
1171
+ public async cancelPrompt(
1172
+ scopeId: string,
1173
+ sessionId: string,
1174
+ queueId: string,
1175
+ ): Promise<boolean> {
1176
+ validateIdentifier(queueId, 'queueId');
1177
+ requireTransferIdentifier(queueId, 'queueId');
1178
+ const { state } = await this.resolveState(scopeId);
1179
+ const stored = this.requireSession(state, sessionId);
1180
+ const queued = stored.outstandingPromptsById.get(queueId);
1181
+ if (!queued) {
1182
+ if (stored.terminalPromptQueueEntries.has(queueId)) return false;
1183
+ throw new FlexHarnessNotFoundError('Prompt queue entry', queueId);
1184
+ }
1185
+ return this.cancelQueuedPrompt(
1186
+ queued,
1187
+ this.trustInternalError(new FlexHarnessAbortError('The queued prompt was cancelled.')),
1188
+ );
1189
+ }
1190
+
1054
1191
  public async cancelScheduledPrompt(
1055
1192
  scopeId: string,
1056
1193
  sessionId: string,
@@ -1058,18 +1195,12 @@ export class FlexHarness<TScope = unknown> {
1058
1195
  ): Promise<boolean> {
1059
1196
  validateIdentifier(scheduleKey, 'scheduleKey');
1060
1197
  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
- }
1198
+ const stored = this.requireSession(state, sessionId);
1199
+ const queued = [...stored.outstandingPromptsById.values()]
1200
+ .find((entry) => entry.scheduleKey === scheduleKey);
1201
+ if (!queued) return false;
1065
1202
  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;
1203
+ return this.cancelQueuedPrompt(queued, cancellation);
1073
1204
  }
1074
1205
 
1075
1206
  public async abort(scopeId: string, sessionId: string): Promise<boolean> {
@@ -1321,45 +1452,199 @@ export class FlexHarness<TScope = unknown> {
1321
1452
  return disposal;
1322
1453
  }
1323
1454
 
1324
- private async admitPrompt(
1455
+ private async enqueuePromptInternal(
1325
1456
  scopeId: string,
1326
1457
  sessionId: string,
1327
- prompt: INormalizedFlexPrompt,
1458
+ prompt: TFlexPrompt,
1328
1459
  options: IFlexPromptOptions,
1329
1460
  scheduleKey?: string,
1330
1461
  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;
1462
+ ): Promise<{ admission: IFlexPromptQueueAdmission; started: Promise<IFlexPromptAdmission> }> {
1463
+ this.assertOpen();
1464
+ const normalizedPrompt = normalizeFlexPrompt(prompt);
1465
+ const normalizedOptions = cloneSerializable(options);
1466
+ const byteSize = jsonBytes({
1467
+ prompt: normalizedPrompt,
1468
+ options: normalizedOptions,
1469
+ scheduleKey,
1470
+ debounceMs,
1350
1471
  });
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(),
1472
+ const releasePendingAdmission = this.reservePendingPromptAdmission(byteSize);
1473
+ let resolveSettled!: () => void;
1474
+ const pendingOwner: IPendingPromptAdmission = {
1475
+ controller: new AbortController(),
1476
+ settled: new Promise<void>((resolve) => {
1477
+ resolveSettled = resolve;
1478
+ }),
1479
+ resolveSettled: () => resolveSettled(),
1480
+ };
1481
+ this.pendingPromptAdmissionOwners.add(pendingOwner);
1482
+ let abortAdmission!: () => void;
1483
+ const abortPromise = new Promise<never>((_resolve, reject) => {
1484
+ abortAdmission = () => reject(
1485
+ pendingOwner.controller.signal.reason ?? new FlexHarnessAbortError(),
1486
+ );
1487
+ pendingOwner.controller.signal.addEventListener('abort', abortAdmission, { once: true });
1488
+ if (pendingOwner.controller.signal.aborted) abortAdmission();
1489
+ });
1490
+ try {
1491
+ const resolved = await Promise.race([this.resolveState(scopeId), abortPromise]);
1492
+ const state = resolved.state;
1493
+ await Promise.race([state.scopeQueue, abortPromise]);
1494
+ this.assertStateAcceptingWork(state);
1495
+ const stored = this.requireSession(state, sessionId);
1496
+ if (stored.outstandingPromptsById.size >= this.promptQueueLimits.maxOutstandingPromptsPerSession) {
1497
+ throw new FlexHarnessQueueFullError(
1498
+ `Session "${sessionId}" has reached its outstanding prompt limit.`,
1499
+ );
1500
+ }
1501
+ if (
1502
+ stored.outstandingPromptBytes + byteSize
1503
+ > this.promptQueueLimits.maxOutstandingBytesPerSession
1504
+ ) {
1505
+ throw new FlexHarnessQueueFullError(
1506
+ `Session "${sessionId}" has reached its outstanding prompt byte limit.`,
1507
+ );
1508
+ }
1509
+ if (
1510
+ scheduleKey
1511
+ && [...stored.outstandingPromptsById.values()]
1512
+ .some((entry) => entry.scheduleKey === scheduleKey)
1513
+ ) {
1514
+ throw new FlexHarnessSessionBusyError(sessionId, `already has scheduled key "${scheduleKey}"`);
1515
+ }
1516
+ let resolveCompletion!: (result: IFlexPromptResult) => void;
1517
+ let rejectCompletion!: (error: unknown) => void;
1518
+ const completion = new Promise<IFlexPromptResult>((resolve, reject) => {
1519
+ resolveCompletion = resolve;
1520
+ rejectCompletion = reject;
1521
+ });
1522
+ void completion.catch(() => undefined);
1523
+ let resolveStarted!: (admission: IFlexPromptAdmission) => void;
1524
+ let rejectStarted!: (error: unknown) => void;
1525
+ const started = new Promise<IFlexPromptAdmission>((resolve, reject) => {
1526
+ resolveStarted = resolve;
1527
+ rejectStarted = reject;
1528
+ });
1529
+ void started.catch(() => undefined);
1530
+ const queued: IQueuedPrompt = {
1531
+ state,
1532
+ stored,
1533
+ scopeId,
1534
+ scope: resolved.scope.scope,
1535
+ sessionId,
1536
+ queueId: plugins.crypto.randomUUID(),
1537
+ queueSequence: ++this.promptQueueSequence,
1538
+ status: 'queued',
1539
+ queuedAt: new Date().toISOString(),
1540
+ ...(scheduleKey === undefined ? {} : { scheduleKey, debounceMs }),
1541
+ prompt: normalizedPrompt,
1542
+ options: normalizedOptions,
1543
+ byteSize,
1544
+ completion,
1545
+ resolveCompletion,
1546
+ rejectCompletion,
1547
+ started,
1548
+ resolveStarted,
1549
+ rejectStarted,
1550
+ };
1551
+ stored.promptQueue.push(queued);
1552
+ stored.outstandingPromptsById.set(queued.queueId, queued);
1553
+ stored.outstandingPromptBytes += byteSize;
1554
+ this.emitPromptQueueEvent(queued, 'prompt.queued');
1555
+ this.schedulePromptQueueDrain(state, stored);
1556
+ return {
1557
+ admission: Object.freeze({ queueId: queued.queueId, completion }),
1558
+ started,
1559
+ };
1560
+ } finally {
1561
+ pendingOwner.controller.signal.removeEventListener('abort', abortAdmission);
1562
+ this.pendingPromptAdmissionOwners.delete(pendingOwner);
1563
+ releasePendingAdmission();
1564
+ pendingOwner.resolveSettled();
1565
+ }
1566
+ }
1567
+
1568
+ private reservePendingPromptAdmission(byteSize: number): () => void {
1569
+ if (this.pendingPromptAdmissions >= this.promptQueueLimits.maxPendingAdmissions) {
1570
+ throw new FlexHarnessQueueFullError('FlexHarness has reached its pending prompt admission limit.');
1571
+ }
1572
+ if (
1573
+ this.pendingPromptAdmissionBytes + byteSize
1574
+ > this.promptQueueLimits.maxPendingAdmissionBytes
1575
+ ) {
1576
+ throw new FlexHarnessQueueFullError('FlexHarness has reached its pending prompt admission byte limit.');
1577
+ }
1578
+ this.pendingPromptAdmissions++;
1579
+ this.pendingPromptAdmissionBytes += byteSize;
1580
+ let released = false;
1581
+ return () => {
1582
+ if (released) return;
1583
+ released = true;
1584
+ this.pendingPromptAdmissions--;
1585
+ this.pendingPromptAdmissionBytes -= byteSize;
1586
+ };
1587
+ }
1588
+
1589
+ private schedulePromptQueueDrain(state: IStorageState, stored: IStoredSessionState): void {
1590
+ if (stored.promptQueueDrain) return;
1591
+ let drain!: Promise<void>;
1592
+ drain = Promise.resolve()
1593
+ .then(() => this.drainPromptQueue(state, stored))
1594
+ .finally(() => {
1595
+ if (stored.promptQueueDrain === drain) delete stored.promptQueueDrain;
1596
+ if (
1597
+ stored.promptQueue.length > 0
1598
+ && !state.activeRuns.has(stored.session.sessionId)
1599
+ && state.lifecycle === 'active'
1600
+ && state.sessions.get(stored.session.sessionId) === stored
1601
+ ) this.schedulePromptQueueDrain(state, stored);
1602
+ });
1603
+ stored.promptQueueDrain = drain;
1604
+ void drain.catch(() => undefined);
1605
+ }
1606
+
1607
+ private async drainPromptQueue(state: IStorageState, stored: IStoredSessionState): Promise<void> {
1608
+ while (
1609
+ state.lifecycle === 'active'
1610
+ && state.sessions.get(stored.session.sessionId) === stored
1611
+ && !state.activeRuns.has(stored.session.sessionId)
1612
+ ) {
1613
+ const queued = stored.promptQueue.shift();
1614
+ if (!queued) return;
1615
+ if (stored.outstandingPromptsById.get(queued.queueId) !== queued) continue;
1616
+ queued.status = 'starting';
1617
+ queued.runId = plugins.crypto.randomUUID();
1618
+ queued.startedAt = new Date().toISOString();
1619
+ const run = this.createActiveRun(queued);
1620
+ state.activeRuns.set(queued.sessionId, run);
1621
+ try {
1622
+ await this.promoteQueuedPrompt(queued, run);
1623
+ return;
1624
+ } catch (error) {
1625
+ if (state.activeRuns.get(queued.sessionId) === run) state.activeRuns.delete(queued.sessionId);
1626
+ const cancellation = isAbortError(error);
1627
+ this.finishQueuedPrompt(queued, cancellation ? 'cancelled' : 'failed', error);
1628
+ if (state.lifecycle !== 'active') return;
1629
+ }
1630
+ }
1631
+ }
1632
+
1633
+ private createActiveRun(queued: IQueuedPrompt): IActiveRun {
1634
+ return {
1635
+ state: queued.state,
1636
+ stored: queued.stored,
1637
+ scopeId: queued.scopeId,
1638
+ scope: queued.scope,
1639
+ sessionId: queued.sessionId,
1640
+ queueId: queued.queueId,
1641
+ runId: queued.runId!,
1359
1642
  userMessageId: plugins.crypto.randomUUID(),
1360
1643
  assistantMessageId: plugins.crypto.randomUUID(),
1361
1644
  controller: new AbortController(),
1362
- ...(scheduleKey === undefined ? {} : { scheduleKey, debounceMs }),
1645
+ ...(queued.scheduleKey === undefined
1646
+ ? {}
1647
+ : { scheduleKey: queued.scheduleKey, debounceMs: queued.debounceMs }),
1363
1648
  callbackEventCount: 0,
1364
1649
  callbackOutputBytes: 0,
1365
1650
  callbackParts: [],
@@ -1368,40 +1653,59 @@ export class FlexHarness<TScope = unknown> {
1368
1653
  toolPartIds: new Map(),
1369
1654
  pendingPermissionIds: new Set(),
1370
1655
  phase: 'admitting',
1371
- completion,
1372
- resolveCompletion,
1373
- rejectCompletion,
1656
+ completion: queued.completion,
1374
1657
  };
1375
- state.activeRuns.set(sessionId, run);
1658
+ }
1659
+
1660
+ private assertPromptPromotion(queued: IQueuedPrompt, run: IActiveRun): void {
1661
+ if (run.controller.signal.aborted) {
1662
+ throw run.controller.signal.reason ?? new FlexHarnessAbortError();
1663
+ }
1664
+ this.assertStateAcceptingWork(run.state);
1665
+ if (
1666
+ run.state.sessions.get(run.sessionId) !== run.stored
1667
+ || run.stored.outstandingPromptsById.get(run.queueId) !== queued
1668
+ || run.state.activeRuns.get(run.sessionId) !== run
1669
+ ) throw this.trustInternalError(new FlexHarnessAbortError('The prompt was retired during admission.'));
1670
+ }
1671
+
1672
+ private async promoteQueuedPrompt(queued: IQueuedPrompt, run: IActiveRun): Promise<void> {
1673
+ const prompt = queued.prompt!;
1674
+ const options = queued.options!;
1376
1675
  let projectionReserved = false;
1377
1676
  try {
1378
- run.transaction = await stored.agentSession.beginGeneration(
1677
+ run.transaction = await run.stored.agentSession.beginGeneration(
1379
1678
  cloneSerializable(prompt.modelMessage.content) as Parameters<
1380
1679
  plugins.IAgentSession['beginGeneration']
1381
1680
  >[0],
1382
1681
  { generationId: run.runId },
1383
1682
  );
1683
+ this.assertPromptPromotion(queued, run);
1384
1684
  const reservation = this.createReservation(run, prompt);
1385
- await this.mutateProjection(state, stored, () => {
1386
- stored.messages.push(reservation.userMessage, reservation.assistantMessage);
1685
+ await this.mutateProjection(run.state, run.stored, () => {
1686
+ run.stored.messages.push(reservation.userMessage, reservation.assistantMessage);
1387
1687
  });
1388
1688
  projectionReserved = true;
1389
1689
  run.reservedUserMessage = publicSnapshot(reservation.userMessage);
1390
1690
  run.reservedAssistantMessage = publicSnapshot(reservation.assistantMessage);
1391
- await this.mutateScope(state, () => {
1392
- if (state.tombstones.has(sessionId) || state.sessions.get(sessionId) !== stored) {
1691
+ this.assertPromptPromotion(queued, run);
1692
+ await this.mutateScope(run.state, () => {
1693
+ if (run.controller.signal.aborted) {
1694
+ throw run.controller.signal.reason ?? new FlexHarnessAbortError();
1695
+ }
1696
+ if (run.state.tombstones.has(run.sessionId) || run.state.sessions.get(run.sessionId) !== run.stored) {
1393
1697
  throw new FlexHarnessAbortError('The session was deleted during prompt admission.');
1394
1698
  }
1395
- stored.session.status = scheduleKey ? 'scheduled' : 'running';
1396
- stored.session.activity = {
1699
+ run.stored.session.status = run.scheduleKey ? 'scheduled' : 'running';
1700
+ run.stored.session.activity = {
1397
1701
  runId: run.runId,
1398
- status: scheduleKey ? 'scheduled' : 'running',
1702
+ status: run.scheduleKey ? 'scheduled' : 'running',
1399
1703
  startedAt: reservation.userMessage.createdAt,
1400
1704
  };
1401
- stored.session.updatedAt = reservation.userMessage.createdAt;
1705
+ run.stored.session.updatedAt = reservation.userMessage.createdAt;
1402
1706
  });
1403
1707
  } catch (error) {
1404
- if (containsStoreCommitUncertainty(error)) state.lifecycle = 'fenced';
1708
+ if (containsStoreCommitUncertainty(error)) run.state.lifecycle = 'fenced';
1405
1709
  const safeError = this.projectExternalError(run, error, projectionReserved ? 'persistence' : 'agentSession');
1406
1710
  let cleanupErrors: unknown[] = [];
1407
1711
  let cleanupFailure: unknown;
@@ -1410,30 +1714,28 @@ export class FlexHarness<TScope = unknown> {
1410
1714
  } catch (rollbackError) {
1411
1715
  cleanupFailure = rollbackError;
1412
1716
  }
1413
- if (state.lifecycle === 'fenced' && cleanupFailure === undefined) {
1717
+ if (run.state.lifecycle === 'fenced' && cleanupFailure === undefined) {
1414
1718
  const combined = combineErrors([safeError, ...cleanupErrors]);
1415
1719
  try {
1416
- await this.fenceNamespace(state, run, combined);
1720
+ await this.fenceNamespace(run.state, run, combined);
1417
1721
  } catch (fenceError) {
1418
1722
  cleanupFailure = fenceError;
1419
1723
  }
1420
1724
  }
1421
- if (state.activeRuns.get(sessionId) === run) state.activeRuns.delete(sessionId);
1422
1725
  const rejection = cleanupFailure
1423
1726
  ?? (cleanupErrors.length > 0 ? combineErrors([safeError, ...cleanupErrors]) : safeError);
1424
- run.rejectCompletion(rejection);
1425
1727
  throw rejection;
1426
1728
  }
1427
1729
 
1428
- const runType = scheduleKey ? 'run.scheduled' : 'run.started';
1429
- this.emitEvent(scopeId, sessionId, {
1730
+ const runType = run.scheduleKey ? 'run.scheduled' : 'run.started';
1731
+ this.emitEvent(run.scopeId, run.sessionId, {
1430
1732
  type: runType,
1431
1733
  runId: run.runId,
1432
1734
  messageId: run.userMessageId,
1433
- session: publicSnapshot(stored.session),
1735
+ session: publicSnapshot(run.stored.session),
1434
1736
  });
1435
1737
  for (const message of [run.reservedUserMessage!, run.reservedAssistantMessage!]) {
1436
- this.emitEvent(scopeId, sessionId, {
1738
+ this.emitEvent(run.scopeId, run.sessionId, {
1437
1739
  type: 'message.created',
1438
1740
  runId: run.runId,
1439
1741
  messageId: message.messageId,
@@ -1441,22 +1743,144 @@ export class FlexHarness<TScope = unknown> {
1441
1743
  });
1442
1744
  }
1443
1745
 
1444
- run.phase = scheduleKey ? 'scheduled' : 'running';
1746
+ queued.status = run.scheduleKey ? 'scheduled' : 'starting';
1747
+ this.emitPromptQueueEvent(queued, 'prompt.started');
1748
+ queued.resolveStarted(Object.freeze({
1749
+ queueId: queued.queueId,
1750
+ runId: run.runId,
1751
+ completion: queued.completion,
1752
+ }));
1753
+ run.phase = run.scheduleKey ? 'scheduled' : 'running';
1445
1754
  const generateOptions = {
1446
1755
  transaction: run.transaction,
1447
1756
  abort: run.controller.signal,
1448
1757
  prepare: (context: { generationId: string; abortSignal: AbortSignal }) =>
1449
1758
  this.prepareGeneration(run, options, context.abortSignal),
1450
1759
  };
1451
- const generation = scheduleKey
1452
- ? stored.agentSession.scheduleGenerate({
1453
- key: scheduleKey,
1454
- debounceMs,
1760
+ const generation = run.scheduleKey
1761
+ ? run.stored.agentSession.scheduleGenerate({
1762
+ key: run.scheduleKey,
1763
+ debounceMs: run.debounceMs,
1455
1764
  ...generateOptions,
1456
1765
  })
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 });
1766
+ : run.stored.agentSession.generate(generateOptions);
1767
+ const execution = this.executeRun(run, generation).then(
1768
+ (result) => this.finishQueuedPrompt(queued, 'completed', undefined, result),
1769
+ (error) => this.finishQueuedPrompt(
1770
+ queued,
1771
+ isAbortError(error) ? 'cancelled' : 'failed',
1772
+ error,
1773
+ ),
1774
+ ).finally(() => this.schedulePromptQueueDrain(run.state, run.stored));
1775
+ void execution.catch(() => undefined);
1776
+ }
1777
+
1778
+ private projectPromptQueueEntry(queued: IQueuedPrompt): IFlexPromptQueueEntry {
1779
+ return {
1780
+ queueId: queued.queueId,
1781
+ queueSequence: queued.queueSequence,
1782
+ scopeId: queued.scopeId,
1783
+ sessionId: queued.sessionId,
1784
+ status: queued.status,
1785
+ queuedAt: queued.queuedAt,
1786
+ ...(queued.runId ? { runId: queued.runId } : {}),
1787
+ ...(queued.scheduleKey ? { scheduleKey: queued.scheduleKey } : {}),
1788
+ ...(queued.startedAt ? { startedAt: queued.startedAt } : {}),
1789
+ };
1790
+ }
1791
+
1792
+ private promptQueueEntry(
1793
+ stored: IStoredSessionState,
1794
+ queueId: string,
1795
+ ): IFlexPromptQueueEntry | undefined {
1796
+ const outstanding = stored.outstandingPromptsById.get(queueId);
1797
+ return outstanding
1798
+ ? this.projectPromptQueueEntry(outstanding)
1799
+ : stored.terminalPromptQueueEntries.get(queueId);
1800
+ }
1801
+
1802
+ private emitPromptQueueEvent(
1803
+ queued: IQueuedPrompt,
1804
+ type: 'prompt.queued' | 'prompt.started' | 'prompt.running' | 'prompt.finished',
1805
+ entry: IFlexPromptQueueEntry = this.projectPromptQueueEntry(queued),
1806
+ ): void {
1807
+ this.emitEvent(queued.scopeId, queued.sessionId, {
1808
+ type,
1809
+ queueId: queued.queueId,
1810
+ ...(entry.runId ? { runId: entry.runId } : {}),
1811
+ entry: publicSnapshot(entry),
1812
+ });
1813
+ }
1814
+
1815
+ private finishQueuedPrompt(
1816
+ queued: IQueuedPrompt,
1817
+ status: 'completed' | 'failed' | 'cancelled',
1818
+ error?: unknown,
1819
+ result?: IFlexPromptResult,
1820
+ ): void {
1821
+ const stored = queued.stored;
1822
+ if (stored.outstandingPromptsById.get(queued.queueId) !== queued) return;
1823
+ stored.promptQueue = stored.promptQueue.filter((entry) => entry !== queued);
1824
+ stored.outstandingPromptsById.delete(queued.queueId);
1825
+ stored.outstandingPromptBytes = Math.max(0, stored.outstandingPromptBytes - queued.byteSize);
1826
+ delete queued.prompt;
1827
+ delete queued.options;
1828
+ const terminal: IFlexPromptQueueEntry = {
1829
+ ...this.projectPromptQueueEntry(queued),
1830
+ status,
1831
+ finishedAt: new Date().toISOString(),
1832
+ ...(error === undefined ? {} : { error: errorToInfo(error) }),
1833
+ };
1834
+ stored.terminalPromptQueueEntries.set(queued.queueId, terminal);
1835
+ while (
1836
+ stored.terminalPromptQueueEntries.size
1837
+ > this.promptQueueLimits.maxTerminalEntriesPerSession
1838
+ ) {
1839
+ const oldest = stored.terminalPromptQueueEntries.keys().next().value as string | undefined;
1840
+ if (!oldest) break;
1841
+ stored.terminalPromptQueueEntries.delete(oldest);
1842
+ }
1843
+ this.emitPromptQueueEvent(queued, 'prompt.finished', terminal);
1844
+ if (status === 'completed') {
1845
+ if (!result) throw new Error('A completed queued prompt requires a result.');
1846
+ queued.resolveCompletion(result);
1847
+ } else {
1848
+ const rejection = error ?? this.trustInternalError(new FlexHarnessAbortError());
1849
+ queued.rejectStarted(rejection);
1850
+ queued.rejectCompletion(rejection);
1851
+ }
1852
+ }
1853
+
1854
+ private cancelQueuedPrompt(queued: IQueuedPrompt, reason: FlexHarnessAbortError): boolean {
1855
+ if (queued.stored.outstandingPromptsById.get(queued.queueId) !== queued) return false;
1856
+ if (queued.status === 'queued') {
1857
+ this.finishQueuedPrompt(queued, 'cancelled', reason);
1858
+ this.schedulePromptQueueDrain(queued.state, queued.stored);
1859
+ return true;
1860
+ }
1861
+ const run = queued.state.activeRuns.get(queued.sessionId);
1862
+ if (!run || run.queueId !== queued.queueId) return false;
1863
+ if (run.phase === 'finalizing' || run.phase === 'promoting') return false;
1864
+ this.cancelRun(run, reason);
1865
+ return true;
1866
+ }
1867
+
1868
+ private cancelStoredPromptQueue(
1869
+ stored: IStoredSessionState,
1870
+ reason: FlexHarnessAbortError,
1871
+ excludedQueueId?: string,
1872
+ ): void {
1873
+ for (const queued of [...stored.outstandingPromptsById.values()]) {
1874
+ if (queued.queueId === excludedQueueId) continue;
1875
+ this.cancelQueuedPrompt(queued, reason);
1876
+ }
1877
+ }
1878
+
1879
+ private purgeStoredPromptQueue(stored: IStoredSessionState): void {
1880
+ stored.promptQueue.length = 0;
1881
+ stored.outstandingPromptsById.clear();
1882
+ stored.terminalPromptQueueEntries.clear();
1883
+ stored.outstandingPromptBytes = 0;
1460
1884
  }
1461
1885
 
1462
1886
  private async executeRun(
@@ -1597,7 +2021,13 @@ export class FlexHarness<TScope = unknown> {
1597
2021
  options: IFlexPromptOptions,
1598
2022
  signal: AbortSignal,
1599
2023
  ): Promise<plugins.IAgentGenerationLease> {
2024
+ signal.throwIfAborted();
1600
2025
  run.phase = 'running';
2026
+ const queued = run.stored.outstandingPromptsById.get(run.queueId);
2027
+ if (queued && (queued.status === 'starting' || queued.status === 'scheduled')) {
2028
+ queued.status = 'running';
2029
+ this.emitPromptQueueEvent(queued, 'prompt.running');
2030
+ }
1601
2031
  const modelOutcome = Promise.resolve()
1602
2032
  .then(() => this.modelResolver.resolveModel({
1603
2033
  scopeId: run.scopeId,
@@ -2390,6 +2820,7 @@ export class FlexHarness<TScope = unknown> {
2390
2820
  if (run.internalFailure === undefined) run.ownerCancellation ??= reason;
2391
2821
  this.rejectRunPermissions(run.state, run, reason);
2392
2822
  if (!run.controller.signal.aborted) run.controller.abort(reason);
2823
+ if (run.phase === 'admitting') return;
2393
2824
  if (run.scheduleKey) run.stored.agentSession.cancelScheduledGeneration(run.scheduleKey, reason);
2394
2825
  else run.stored.agentSession.abortCurrentGeneration(reason);
2395
2826
  }
@@ -2731,6 +3162,10 @@ export class FlexHarness<TScope = unknown> {
2731
3162
  agentEventStoreReleased: true,
2732
3163
  executionContextCloseCompleted: true,
2733
3164
  jobStoreReleased: true,
3165
+ promptQueue: [],
3166
+ outstandingPromptsById: new Map(),
3167
+ terminalPromptQueueEntries: new Map(),
3168
+ outstandingPromptBytes: 0,
2734
3169
  };
2735
3170
  }
2736
3171
 
@@ -2813,6 +3248,10 @@ export class FlexHarness<TScope = unknown> {
2813
3248
  executionContextCloseCompleted: executionContextHandle?.close === undefined,
2814
3249
  jobStoreReleased: this.stores.jobs.releaseSession === undefined,
2815
3250
  jobs: executionContextHandle?.context.jobs,
3251
+ promptQueue: [],
3252
+ outstandingPromptsById: new Map(),
3253
+ terminalPromptQueueEntries: new Map(),
3254
+ outstandingPromptBytes: 0,
2816
3255
  };
2817
3256
  const agentSessionOptions: plugins.IAgentSessionOptions & {
2818
3257
  transactionOutcomeErrorProjector: (error: unknown) => string;
@@ -3325,6 +3764,7 @@ export class FlexHarness<TScope = unknown> {
3325
3764
  if (retained) {
3326
3765
  const errors: unknown[] = [];
3327
3766
  const reason = this.trustInternalError(new FlexHarnessAbortError('The session was deleted.'));
3767
+ this.cancelStoredPromptQueue(retained.stored, reason);
3328
3768
  const run = state.activeRuns.get(sessionId);
3329
3769
  if (run) this.cancelRun(run, reason);
3330
3770
  if (!retained.stored.agentSessionAbortCompleted) {
@@ -3346,6 +3786,10 @@ export class FlexHarness<TScope = unknown> {
3346
3786
  errors.push(settled[0].reason);
3347
3787
  }
3348
3788
  }
3789
+ if (retained.stored.promptQueueDrain) {
3790
+ const settled = await Promise.allSettled([retained.stored.promptQueueDrain]);
3791
+ if (settled[0].status === 'rejected') errors.push(settled[0].reason);
3792
+ }
3349
3793
  try {
3350
3794
  await this.closeStoredSession(retained.stored);
3351
3795
  } catch (error) {
@@ -3358,6 +3802,7 @@ export class FlexHarness<TScope = unknown> {
3358
3802
  ));
3359
3803
  }
3360
3804
  if (errors.length > 0) throw combineErrors(errors);
3805
+ this.purgeStoredPromptQueue(retained.stored);
3361
3806
  }
3362
3807
  if (!retained?.domainsCompleted) {
3363
3808
  if (this.hasOrphanedSessionResources(state.storageKey, sessionId)) {
@@ -3384,6 +3829,13 @@ export class FlexHarness<TScope = unknown> {
3384
3829
  const reason = this.trustInternalError(new FlexHarnessAbortError('The session namespace was fenced.'));
3385
3830
  const cleanupErrors: unknown[] = [];
3386
3831
  try {
3832
+ for (const stored of state.sessions.values()) {
3833
+ this.cancelStoredPromptQueue(
3834
+ stored,
3835
+ reason,
3836
+ stored === currentRun.stored ? currentRun.queueId : undefined,
3837
+ );
3838
+ }
3387
3839
  const otherRuns = [...state.activeRuns.values()].filter((run) => run !== currentRun);
3388
3840
  for (const run of otherRuns) this.cancelRun(run, reason);
3389
3841
  const runResults = await Promise.allSettled(otherRuns.map((run) => run.completion));
@@ -3432,6 +3884,7 @@ export class FlexHarness<TScope = unknown> {
3432
3884
  ));
3433
3885
  }
3434
3886
  }
3887
+ if (stored !== currentRun.stored) this.purgeStoredPromptQueue(stored);
3435
3888
  }
3436
3889
  cleanupErrors.push(...await this.settleDetachedCleanups(state, detachedCleanupAttempts));
3437
3890
  state.fenceAdditionalErrors.length = 0;
@@ -3677,6 +4130,7 @@ export class FlexHarness<TScope = unknown> {
3677
4130
  if (state.lifecycle !== 'fenced') state.lifecycle = 'retiring';
3678
4131
  const detachedCleanupAttempts = this.beginDetachedCleanupSettlement(state);
3679
4132
  const errors: unknown[] = [];
4133
+ for (const stored of state.sessions.values()) this.cancelStoredPromptQueue(stored, reason);
3680
4134
  for (const run of state.activeRuns.values()) {
3681
4135
  if (run.phase !== 'finalizing' && run.phase !== 'promoting') this.cancelRun(run, reason);
3682
4136
  }
@@ -3704,6 +4158,7 @@ export class FlexHarness<TScope = unknown> {
3704
4158
  await Promise.all([...state.sessions.values()].flatMap((stored) => [
3705
4159
  stored.projectionQueue,
3706
4160
  stored.permissionQueue,
4161
+ ...(stored.promptQueueDrain ? [stored.promptQueueDrain] : []),
3707
4162
  ]));
3708
4163
  const tombstoneAttempts = [...state.tombstoneCleanups.entries()];
3709
4164
  const tombstoneResults = await Promise.allSettled(
@@ -3726,6 +4181,7 @@ export class FlexHarness<TScope = unknown> {
3726
4181
  'lifecycle-close',
3727
4182
  ));
3728
4183
  }
4184
+ this.purgeStoredPromptQueue(stored);
3729
4185
  }
3730
4186
  for (const sessionId of [...state.tombstones.keys()]) {
3731
4187
  if (attemptedTombstones.has(sessionId)) continue;
@@ -3753,6 +4209,14 @@ export class FlexHarness<TScope = unknown> {
3753
4209
  }
3754
4210
 
3755
4211
  private async disposeInternal(): Promise<void> {
4212
+ const pendingAdmissions = [...this.pendingPromptAdmissionOwners];
4213
+ const pendingAdmissionReason = this.trustInternalError(new FlexHarnessAbortError(
4214
+ 'The prompt admission was aborted because FlexHarness was disposed.',
4215
+ ));
4216
+ for (const pending of pendingAdmissions) {
4217
+ if (!pending.controller.signal.aborted) pending.controller.abort(pendingAdmissionReason);
4218
+ }
4219
+ await Promise.all(pendingAdmissions.map((pending) => pending.settled));
3756
4220
  const loads = [...this.stateLoads.entries()];
3757
4221
  const results = await Promise.allSettled(loads.map(([storageKey, stateLoad]) =>
3758
4222
  this.drainStorage(
@@ -3878,10 +4342,10 @@ export class FlexHarness<TScope = unknown> {
3878
4342
  const stored = this.requireSession(state, sessionId);
3879
4343
  const pending = [...state.pendingPermissions.values()].some((entry) =>
3880
4344
  !entry.settled && entry.request.sessionId === sessionId);
3881
- if (state.activeRuns.has(sessionId) || pending) {
4345
+ if (stored.outstandingPromptsById.size > 0 || pending) {
3882
4346
  throw new FlexHarnessSessionBusyError(
3883
4347
  sessionId,
3884
- 'cannot be changed while it has an active run or pending permission',
4348
+ 'cannot be changed while it has an outstanding prompt or pending permission',
3885
4349
  );
3886
4350
  }
3887
4351
  return stored;
@@ -3903,11 +4367,23 @@ export class FlexHarness<TScope = unknown> {
3903
4367
  part: TFlexMessagePart,
3904
4368
  delta?: string,
3905
4369
  ): void {
4370
+ const messageIndex = run.stored.messages.findIndex(
4371
+ (message) => message.messageId === run.assistantMessageId,
4372
+ );
4373
+ const partIndex = run.callbackParts.findIndex((entry) => entry.partId === part.partId);
4374
+ if (
4375
+ messageIndex < 0
4376
+ || run.stored.messages[messageIndex]?.messageId !== run.assistantMessageId
4377
+ || partIndex < 0
4378
+ || run.callbackParts[partIndex]?.partId !== part.partId
4379
+ ) throw new Error('Flex part event source coordinates are unavailable.');
3906
4380
  this.emitEvent(run.scopeId, run.sessionId, {
3907
4381
  type,
3908
4382
  runId: run.runId,
3909
4383
  messageId: run.assistantMessageId,
4384
+ messageIndex,
3910
4385
  partId: part.partId,
4386
+ partIndex,
3911
4387
  part: publicSnapshot(part),
3912
4388
  ...(delta === undefined ? {} : { delta: truncateUtf8(delta, maxTransferTextBytes) }),
3913
4389
  });