deepline 0.1.208 → 0.1.210

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.
Files changed (39) hide show
  1. package/dist/bundling-sources/apps/play-runner-workers/src/coordinator-entry.ts +0 -2
  2. package/dist/bundling-sources/apps/play-runner-workers/src/entry.ts +102 -138
  3. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/receipts.ts +2 -12
  4. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/tool-dispatch.ts +12 -4
  5. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/tool-receipts.ts +25 -92
  6. package/dist/bundling-sources/sdk/src/client.ts +10 -0
  7. package/dist/bundling-sources/sdk/src/play.ts +2 -2
  8. package/dist/bundling-sources/sdk/src/release.ts +2 -2
  9. package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +16 -31
  10. package/dist/bundling-sources/shared_libs/play-runtime/child-execution-strategy.ts +18 -0
  11. package/dist/bundling-sources/shared_libs/play-runtime/child-run-id.ts +4 -5
  12. package/dist/bundling-sources/shared_libs/play-runtime/context.ts +316 -232
  13. package/dist/bundling-sources/shared_libs/play-runtime/ctx-types.ts +16 -2
  14. package/dist/bundling-sources/shared_libs/play-runtime/daytona-runtime-config.ts +0 -5
  15. package/dist/bundling-sources/shared_libs/play-runtime/durable-call-cache.ts +2 -4
  16. package/dist/bundling-sources/shared_libs/play-runtime/execution-capabilities.ts +1 -0
  17. package/dist/bundling-sources/shared_libs/play-runtime/governor/app-runtime-rate-state-backend.ts +429 -102
  18. package/dist/bundling-sources/shared_libs/play-runtime/governor/governor.ts +27 -5
  19. package/dist/bundling-sources/shared_libs/play-runtime/governor/rate-state-backend.ts +18 -0
  20. package/dist/bundling-sources/shared_libs/play-runtime/protocol.ts +31 -12
  21. package/dist/bundling-sources/shared_libs/play-runtime/run-failure.ts +103 -0
  22. package/dist/bundling-sources/shared_libs/play-runtime/run-ledger.ts +105 -10
  23. package/dist/bundling-sources/shared_libs/play-runtime/run-snapshot-stream.ts +21 -1
  24. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-lifecycle.ts +32 -86
  25. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-payload-transport.ts +141 -2
  26. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-session-execution.ts +45 -0
  27. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona.ts +180 -447
  28. package/dist/bundling-sources/shared_libs/play-runtime/runtime-constants.ts +12 -0
  29. package/dist/bundling-sources/shared_libs/play-runtime/suspension.ts +33 -1
  30. package/dist/bundling-sources/shared_libs/play-runtime/tool-http-errors.ts +11 -0
  31. package/dist/bundling-sources/shared_libs/plays/artifact-kind-guard.ts +112 -0
  32. package/dist/cli/index.js +33 -8
  33. package/dist/cli/index.mjs +33 -8
  34. package/dist/index.d.mts +11 -2
  35. package/dist/index.d.ts +11 -2
  36. package/dist/index.js +26 -4
  37. package/dist/index.mjs +26 -4
  38. package/package.json +1 -1
  39. package/dist/bundling-sources/shared_libs/play-runtime/child-run-lifecycle.ts +0 -70
@@ -9,7 +9,25 @@ import {
9
9
  type PacingPermit,
10
10
  type PacingRule,
11
11
  type RateStateBackend,
12
+ type RateStateBackendKind,
12
13
  } from './rate-state-backend';
14
+ import {
15
+ ProviderExhaustedError,
16
+ providerNameFromBucketId,
17
+ } from '../run-failure';
18
+
19
+ /**
20
+ * Longest provider pacer hold this substrate will sleep toward before failing
21
+ * the call fast with a typed {@link ProviderExhaustedError}. Duration alone
22
+ * decides — there is no quota-vs-rate classification. A hold at or under this is
23
+ * slept off (today's behavior); a hold above it is skipped with no spend and no
24
+ * provider dispatch.
25
+ *
26
+ * Enforced ONLY on the `app_runtime_postgres` (Absurd/Node DB-authoritative)
27
+ * backend; the in-memory and coordinator backends never construct this class and
28
+ * so keep their sleep-toward-it behavior byte-for-byte.
29
+ */
30
+ export const PROVIDER_EXHAUSTED_MAX_WAIT_MS = 60_000;
13
31
 
14
32
  /**
15
33
  * Permits leased per app-runtime round trip for the direct Node substrate.
@@ -17,11 +35,58 @@ import {
17
35
  * Eight keeps DB/API round trips amortized for batch-heavy maps while limiting
18
36
  * stale-block over-issue to 8 x live runner processes per bucket/window.
19
37
  */
38
+ /**
39
+ * Transport seam for the app-runtime rate-state store. Mirrors the three module
40
+ * calls the backend makes, minus the `context` argument (the default port binds
41
+ * it). Aligns this backend with {@link CoordinatorRateStateBackend}'s injectable
42
+ * port so both are driven — and tested — the same way.
43
+ */
44
+ export interface AppRuntimeRatePort {
45
+ acquire(
46
+ input: Parameters<typeof acquireRateStateViaAppRuntime>[1],
47
+ ): ReturnType<typeof acquireRateStateViaAppRuntime>;
48
+ release(
49
+ input: Parameters<typeof releaseRateStateViaAppRuntime>[1],
50
+ ): ReturnType<typeof releaseRateStateViaAppRuntime>;
51
+ penalize(
52
+ input: Parameters<typeof penalizeRateStateViaAppRuntime>[1],
53
+ ): ReturnType<typeof penalizeRateStateViaAppRuntime>;
54
+ }
55
+
56
+ function contextRatePort(context: WorkerRuntimeApiContext): AppRuntimeRatePort {
57
+ return {
58
+ acquire: (input) => acquireRateStateViaAppRuntime(context, input),
59
+ release: (input) => releaseRateStateViaAppRuntime(context, input),
60
+ penalize: (input) => penalizeRateStateViaAppRuntime(context, input),
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Permits leased per app-runtime round trip. Eight keeps DB/API round trips
66
+ * amortized for batch-heavy maps while bounding stale-block over-issue to 8 x
67
+ * live runner processes per bucket/window. Deliberately conservative: absurd
68
+ * runs many concurrent sandboxes against the same org+provider bucket, so a
69
+ * larger block would let one runner reserve a whole window and starve peers.
70
+ */
20
71
  export const APP_RUNTIME_RATE_STATE_LEASE_BLOCK_SIZE = 8;
21
72
  const LEASE_BLOCK_MIN_TTL_MS = 250;
22
73
  const LEASE_BLOCK_MAX_TTL_MS = 5_000;
23
74
  const MAX_ACQUIRE_SLEEP_MS = 5_000;
24
75
  const STORE_FAILURE_RETRY_DELAYS_MS = [100, 250] as const;
76
+ /**
77
+ * Jitter fraction applied to a server-supplied `waitMs` so a synchronized wave
78
+ * of buckets that all hit granted=0 at the same instant re-probe at slightly
79
+ * different times instead of thundering the store in lockstep. ±10%.
80
+ */
81
+ const ACQUIRE_WAIT_JITTER_FRACTION = 0.1;
82
+
83
+ /** Last-known server cooldown surface for a bucket (consumed by PROVIDER_EXHAUSTED). */
84
+ export interface RateStateCooldown {
85
+ /** Absolute epoch-ms until which the pacer's own (internally-capped) cooldown holds. */
86
+ coolUntilMs: number;
87
+ /** Most recent server Retry-After, stored verbatim as an absolute epoch-ms deadline. */
88
+ claimedRetryAtMs: number;
89
+ }
25
90
 
26
91
  interface LeasedBlock {
27
92
  /**
@@ -55,29 +120,52 @@ interface Options {
55
120
  now?: () => number;
56
121
  sleep?: (ms: number) => Promise<void>;
57
122
  onStoreFailure?: (info: { bucketId: string; error: string }) => void;
123
+ /** Override the transport (tests inject a counting/faulting port). */
124
+ port?: AppRuntimeRatePort;
125
+ /**
126
+ * Deterministic jitter source in [0,1) for the ±10% wait jitter. Defaults to
127
+ * Math.random; tests pin it to assert the jittered sleep stays in bounds.
128
+ */
129
+ jitter?: () => number;
58
130
  }
59
131
 
60
132
  export class AppRuntimeRateStateBackend implements RateStateBackend {
61
- private readonly context: WorkerRuntimeApiContext;
133
+ /**
134
+ * DB-authoritative: the whole token bucket + AIMD runs in Postgres, so the
135
+ * Governor defers pacing to this backend (see `resolveAdaptivePacing` /
136
+ * `reportProviderBackpressure` in governor.ts).
137
+ */
138
+ readonly kind: RateStateBackendKind = 'app_runtime_postgres';
139
+ private readonly port: AppRuntimeRatePort;
62
140
  private readonly schedulerSchema: string | null;
63
141
  private readonly now: () => number;
64
142
  private readonly sleep: (ms: number) => Promise<void>;
143
+ private readonly jitter: () => number;
65
144
  private readonly onStoreFailure: (info: {
66
145
  bucketId: string;
67
146
  error: string;
68
147
  }) => void;
69
148
  private readonly blocks = new Map<string, LeasedBlock>();
70
- private readonly acquisitionTails = new Map<string, Promise<void>>();
149
+ private readonly refills = new Map<string, Promise<void>>();
71
150
  private readonly pendingReleases = new Map<string, PendingRelease>();
151
+ /**
152
+ * Acquirers currently parked on each bucket's refill (empty block, awaiting a
153
+ * grant). Demand-sizing reads this to request a block sized to the live wave
154
+ * instead of a fixed floor, so a 200-row map fans into ~one round trip.
155
+ */
156
+ private readonly waiters = new Map<string, number>();
157
+ /** Last-known server cooldown per bucket (surfaced for PROVIDER_EXHAUSTED). */
158
+ private readonly cooldowns = new Map<string, RateStateCooldown>();
72
159
  private pendingReleaseFailure: Error | null = null;
73
160
 
74
161
  constructor(context: WorkerRuntimeApiContext, options: Options = {}) {
75
- this.context = context;
162
+ this.port = options.port ?? contextRatePort(context);
76
163
  this.schedulerSchema = options.schedulerSchema?.trim() || null;
77
164
  this.now = options.now ?? (() => Date.now());
78
165
  this.sleep =
79
166
  options.sleep ??
80
167
  ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
168
+ this.jitter = options.jitter ?? Math.random;
81
169
  this.onStoreFailure =
82
170
  options.onStoreFailure ??
83
171
  ((info) => {
@@ -85,6 +173,51 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
85
173
  });
86
174
  }
87
175
 
176
+ /**
177
+ * Last-known server cooldown for a bucket, or null if the bucket has never
178
+ * reported one. Minimal read surface for the PROVIDER_EXHAUSTED task: it lets
179
+ * a caller learn how long the pacer is holding a provider off without issuing
180
+ * its own acquire.
181
+ */
182
+ cooldownFor(bucketId: string): RateStateCooldown | null {
183
+ return this.cooldowns.get(bucketId) ?? null;
184
+ }
185
+
186
+ /**
187
+ * Pre-check the last-known server cooldown for a bucket. If the pacer is
188
+ * already holding this provider past PROVIDER_EXHAUSTED_MAX_WAIT_MS, throw a
189
+ * typed {@link ProviderExhaustedError} before any store round trip. Prefers
190
+ * the server Retry-After (`claimedRetryAtMs`) as the retry deadline when it is
191
+ * later than the internally-capped `coolUntilMs` and the cooldown is active;
192
+ * otherwise reports `coolUntilMs`. No-op when the bucket has no live hold.
193
+ */
194
+ private throwIfPacerExhausted(bucketId: string): void {
195
+ const cooldown = this.cooldowns.get(bucketId);
196
+ if (!cooldown) return;
197
+ const now = this.now();
198
+ const coolActive = cooldown.coolUntilMs - now > 0;
199
+ if (!coolActive) return;
200
+ const coolWait = cooldown.coolUntilMs - now;
201
+ const claimActive = cooldown.claimedRetryAtMs - now > 0;
202
+ // Duration alone decides. Neither hold is over threshold → let it sleep.
203
+ if (
204
+ coolWait <= PROVIDER_EXHAUSTED_MAX_WAIT_MS &&
205
+ !(claimActive && cooldown.claimedRetryAtMs - now > PROVIDER_EXHAUSTED_MAX_WAIT_MS)
206
+ ) {
207
+ return;
208
+ }
209
+ // Retry deadline: the server Retry-After verbatim when present, else the
210
+ // pacer's own cooldown deadline.
211
+ const retryAtMs =
212
+ cooldown.claimedRetryAtMs > 0
213
+ ? cooldown.claimedRetryAtMs
214
+ : cooldown.coolUntilMs;
215
+ throw new ProviderExhaustedError({
216
+ provider: providerNameFromBucketId(bucketId),
217
+ retryAtMs,
218
+ });
219
+ }
220
+
88
221
  async acquire(input: {
89
222
  bucketId: string;
90
223
  rules: readonly PacingRule[];
@@ -93,125 +226,285 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
93
226
  const { bucketId, rules, signal } = input;
94
227
  if (rules.length === 0) return noopPacingPermit();
95
228
  this.throwPendingReleaseFailure();
229
+ // Pre-check: zero round trips when the pacer is already holding past the
230
+ // threshold. Fails fast with a typed PROVIDER_EXHAUSTED before touching the
231
+ // store.
232
+ this.throwIfPacerExhausted(bucketId);
96
233
 
97
234
  const ordered = [...rules].sort((a, b) => a.ruleId.localeCompare(b.ruleId));
98
235
  const rulesKey = rulesSignature(ordered);
99
- return await this.withBucketAcquisitionLock(bucketId, async () => {
236
+
237
+ // Drawing a cached permit is a synchronous, single-threaded critical
238
+ // section (`drawFromBlock` has no `await`), so concurrent acquires can never
239
+ // hand out the same permit — no lock is needed for the fast path. The ONLY
240
+ // thing that needs coordination is the block refill, which we single-flight
241
+ // per bucket: one network round-trip (and any window-exhausted sleep) serves
242
+ // the whole waiting wave, then everyone re-draws. This replaces the old
243
+ // per-bucket lock that held across the RPC + up-to-5s sleep and thereby
244
+ // serialized every map row behind one sleeping acquirer.
245
+ while (true) {
246
+ if (signal?.aborted) {
247
+ throw signal.reason instanceof Error
248
+ ? signal.reason
249
+ : new Error('Rate-state acquire aborted.');
250
+ }
100
251
  const localPermit = this.drawFromBlock(bucketId, rulesKey);
101
252
  if (localPermit) return localPermit;
253
+ // Count this acquirer as waiting on the refill so the single-flight refill
254
+ // can size its block to the live wave. Decremented once a block lands (or
255
+ // the refill errors) — see the finally in `refillBlock`.
256
+ this.waiters.set(bucketId, (this.waiters.get(bucketId) ?? 0) + 1);
257
+ try {
258
+ await this.refillBlock(bucketId, ordered, rulesKey, signal);
259
+ } finally {
260
+ const remaining = (this.waiters.get(bucketId) ?? 1) - 1;
261
+ if (remaining > 0) this.waiters.set(bucketId, remaining);
262
+ else this.waiters.delete(bucketId);
263
+ }
264
+ }
265
+ }
266
+
267
+ /**
268
+ * How many permits to request on the next refill for a bucket. Buckets with any
269
+ * `maxConcurrency` rule stay at the fixed floor block: each permit pins a
270
+ * durable concurrency slot, so over-reserving would strand real slots. Pure-rate
271
+ * buckets demand-size: clamp the live waiter count to
272
+ * [block floor, window budget] where the window budget is the smallest
273
+ * `requestsPerWindow` across the pure-rate rules (never reserve more than one
274
+ * window can hold). A 200-row wave then lands in ~one round trip.
275
+ */
276
+ private requestedForBucket(
277
+ bucketId: string,
278
+ rules: readonly PacingRule[],
279
+ ): number {
280
+ const floor = APP_RUNTIME_RATE_STATE_LEASE_BLOCK_SIZE;
281
+ if (rules.some((rule) => rule.maxConcurrency != null)) return floor;
282
+ const windowBudget = Math.min(
283
+ ...rules
284
+ .map((rule) => rule.requestsPerWindow)
285
+ .filter((value) => Number.isFinite(value) && value > 0),
286
+ );
287
+ if (!Number.isFinite(windowBudget) || windowBudget <= 0) return floor;
288
+ const waiters = this.waiters.get(bucketId) ?? 1;
289
+ return Math.max(floor, Math.min(waiters, Math.trunc(windowBudget)));
290
+ }
291
+
292
+ /**
293
+ * Sleep duration for a granted=0 refill: the server's `waitMs`, spread by ±10%
294
+ * jitter and capped by MAX_ACQUIRE_SLEEP_MS. Always at least 1ms.
295
+ */
296
+ private jitteredWaitMs(serverWaitMs: number): number {
297
+ const base = Math.max(1, Math.min(serverWaitMs, MAX_ACQUIRE_SLEEP_MS));
298
+ // jitter() in [0,1) → factor in [1-0.1, 1+0.1).
299
+ const factor = 1 + (this.jitter() * 2 - 1) * ACQUIRE_WAIT_JITTER_FRACTION;
300
+ return Math.max(
301
+ 1,
302
+ Math.min(Math.round(base * factor), MAX_ACQUIRE_SLEEP_MS),
303
+ );
304
+ }
305
+
306
+ /** Store the server's advisory cooldown for a bucket if the response carried one. */
307
+ private recordCooldown(
308
+ bucketId: string,
309
+ response: { coolUntilMs?: number; claimedRetryAtMs?: number },
310
+ ): void {
311
+ const coolUntilMs = Number(response.coolUntilMs);
312
+ const claimedRetryAtMs = Number(response.claimedRetryAtMs);
313
+ if (!Number.isFinite(coolUntilMs) && !Number.isFinite(claimedRetryAtMs)) {
314
+ return;
315
+ }
316
+ this.cooldowns.set(bucketId, {
317
+ coolUntilMs: Number.isFinite(coolUntilMs) ? Math.max(0, coolUntilMs) : 0,
318
+ claimedRetryAtMs: Number.isFinite(claimedRetryAtMs)
319
+ ? Math.max(0, claimedRetryAtMs)
320
+ : 0,
321
+ });
322
+ }
323
+
324
+ /**
325
+ * Build a PROVIDER_EXHAUSTED error for a granted=0 refill response. Retry
326
+ * deadline: the server Retry-After (`claimedRetryAtMs`) verbatim when present,
327
+ * else the pacer cooldown deadline (`coolUntilMs`), else `now + waitMs`.
328
+ */
329
+ private providerExhaustedFromResponse(
330
+ bucketId: string,
331
+ response: { waitMs: number; coolUntilMs?: number; claimedRetryAtMs?: number },
332
+ ): ProviderExhaustedError {
333
+ const claimedRetryAtMs = Number(response.claimedRetryAtMs);
334
+ const coolUntilMs = Number(response.coolUntilMs);
335
+ const retryAtMs =
336
+ Number.isFinite(claimedRetryAtMs) && claimedRetryAtMs > 0
337
+ ? claimedRetryAtMs
338
+ : Number.isFinite(coolUntilMs) && coolUntilMs > 0
339
+ ? coolUntilMs
340
+ : this.now() + Math.max(0, response.waitMs);
341
+ return new ProviderExhaustedError({
342
+ provider: providerNameFromBucketId(bucketId),
343
+ retryAtMs,
344
+ });
345
+ }
346
+
347
+ /**
348
+ * Single-flight a block refill for a bucket. Concurrent acquires that find the
349
+ * block empty all await the SAME in-flight refill instead of each issuing its
350
+ * own serialized round-trip, so the RPC and any window-exhausted sleep happen
351
+ * once per wave. Rejection (fail-closed store failure) propagates to every
352
+ * waiter.
353
+ */
354
+ private refillBlock(
355
+ bucketId: string,
356
+ ordered: PacingRule[],
357
+ rulesKey: string,
358
+ signal?: AbortSignal,
359
+ ): Promise<void> {
360
+ const existing = this.refills.get(bucketId);
361
+ if (existing) return existing;
362
+ const promise = this.performRefill(
363
+ bucketId,
364
+ ordered,
365
+ rulesKey,
366
+ signal,
367
+ ).finally(() => {
368
+ if (this.refills.get(bucketId) === promise) {
369
+ this.refills.delete(bucketId);
370
+ }
371
+ });
372
+ this.refills.set(bucketId, promise);
373
+ return promise;
374
+ }
102
375
 
103
- let consecutiveFailures = 0;
104
- while (true) {
105
- if (signal?.aborted) {
106
- throw signal.reason instanceof Error
107
- ? signal.reason
108
- : new Error('Rate-state acquire aborted.');
376
+ private async performRefill(
377
+ bucketId: string,
378
+ ordered: PacingRule[],
379
+ rulesKey: string,
380
+ signal?: AbortSignal,
381
+ ): Promise<void> {
382
+ let consecutiveFailures = 0;
383
+ // Yield once before sizing the first request so the whole synchronously-
384
+ // scheduled acquire wave finishes parking (incrementing `waiters`) before
385
+ // `requestedForBucket` reads the count. Without this, the single-flight
386
+ // refill fires while only the first acquirer has registered, and demand
387
+ // sizing collapses to the floor. Single-flight still holds — this is the
388
+ // one and only refill for the bucket; peers just enqueue behind it.
389
+ let firstPass = true;
390
+ while (true) {
391
+ // Abort is enforced per-acquirer in the acquire loop; a shared refill just
392
+ // stops early so waiters re-check their own signal.
393
+ if (signal?.aborted) return;
394
+ if (firstPass) {
395
+ firstPass = false;
396
+ await Promise.resolve();
397
+ if (signal?.aborted) return;
398
+ }
399
+ try {
400
+ const response = await this.port.acquire({
401
+ bucketId,
402
+ // Stamp adaptiveMaxRps on default-pacing rules so the DB row's ceiling
403
+ // matches the old in-memory AIMD range.
404
+ rules: withAdaptiveMaxRps(ordered),
405
+ requested: this.requestedForBucket(bucketId, ordered),
406
+ schedulerSchema: this.schedulerSchema,
407
+ });
408
+ this.recordCooldown(bucketId, response);
409
+ if (response.granted > 0) {
410
+ // Merge the whole grant into the block and let acquirers re-draw. No
411
+ // permit is handed out here, so nothing is double-drawn.
412
+ this.mergeGrantedBlock(bucketId, ordered, rulesKey, response);
413
+ return;
109
414
  }
110
- try {
111
- const response = await acquireRateStateViaAppRuntime(this.context, {
112
- bucketId,
113
- rules: ordered,
114
- requested: APP_RUNTIME_RATE_STATE_LEASE_BLOCK_SIZE,
115
- schedulerSchema: this.schedulerSchema,
116
- });
117
- consecutiveFailures = 0;
118
- if (response.granted > 0) {
119
- const hasConcurrency = ordered.some(
120
- (rule) => rule.maxConcurrency != null,
121
- );
122
- const leaseIds = Array.isArray(response.leaseIds)
123
- ? response.leaseIds.filter(
124
- (leaseId): leaseId is string =>
125
- typeof leaseId === 'string' && leaseId.trim().length > 0,
126
- )
127
- : [];
128
- if (hasConcurrency && leaseIds.length !== response.granted) {
129
- throw new Error(
130
- `Rate-state store granted ${response.granted} concurrency permits for ${bucketId} with ${leaseIds.length} lease tokens.`,
131
- );
132
- }
133
- if (!hasConcurrency && leaseIds.length > 0) {
134
- throw new Error(
135
- `Rate-state store returned ${leaseIds.length} lease tokens for pure-rate bucket ${bucketId}.`,
136
- );
137
- }
138
- const currentLeaseId = hasConcurrency ? leaseIds[0]! : null;
139
- const remainingLeaseIds = hasConcurrency ? leaseIds.slice(1) : [];
140
- const remainingWindowPermits = hasConcurrency
141
- ? 0
142
- : response.granted - 1;
143
- if (remainingLeaseIds.length > 0 || remainingWindowPermits > 0) {
144
- this.mergeBlock(
145
- bucketId,
146
- remainingLeaseIds,
147
- remainingWindowPermits,
148
- rulesKey,
149
- ordered,
150
- );
151
- }
152
- return this.permitFor(bucketId, ordered, currentLeaseId);
153
- }
154
- await this.sleep(
155
- Math.max(1, Math.min(response.waitMs, MAX_ACQUIRE_SLEEP_MS)),
156
- );
157
- } catch (error) {
158
- // Fail closed. A degraded rate-state store must not grant unlimited
159
- // provider calls; after this bounded retry ladder the run fails loudly.
160
- consecutiveFailures += 1;
161
- const normalized = normalizeError(error);
162
- this.onStoreFailure({ bucketId, error: normalized.message });
163
- if (consecutiveFailures > STORE_FAILURE_RETRY_DELAYS_MS.length) {
164
- throw new Error(
165
- `Rate-state store unavailable for ${bucketId}; failing closed after ${consecutiveFailures} acquire attempts: ${normalized.message}`,
166
- );
167
- }
168
- await this.sleep(
169
- STORE_FAILURE_RETRY_DELAYS_MS[consecutiveFailures - 1] ?? 250,
415
+ // Live path: the server told us to wait past the threshold. Fail fast
416
+ // with a typed PROVIDER_EXHAUSTED instead of sleeping toward it. The
417
+ // rejection propagates to every waiter parked on this single-flight
418
+ // refill (they all skip with no spend). Duration alone decides.
419
+ if (response.waitMs > PROVIDER_EXHAUSTED_MAX_WAIT_MS) {
420
+ throw this.providerExhaustedFromResponse(bucketId, response);
421
+ }
422
+ // Window exhausted: sleep the SERVER's waitMs once for the whole waiting
423
+ // wave (not per row, and not a guess), then return so acquirers re-draw
424
+ // and single-flight another refill if still empty. ±10% jitter so a
425
+ // synchronized wave re-probes at spread-out times; capped by
426
+ // MAX_ACQUIRE_SLEEP_MS.
427
+ await this.sleep(this.jitteredWaitMs(response.waitMs));
428
+ return;
429
+ } catch (error) {
430
+ // PROVIDER_EXHAUSTED is a deliberate skip, not a store failure: it must
431
+ // escape this fail-closed retry ladder verbatim so it reaches the
432
+ // acquire caller (and, via step-miss handling, the row).
433
+ if (error instanceof ProviderExhaustedError) throw error;
434
+ // Fail closed. A degraded rate-state store must not grant unlimited
435
+ // provider calls; after this bounded retry ladder the run fails loudly.
436
+ consecutiveFailures += 1;
437
+ const normalized = normalizeError(error);
438
+ this.onStoreFailure({ bucketId, error: normalized.message });
439
+ if (consecutiveFailures > STORE_FAILURE_RETRY_DELAYS_MS.length) {
440
+ throw new Error(
441
+ `Rate-state store unavailable for ${bucketId}; failing closed after ${consecutiveFailures} acquire attempts: ${normalized.message}`,
170
442
  );
171
443
  }
444
+ await this.sleep(
445
+ STORE_FAILURE_RETRY_DELAYS_MS[consecutiveFailures - 1] ?? 250,
446
+ );
172
447
  }
173
- });
448
+ }
174
449
  }
175
450
 
176
- private async withBucketAcquisitionLock<T>(
451
+ /**
452
+ * Validate a granted acquire response and merge its permits into the bucket's
453
+ * local block. Concurrency rules carry one lease token per permit (release
454
+ * semantics); pure-rate rules carry untracked window permits the store already
455
+ * decremented.
456
+ */
457
+ private mergeGrantedBlock(
177
458
  bucketId: string,
178
- operation: () => Promise<T>,
179
- ): Promise<T> {
180
- const previous = this.acquisitionTails.get(bucketId) ?? Promise.resolve();
181
- let unlock!: () => void;
182
- const gate = new Promise<void>((resolve) => {
183
- unlock = resolve;
184
- });
185
- const tail = previous.then(() => gate);
186
- this.acquisitionTails.set(bucketId, tail);
187
- await previous;
188
- try {
189
- return await operation();
190
- } finally {
191
- unlock();
192
- if (this.acquisitionTails.get(bucketId) === tail) {
193
- this.acquisitionTails.delete(bucketId);
194
- }
459
+ ordered: PacingRule[],
460
+ rulesKey: string,
461
+ response: { granted: number; leaseIds?: unknown },
462
+ ): void {
463
+ const hasConcurrency = ordered.some((rule) => rule.maxConcurrency != null);
464
+ const leaseIds = Array.isArray(response.leaseIds)
465
+ ? response.leaseIds.filter(
466
+ (leaseId): leaseId is string =>
467
+ typeof leaseId === 'string' && leaseId.trim().length > 0,
468
+ )
469
+ : [];
470
+ if (hasConcurrency && leaseIds.length !== response.granted) {
471
+ throw new Error(
472
+ `Rate-state store granted ${response.granted} concurrency permits for ${bucketId} with ${leaseIds.length} lease tokens.`,
473
+ );
474
+ }
475
+ if (!hasConcurrency && leaseIds.length > 0) {
476
+ throw new Error(
477
+ `Rate-state store returned ${leaseIds.length} lease tokens for pure-rate bucket ${bucketId}.`,
478
+ );
195
479
  }
480
+ this.mergeBlock(
481
+ bucketId,
482
+ hasConcurrency ? leaseIds : [],
483
+ hasConcurrency ? 0 : response.granted,
484
+ rulesKey,
485
+ ordered,
486
+ );
196
487
  }
197
488
 
198
489
  penalize(input: { bucketId: string; cooldownMs: number }): void {
199
490
  if (input.cooldownMs <= 0) return;
200
491
  this.drainBlock(input.bucketId);
201
- void penalizeRateStateViaAppRuntime(this.context, {
202
- bucketId: input.bucketId,
203
- cooldownMs: input.cooldownMs,
204
- schedulerSchema: this.schedulerSchema,
205
- }).catch((error) => {
206
- const normalized = normalizeError(error);
207
- this.pendingReleaseFailure = new Error(
208
- `Rate-state penalize failed for ${input.bucketId}: ${normalized.message}`,
209
- );
210
- this.onStoreFailure({
492
+ void this.port
493
+ .penalize({
211
494
  bucketId: input.bucketId,
212
- error: normalized.message,
495
+ cooldownMs: input.cooldownMs,
496
+ 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
+ });
213
507
  });
214
- });
215
508
  }
216
509
 
217
510
  private permitFor(
@@ -328,7 +621,7 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
328
621
  while (pending.leaseIds.length > 0) {
329
622
  const leaseIds = pending.leaseIds.splice(0);
330
623
  try {
331
- await releaseRateStateViaAppRuntime(this.context, {
624
+ await this.port.release({
332
625
  bucketId: pending.bucketId,
333
626
  rules: pending.rules,
334
627
  leaseIds,
@@ -364,6 +657,40 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
364
657
  }
365
658
  }
366
659
 
660
+ /**
661
+ * Multiplier applied to a default-pacing rule's `requestsPerWindow` to derive the
662
+ * `adaptiveMaxRps` ceiling. Matches the DB seed's `DEFAULT_PACING_MAX_RPS_MULTIPLIER`
663
+ * and the old in-memory AIMD range (default RPS ramps up to base * 25).
664
+ */
665
+ const DEFAULT_PACING_ADAPTIVE_MAX_RPS_MULTIPLIER = 25;
666
+
667
+ /**
668
+ * A rule id shaped `default:<toolId>` is the undeclared-tool default-pacing rule
669
+ * minted by `defaultPacingForTool` in governor.ts. Only those ramp above their
670
+ * base rate; declared providers pin at their contractual rate.
671
+ */
672
+ function isDefaultPacingRuleId(ruleId: string): boolean {
673
+ return ruleId.startsWith('default:');
674
+ }
675
+
676
+ /**
677
+ * Stamp `adaptiveMaxRps` onto default-pacing rules so the DB row's ceiling matches
678
+ * the old in-memory AIMD range (base * 25). Declared rules pass through untouched
679
+ * (the DB pins them at base). The field is extra on the wire; the server's
680
+ * `normalizeRules` reads it, everyone else ignores it.
681
+ */
682
+ function withAdaptiveMaxRps(rules: readonly PacingRule[]): PacingRule[] {
683
+ return rules.map((rule) =>
684
+ isDefaultPacingRuleId(rule.ruleId)
685
+ ? ({
686
+ ...rule,
687
+ adaptiveMaxRps:
688
+ rule.requestsPerWindow * DEFAULT_PACING_ADAPTIVE_MAX_RPS_MULTIPLIER,
689
+ } as PacingRule)
690
+ : rule,
691
+ );
692
+ }
693
+
367
694
  function rulesSignature(rules: readonly PacingRule[]): string {
368
695
  return [...rules]
369
696
  .map(
@@ -282,6 +282,15 @@ export function createPlayExecutionGovernor(
282
282
  const budgetState = input.budgetState ?? new InMemoryBudgetStateBackend();
283
283
  const providerByTool = new Map<string, string>();
284
284
 
285
+ // When the rate-state backend owns pacing authoritatively (the Absurd/Node
286
+ // app-runtime Postgres pacer runs the whole token bucket + AIMD in the row),
287
+ // the Governor's in-memory adaptive admission becomes a passthrough: it only
288
+ // SEEDS declared/default rules and never halves/ramps them here. Backpressure
289
+ // is routed solely to `rateState.penalize` so the DB row is the single source
290
+ // of pacing truth. The coordinator and in-memory backends leave `kind`
291
+ // undefined and keep the in-memory adaptive admission (byte-identical).
292
+ const dbAuthoritativePacing = input.rateState.kind === 'app_runtime_postgres';
293
+
285
294
  const bucketId = (provider: string) => `${input.scope.orgId}:${provider}`;
286
295
 
287
296
  async function resolveAdaptivePacing(toolId: string): Promise<{
@@ -291,6 +300,14 @@ export function createPlayExecutionGovernor(
291
300
  const resolvedPacing = await input.resolvePacing(toolId);
292
301
  const declaredPacing =
293
302
  resolvedPacing && resolvedPacing.rules.length > 0 ? resolvedPacing : null;
303
+ if (dbAuthoritativePacing) {
304
+ // Passthrough: hand the DB-backed pacer the declared rules verbatim (or
305
+ // the default-pacing shape when undeclared). No in-memory admission — the
306
+ // row is authoritative.
307
+ const pacing = declaredPacing ?? defaultPacingForTool(toolId, policy);
308
+ providerByTool.set(toolId, pacing.provider);
309
+ return pacing;
310
+ }
294
311
  const pacing = adaptiveAdmission.resolvePacing({
295
312
  orgId: input.scope.orgId,
296
313
  toolId,
@@ -531,11 +548,16 @@ export function createPlayExecutionGovernor(
531
548
  resolveRowConcurrency(policy, requested),
532
549
 
533
550
  reportProviderBackpressure(bp) {
534
- adaptiveAdmission.observeProviderBackpressure({
535
- orgId: input.scope.orgId,
536
- provider: bp.provider,
537
- retryAfterMs: bp.retryAfterMs,
538
- });
551
+ // DB-authoritative pacer: route backpressure ONLY to the row via
552
+ // penalize. The in-memory adaptive admission must not also halve, or the
553
+ // provider would be throttled twice (once in the row, once in memory).
554
+ if (!dbAuthoritativePacing) {
555
+ adaptiveAdmission.observeProviderBackpressure({
556
+ orgId: input.scope.orgId,
557
+ provider: bp.provider,
558
+ retryAfterMs: bp.retryAfterMs,
559
+ });
560
+ }
539
561
  input.rateState.penalize({
540
562
  bucketId: bucketId(bp.provider),
541
563
  cooldownMs: bp.retryAfterMs,