deepline 0.1.230 → 0.1.232

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.
@@ -89,20 +89,8 @@ export interface RateStateCooldown {
89
89
  }
90
90
 
91
91
  interface LeasedBlock {
92
- /**
93
- * Concurrency reservations, one per locally-cached permit. Only rules with a
94
- * `maxConcurrency` mint lease tokens; each id maps to a durable slot that must
95
- * be released (or TTL-expired) so the shared concurrency count drains.
96
- */
97
- leaseIds: string[];
98
- /**
99
- * Pure-rate (RPS-only) permits with no concurrency slot behind them. The
100
- * server decremented the provider window by this count already, so drawing
101
- * one locally is a no-op that needs no release token. Tracked separately from
102
- * `leaseIds` so a pure-RPS block (the common `defaultPacingForTool` shape)
103
- * actually amortizes its whole grant across acquires instead of burning it.
104
- */
105
- windowPermits: number;
92
+ permits: Array<[scheduledAtMs: number, leaseId: string | null]>;
93
+ rateScopeToken: string | null;
106
94
  expiresAt: number;
107
95
  rulesKey: string;
108
96
  rules: PacingRule[];
@@ -110,6 +98,7 @@ interface LeasedBlock {
110
98
 
111
99
  interface PendingRelease {
112
100
  bucketId: string;
101
+ rateScopeToken: string | null;
113
102
  rules: PacingRule[];
114
103
  leaseIds: string[];
115
104
  flushing: boolean;
@@ -156,6 +145,8 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
156
145
  private readonly waiters = new Map<string, number>();
157
146
  /** Last-known server cooldown per bucket (surfaced for PROVIDER_EXHAUSTED). */
158
147
  private readonly cooldowns = new Map<string, RateStateCooldown>();
148
+ private readonly effectiveRps = new Map<string, number>();
149
+ private readonly pendingSuccesses = new Map<string, number>();
159
150
  private pendingReleaseFailure: Error | null = null;
160
151
 
161
152
  constructor(context: WorkerRuntimeApiContext, options: Options = {}) {
@@ -220,6 +211,7 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
220
211
 
221
212
  async acquire(input: {
222
213
  bucketId: string;
214
+ rateScopeToken?: string | null;
223
215
  rules: readonly PacingRule[];
224
216
  signal?: AbortSignal;
225
217
  }): Promise<PacingPermit> {
@@ -249,13 +241,29 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
249
241
  : new Error('Rate-state acquire aborted.');
250
242
  }
251
243
  const localPermit = this.drawFromBlock(bucketId, rulesKey);
252
- if (localPermit) return localPermit;
244
+ if (localPermit) {
245
+ const waitMs = localPermit.scheduledAtMs - this.now();
246
+ if (waitMs > 0) await this.sleep(waitMs);
247
+ if (signal?.aborted) {
248
+ localPermit.permit.release();
249
+ throw signal.reason instanceof Error
250
+ ? signal.reason
251
+ : new Error('Rate-state acquire aborted.');
252
+ }
253
+ return localPermit.permit;
254
+ }
253
255
  // Count this acquirer as waiting on the refill so the single-flight refill
254
256
  // can size its block to the live wave. Decremented once a block lands (or
255
257
  // the refill errors) — see the finally in `refillBlock`.
256
258
  this.waiters.set(bucketId, (this.waiters.get(bucketId) ?? 0) + 1);
257
259
  try {
258
- await this.refillBlock(bucketId, ordered, rulesKey, signal);
260
+ await this.refillBlock(
261
+ bucketId,
262
+ input.rateScopeToken?.trim() || null,
263
+ ordered,
264
+ rulesKey,
265
+ signal,
266
+ );
259
267
  } finally {
260
268
  const remaining = (this.waiters.get(bucketId) ?? 1) - 1;
261
269
  if (remaining > 0) this.waiters.set(bucketId, remaining);
@@ -279,7 +287,15 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
279
287
  ): number {
280
288
  const floor = APP_RUNTIME_RATE_STATE_LEASE_BLOCK_SIZE;
281
289
  if (rules.some((rule) => rule.maxConcurrency != null)) return floor;
290
+ const configuredRps = Math.min(
291
+ ...rules.map((rule) => (rule.requestsPerWindow / rule.windowMs) * 1_000),
292
+ );
293
+ const horizonBudget = Math.max(
294
+ 1,
295
+ Math.ceil(this.effectiveRps.get(bucketId) ?? configuredRps),
296
+ );
282
297
  const windowBudget = Math.min(
298
+ horizonBudget,
283
299
  ...rules
284
300
  .map((rule) => rule.requestsPerWindow)
285
301
  .filter((value) => Number.isFinite(value) && value > 0),
@@ -353,6 +369,7 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
353
369
  */
354
370
  private refillBlock(
355
371
  bucketId: string,
372
+ rateScopeToken: string | null,
356
373
  ordered: PacingRule[],
357
374
  rulesKey: string,
358
375
  signal?: AbortSignal,
@@ -361,6 +378,7 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
361
378
  if (existing) return existing;
362
379
  const promise = this.performRefill(
363
380
  bucketId,
381
+ rateScopeToken,
364
382
  ordered,
365
383
  rulesKey,
366
384
  signal,
@@ -375,6 +393,7 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
375
393
 
376
394
  private async performRefill(
377
395
  bucketId: string,
396
+ rateScopeToken: string | null,
378
397
  ordered: PacingRule[],
379
398
  rulesKey: string,
380
399
  signal?: AbortSignal,
@@ -397,19 +416,45 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
397
416
  if (signal?.aborted) return;
398
417
  }
399
418
  try {
419
+ const observedSuccesses = this.pendingSuccesses.get(bucketId) ?? 0;
400
420
  const response = await this.port.acquire({
401
421
  bucketId,
422
+ rateScopeToken,
402
423
  // Stamp adaptiveMaxRps on default-pacing rules so the DB row's ceiling
403
424
  // matches the old in-memory AIMD range.
404
425
  rules: withAdaptiveMaxRps(ordered),
405
426
  requested: this.requestedForBucket(bucketId, ordered),
427
+ observedSuccesses,
406
428
  schedulerSchema: this.schedulerSchema,
407
429
  });
430
+ if (observedSuccesses > 0) {
431
+ const remaining = Math.max(
432
+ 0,
433
+ (this.pendingSuccesses.get(bucketId) ?? 0) - observedSuccesses,
434
+ );
435
+ if (remaining > 0) this.pendingSuccesses.set(bucketId, remaining);
436
+ else this.pendingSuccesses.delete(bucketId);
437
+ }
408
438
  this.recordCooldown(bucketId, response);
439
+ if (
440
+ Number.isFinite(response.effectiveRequestsPerSecond) &&
441
+ Number(response.effectiveRequestsPerSecond) > 0
442
+ ) {
443
+ this.effectiveRps.set(
444
+ bucketId,
445
+ Number(response.effectiveRequestsPerSecond),
446
+ );
447
+ }
409
448
  if (response.granted > 0) {
410
449
  // Merge the whole grant into the block and let acquirers re-draw. No
411
450
  // permit is handed out here, so nothing is double-drawn.
412
- this.mergeGrantedBlock(bucketId, ordered, rulesKey, response);
451
+ this.mergeGrantedBlock(
452
+ bucketId,
453
+ rateScopeToken,
454
+ ordered,
455
+ rulesKey,
456
+ response,
457
+ );
413
458
  return;
414
459
  }
415
460
  // Live path: the server told us to wait past the threshold. Fail fast
@@ -456,9 +501,14 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
456
501
  */
457
502
  private mergeGrantedBlock(
458
503
  bucketId: string,
504
+ rateScopeToken: string | null,
459
505
  ordered: PacingRule[],
460
506
  rulesKey: string,
461
- response: { granted: number; leaseIds?: unknown },
507
+ response: {
508
+ granted: number;
509
+ leaseIds?: unknown;
510
+ scheduledAtMs?: unknown;
511
+ },
462
512
  ): void {
463
513
  const hasConcurrency = ordered.some((rule) => rule.maxConcurrency != null);
464
514
  const leaseIds = Array.isArray(response.leaseIds)
@@ -477,38 +527,71 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
477
527
  `Rate-state store returned ${leaseIds.length} lease tokens for pure-rate bucket ${bucketId}.`,
478
528
  );
479
529
  }
530
+ // Rolling deploy compatibility: an older gateway returns no schedule. It
531
+ // has already debited the block under the legacy token-bucket contract, so
532
+ // keep those permits immediately usable until every gateway serves the new
533
+ // globally reserved timestamps.
534
+ const scheduledAtMs = Array.isArray(response.scheduledAtMs)
535
+ ? response.scheduledAtMs.map(Number)
536
+ : Array.from({ length: response.granted }, () => this.now());
537
+ if (
538
+ scheduledAtMs.length !== response.granted ||
539
+ scheduledAtMs.some((value) => !Number.isFinite(value) || value < 0)
540
+ ) {
541
+ throw new Error(
542
+ `Rate-state store granted ${response.granted} permits for ${bucketId} with ${scheduledAtMs.length} dispatch timestamps.`,
543
+ );
544
+ }
480
545
  this.mergeBlock(
481
546
  bucketId,
482
- hasConcurrency ? leaseIds : [],
483
- hasConcurrency ? 0 : response.granted,
547
+ rateScopeToken,
548
+ scheduledAtMs.map(
549
+ (scheduledAtMs, index): [number, string | null] => [
550
+ scheduledAtMs,
551
+ hasConcurrency ? (leaseIds[index] ?? null) : null,
552
+ ],
553
+ ),
484
554
  rulesKey,
485
555
  ordered,
486
556
  );
487
557
  }
488
558
 
489
- penalize(input: { bucketId: string; cooldownMs: number }): void {
559
+ async penalize(input: {
560
+ bucketId: string;
561
+ rateScopeToken?: string | null;
562
+ cooldownMs: number;
563
+ }): Promise<void> {
490
564
  if (input.cooldownMs <= 0) return;
491
565
  this.drainBlock(input.bucketId);
492
- void this.port
493
- .penalize({
566
+ try {
567
+ await this.port.penalize({
494
568
  bucketId: input.bucketId,
569
+ rateScopeToken: input.rateScopeToken?.trim() || null,
495
570
  cooldownMs: input.cooldownMs,
496
571
  schedulerSchema: this.schedulerSchema,
497
- })
498
- .catch((error) => {
499
- const normalized = normalizeError(error);
500
- this.pendingReleaseFailure = new Error(
501
- `Rate-state penalize failed for ${input.bucketId}: ${normalized.message}`,
502
- );
503
- this.onStoreFailure({
504
- bucketId: input.bucketId,
505
- error: normalized.message,
506
- });
507
572
  });
573
+ } catch (error) {
574
+ const normalized = normalizeError(error);
575
+ this.onStoreFailure({
576
+ bucketId: input.bucketId,
577
+ error: normalized.message,
578
+ });
579
+ throw new Error(
580
+ `Rate-state penalize failed for ${input.bucketId}: ${normalized.message}`,
581
+ );
582
+ }
583
+ }
584
+
585
+ observeSuccess(input: { bucketId: string }): void {
586
+ this.pendingSuccesses.set(
587
+ input.bucketId,
588
+ Math.min(1_000_000, (this.pendingSuccesses.get(input.bucketId) ?? 0) + 1),
589
+ );
508
590
  }
509
591
 
510
592
  private permitFor(
511
593
  bucketId: string,
594
+ rateScopeToken: string | null,
512
595
  rules: PacingRule[],
513
596
  leaseId: string | null,
514
597
  ): PacingPermit {
@@ -520,35 +603,49 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
520
603
  release: () => {
521
604
  if (released) return;
522
605
  released = true;
523
- this.releaseReserved(bucketId, rules, leaseId ? [leaseId] : []);
606
+ this.releaseReserved(
607
+ bucketId,
608
+ rateScopeToken,
609
+ rules,
610
+ leaseId ? [leaseId] : [],
611
+ );
524
612
  },
525
613
  };
526
614
  }
527
615
 
528
616
  private mergeBlock(
529
617
  bucketId: string,
530
- leaseIds: string[],
531
- windowPermits: number,
618
+ rateScopeToken: string | null,
619
+ permits: LeasedBlock['permits'],
532
620
  rulesKey: string,
533
621
  rules: PacingRule[],
534
622
  ): void {
535
- const freshExpiresAt = this.now() + leasedBlockTtlMs(rules);
623
+ const lastScheduledAtMs = permits.at(-1)?.[0] ?? this.now();
624
+ const freshExpiresAt =
625
+ Math.max(this.now(), lastScheduledAtMs) + leasedBlockTtlMs(rules);
536
626
  const existing = this.blocks.get(bucketId);
537
627
  if (
538
628
  existing &&
539
629
  existing.rulesKey === rulesKey &&
540
630
  existing.expiresAt > this.now()
541
631
  ) {
542
- existing.leaseIds.push(...leaseIds);
543
- existing.windowPermits += windowPermits;
544
- existing.expiresAt = Math.min(existing.expiresAt, freshExpiresAt);
632
+ existing.permits.push(...permits);
633
+ existing.permits.sort((a, b) => a[0] - b[0]);
634
+ existing.expiresAt = Math.max(existing.expiresAt, freshExpiresAt);
545
635
  return;
546
636
  }
547
637
  if (existing)
548
- this.releaseReserved(bucketId, existing.rules, existing.leaseIds);
638
+ this.releaseReserved(
639
+ bucketId,
640
+ existing.rateScopeToken,
641
+ existing.rules,
642
+ existing.permits.flatMap(([, leaseId]) =>
643
+ leaseId ? [leaseId] : [],
644
+ ),
645
+ );
549
646
  this.blocks.set(bucketId, {
550
- leaseIds,
551
- windowPermits,
647
+ permits,
648
+ rateScopeToken,
552
649
  expiresAt: freshExpiresAt,
553
650
  rulesKey,
554
651
  rules,
@@ -558,7 +655,7 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
558
655
  private drawFromBlock(
559
656
  bucketId: string,
560
657
  rulesKey: string,
561
- ): PacingPermit | null {
658
+ ): { permit: PacingPermit; scheduledAtMs: number } | null {
562
659
  const block = this.blocks.get(bucketId);
563
660
  if (!block) return null;
564
661
  if (block.rulesKey !== rulesKey || block.expiresAt <= this.now()) {
@@ -567,30 +664,50 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
567
664
  // decremented the provider window for them, and the rule-scoped TTL is
568
665
  // capped so at most one block's worth is stranded per bucket/window).
569
666
  // Concurrency leases must be released so the shared slot count drains.
570
- this.releaseReserved(bucketId, block.rules, block.leaseIds);
667
+ this.releaseReserved(
668
+ bucketId,
669
+ block.rateScopeToken,
670
+ block.rules,
671
+ block.permits.flatMap(([, leaseId]) =>
672
+ leaseId ? [leaseId] : [],
673
+ ),
674
+ );
571
675
  return null;
572
676
  }
573
- const hasConcurrency = block.rules.some(
574
- (rule) => rule.maxConcurrency != null,
575
- );
576
- // Concurrency blocks draw a lease token (release semantics); pure-rate
577
- // blocks draw an untracked window permit that the server already accounted.
578
- const leaseId = hasConcurrency ? (block.leaseIds.pop() ?? null) : null;
579
- if (!hasConcurrency && block.windowPermits > 0) block.windowPermits -= 1;
580
- if (block.leaseIds.length === 0 && block.windowPermits === 0)
677
+ const scheduled = block.permits.shift();
678
+ if (!scheduled) {
581
679
  this.blocks.delete(bucketId);
582
- return this.permitFor(bucketId, block.rules, leaseId);
680
+ return null;
681
+ }
682
+ if (block.permits.length === 0) this.blocks.delete(bucketId);
683
+ return {
684
+ permit: this.permitFor(
685
+ bucketId,
686
+ block.rateScopeToken,
687
+ block.rules,
688
+ scheduled[1],
689
+ ),
690
+ scheduledAtMs: scheduled[0],
691
+ };
583
692
  }
584
693
 
585
694
  private drainBlock(bucketId: string): void {
586
695
  const block = this.blocks.get(bucketId);
587
696
  if (!block) return;
588
697
  this.blocks.delete(bucketId);
589
- this.releaseReserved(bucketId, block.rules, block.leaseIds);
698
+ this.releaseReserved(
699
+ bucketId,
700
+ block.rateScopeToken,
701
+ block.rules,
702
+ block.permits.flatMap(([, leaseId]) =>
703
+ leaseId ? [leaseId] : [],
704
+ ),
705
+ );
590
706
  }
591
707
 
592
708
  private releaseReserved(
593
709
  bucketId: string,
710
+ rateScopeToken: string | null,
594
711
  rules: readonly PacingRule[],
595
712
  leaseIds: readonly string[],
596
713
  ): void {
@@ -603,6 +720,7 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
603
720
  const key = `${bucketId}\0${rulesSignature(ordered)}`;
604
721
  const pending = this.pendingReleases.get(key) ?? {
605
722
  bucketId,
723
+ rateScopeToken,
606
724
  rules: ordered,
607
725
  leaseIds: [],
608
726
  flushing: false,
@@ -623,6 +741,7 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
623
741
  try {
624
742
  await this.port.release({
625
743
  bucketId: pending.bucketId,
744
+ rateScopeToken: pending.rateScopeToken,
626
745
  rules: pending.rules,
627
746
  leaseIds,
628
747
  schedulerSchema: this.schedulerSchema,
@@ -167,22 +167,26 @@ export class CoordinatorRateStateBackend implements RateStateBackend {
167
167
  }
168
168
  }
169
169
 
170
- penalize(input: { bucketId: string; cooldownMs: number }): void {
170
+ async penalize(input: {
171
+ bucketId: string;
172
+ cooldownMs: number;
173
+ }): Promise<void> {
171
174
  if (input.cooldownMs <= 0) return;
172
175
  // Drop any cached block for this bucket so the cooldown takes effect on the
173
176
  // very next acquire instead of being masked by already-leased permits.
174
177
  this.blocks.delete(input.bucketId);
175
- void this.port
176
- .ratePenalize({
178
+ try {
179
+ await this.port.ratePenalize({
177
180
  bucketId: input.bucketId,
178
181
  cooldownMs: input.cooldownMs,
179
- })
180
- .catch((error) => {
181
- this.onDegraded({
182
- bucketId: input.bucketId,
183
- error: error instanceof Error ? error.message : String(error),
184
- });
185
182
  });
183
+ } catch (error) {
184
+ this.onDegraded({
185
+ bucketId: input.bucketId,
186
+ error: error instanceof Error ? error.message : String(error),
187
+ });
188
+ throw error;
189
+ }
186
190
  }
187
191
 
188
192
  /**
@@ -70,6 +70,14 @@ export type PacingResolver = (
70
70
  toolId: string,
71
71
  ) => Promise<{ provider: string; rules: PacingRule[] } | null>;
72
72
 
73
+ export type RateScopeResolver = (
74
+ toolId: string,
75
+ provider: string,
76
+ ) =>
77
+ | Promise<{ bucketId: string; token: string } | null>
78
+ | { bucketId: string; token: string }
79
+ | null;
80
+
73
81
  export function defaultPacingForTool(
74
82
  toolId: string,
75
83
  policy: ResolvedExecutionPolicy,
@@ -96,12 +104,9 @@ export interface PlayExecutionGovernor {
96
104
  /** Block until a map-row slot is free. */
97
105
  acquireRowSlot(opts?: { signal?: AbortSignal }): Promise<WorkLease>;
98
106
  /**
99
- * Block until a global tool-concurrency slot AND the per-(org,provider) pacer
100
- * permit are free, then charge the tool-call budget and return a lease. Order:
101
- * concurrency slot provider pace tool budget (charged last so a
102
- * failed/aborted acquire never consumes budget). A run over tool budget still
103
- * acquires and holds a slot + pacing permit before the breach is detected; the
104
- * breach surfaces only once the call is otherwise cleared to run.
107
+ * Block until the per-provider pacer permit and then a global
108
+ * tool-concurrency slot are free, charge the tool-call budget, and return a
109
+ * lease. Parked future permits never consume scarce execution slots.
105
110
  */
106
111
  acquireToolSlot(
107
112
  toolId: string,
@@ -132,12 +137,19 @@ export interface PlayExecutionGovernor {
132
137
  /** Feed a provider's Retry-After back into the shared pacer. */
133
138
  reportProviderBackpressure(input: {
134
139
  provider: string;
140
+ /**
141
+ * The tool whose provider call received the 429. Credential-scoped rate
142
+ * buckets are resolved per tool, so delayed feedback must retain this
143
+ * identity instead of selecting the latest scope for the provider.
144
+ */
145
+ toolId?: string;
135
146
  retryAfterMs: number;
136
- }): void;
147
+ }): Promise<void>;
137
148
 
138
149
  /** Feed clean provider calls back into adaptive admission. */
139
150
  observeProviderSuccess(input: {
140
151
  provider: string;
152
+ toolId?: string;
141
153
  latencyMs?: number | null;
142
154
  }): void;
143
155
  /** Feed success back using the same tool-to-provider resolution as acquire. */
@@ -155,6 +167,7 @@ interface GovernorInput {
155
167
  rateState: RateStateBackend;
156
168
  budgetState?: BudgetStateBackend;
157
169
  resolvePacing: PacingResolver;
170
+ resolveRateScope?: RateScopeResolver;
158
171
  adaptiveAdmission?: RuntimeAdaptiveAdmission;
159
172
  resume?: GovernanceSnapshot;
160
173
  }
@@ -246,6 +259,7 @@ export function createPlayExecutionGovernor(
246
259
  });
247
260
  const budgetState = input.budgetState ?? new InMemoryBudgetStateBackend();
248
261
  const providerByTool = new Map<string, string>();
262
+ const scopeByTool = new Map<string, [string, string]>();
249
263
 
250
264
  // When the rate-state backend owns pacing authoritatively (the Absurd/Node
251
265
  // app-runtime Postgres pacer runs the whole token bucket + AIMD in the row),
@@ -256,7 +270,20 @@ export function createPlayExecutionGovernor(
256
270
  // undefined and keep the in-memory adaptive admission (byte-identical).
257
271
  const dbAuthoritativePacing = input.rateState.kind === 'app_runtime_postgres';
258
272
 
259
- const bucketId = (provider: string) => `${input.scope.orgId}:${provider}`;
273
+ const orgBucket = (provider: string) => `${input.scope.orgId}:${provider}`;
274
+
275
+ async function resolveScope(toolId: string, provider: string) {
276
+ const resolved = await input.resolveRateScope?.(toolId, provider);
277
+ const scope = [
278
+ resolved?.bucketId ?? orgBucket(provider),
279
+ resolved?.token ?? '',
280
+ ] as [string, string];
281
+ scopeByTool.set(toolId, scope);
282
+ return scope;
283
+ }
284
+
285
+ const scopeFor = (toolId: string | undefined, provider: string) =>
286
+ scopeByTool.get(toolId ?? '') ?? ([orgBucket(provider), ''] as [string, string]);
260
287
 
261
288
  async function resolveAdaptivePacing(toolId: string): Promise<{
262
289
  provider: string;
@@ -348,23 +375,26 @@ export function createPlayExecutionGovernor(
348
375
 
349
376
  acquireRowSlot: (opts) => rowSlots.acquire(opts?.signal),
350
377
  async acquireToolSlot(toolId, opts) {
351
- // 1. global tool-concurrency slot.
352
- const slot = await toolSlots.acquire(opts?.signal);
353
- // 2. per-(org,provider) pacing. The provider comes from the pacing
378
+ // 1. Per-provider pacing. The provider comes from the pacing
354
379
  // resolver, so callers only need the toolId. No rules → no pacing.
355
- let permit: { release(): void };
380
+ const pacing = await resolveAdaptivePacing(toolId);
381
+ const rateScope = await resolveScope(toolId, pacing.provider);
382
+ const permit = await input.rateState.acquire({
383
+ bucketId: rateScope[0],
384
+ rateScopeToken: rateScope[1],
385
+ rules: pacing.rules,
386
+ signal: opts?.signal,
387
+ });
388
+ // 2. Acquire a scarce execution slot only after the provider permit is
389
+ // due. If the slot wait aborts, refund any concurrency-bearing permit.
390
+ let slot: WorkLease;
356
391
  try {
357
- const pacing = await resolveAdaptivePacing(toolId);
358
- permit = await input.rateState.acquire({
359
- bucketId: bucketId(pacing.provider),
360
- rules: pacing.rules,
361
- signal: opts?.signal,
362
- });
392
+ slot = await toolSlots.acquire(opts?.signal);
363
393
  } catch (error) {
364
- slot.release();
394
+ permit.release();
365
395
  throw error;
366
396
  }
367
- // 3. charge the budget only once the call is actually cleared to run, so a
397
+ // 3. Charge the budget only once the call is actually cleared to run, so a
368
398
  // failed/aborted acquisition never permanently consumes tool budget.
369
399
  try {
370
400
  await chargeBudget('toolCall');
@@ -409,7 +439,7 @@ export function createPlayExecutionGovernor(
409
439
  resolveRowConcurrency: (requested) =>
410
440
  resolveRowConcurrency(policy, requested),
411
441
 
412
- reportProviderBackpressure(bp) {
442
+ async reportProviderBackpressure(bp) {
413
443
  // DB-authoritative pacer: route backpressure ONLY to the row via
414
444
  // penalize. The in-memory adaptive admission must not also halve, or the
415
445
  // provider would be throttled twice (once in the row, once in memory).
@@ -420,13 +450,22 @@ export function createPlayExecutionGovernor(
420
450
  retryAfterMs: bp.retryAfterMs,
421
451
  });
422
452
  }
423
- input.rateState.penalize({
424
- bucketId: bucketId(bp.provider),
453
+ const rateScope = scopeFor(bp.toolId, bp.provider);
454
+ await input.rateState.penalize({
455
+ bucketId: rateScope[0],
456
+ ...(rateScope[1] ? { rateScopeToken: rateScope[1] } : {}),
425
457
  cooldownMs: bp.retryAfterMs,
426
458
  });
427
459
  },
428
460
 
429
461
  observeProviderSuccess(success) {
462
+ if (dbAuthoritativePacing) {
463
+ const rateScope = scopeFor(success.toolId, success.provider);
464
+ input.rateState.observeSuccess?.({
465
+ bucketId: rateScope[0],
466
+ });
467
+ return;
468
+ }
430
469
  adaptiveAdmission.observeProviderSuccess({
431
470
  orgId: input.scope.orgId,
432
471
  provider: success.provider,
@@ -438,9 +477,9 @@ export function createPlayExecutionGovernor(
438
477
  const provider =
439
478
  providerByTool.get(success.toolId) ??
440
479
  defaultPacingForTool(success.toolId, policy).provider;
441
- adaptiveAdmission.observeProviderSuccess({
442
- orgId: input.scope.orgId,
480
+ governor.observeProviderSuccess({
443
481
  provider,
482
+ toolId: success.toolId,
444
483
  latencyMs: success.latencyMs,
445
484
  });
446
485
  },
@@ -507,8 +546,8 @@ function createInlineChildGovernor(
507
546
  root.suggestedParallelism(toolId, fallback),
508
547
  chargeBudget: (kind, amount) => root.chargeBudget(kind, amount),
509
548
  resolveRowConcurrency: (requested) => root.resolveRowConcurrency(requested),
510
- reportProviderBackpressure: (input) =>
511
- root.reportProviderBackpressure(input),
549
+ reportProviderBackpressure: async (input) =>
550
+ await root.reportProviderBackpressure(input),
512
551
  observeProviderSuccess: (input) => root.observeProviderSuccess(input),
513
552
  observeToolSuccess: (input) => root.observeToolSuccess(input),
514
553
  async forkInlineChild(input) {
@@ -86,6 +86,7 @@ export interface RateStateBackend {
86
86
  */
87
87
  acquire(input: {
88
88
  bucketId: string;
89
+ rateScopeToken?: string | null;
89
90
  rules: readonly PacingRule[];
90
91
  signal?: AbortSignal;
91
92
  }): Promise<PacingPermit>;
@@ -94,7 +95,17 @@ export interface RateStateBackend {
94
95
  * Feed a server-observed Retry-After back so future acquires for this bucket
95
96
  * back off. Advisory and idempotent; never un-charges an in-flight call.
96
97
  */
97
- penalize(input: { bucketId: string; cooldownMs: number }): void;
98
+ penalize(input: {
99
+ bucketId: string;
100
+ rateScopeToken?: string | null;
101
+ cooldownMs: number;
102
+ }): void | Promise<void>;
103
+
104
+ /** Record confirmed provider success for adaptive recovery. */
105
+ observeSuccess?(input: {
106
+ bucketId: string;
107
+ rateScopeToken?: string | null;
108
+ }): void;
98
109
  }
99
110
 
100
111
  const NOOP_PERMIT: PacingPermit = { release() {} };