deepline 0.1.201 → 0.1.203

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 (33) hide show
  1. package/dist/bundling-sources/apps/play-runner-workers/src/entry.ts +30 -9
  2. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/map-chunk-plan.ts +25 -0
  3. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/tool-dispatch.ts +139 -124
  4. package/dist/bundling-sources/sdk/src/release.ts +2 -2
  5. package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +17 -2
  6. package/dist/bundling-sources/shared_libs/play-runtime/bounded-dispatch.ts +52 -0
  7. package/dist/bundling-sources/shared_libs/play-runtime/context.ts +154 -23
  8. package/dist/bundling-sources/shared_libs/play-runtime/coordinator-headers.ts +10 -0
  9. package/dist/bundling-sources/shared_libs/play-runtime/ctx-types.ts +16 -0
  10. package/dist/bundling-sources/shared_libs/play-runtime/execution-capabilities.ts +121 -0
  11. package/dist/bundling-sources/shared_libs/play-runtime/governor/adaptive-admission.ts +6 -1
  12. package/dist/bundling-sources/shared_libs/play-runtime/governor/app-runtime-budget-state-backend.ts +22 -0
  13. package/dist/bundling-sources/shared_libs/play-runtime/governor/app-runtime-rate-state-backend.ts +86 -19
  14. package/dist/bundling-sources/shared_libs/play-runtime/governor/block-reserving-budget-state-backend.ts +141 -0
  15. package/dist/bundling-sources/shared_libs/play-runtime/governor/budget-state-backend.ts +43 -0
  16. package/dist/bundling-sources/shared_libs/play-runtime/governor/governor.ts +129 -41
  17. package/dist/bundling-sources/shared_libs/play-runtime/protocol.ts +40 -2
  18. package/dist/bundling-sources/shared_libs/play-runtime/receipt-completion-sink.ts +92 -33
  19. package/dist/bundling-sources/shared_libs/play-runtime/resource-governor.ts +13 -5
  20. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-lifecycle.ts +122 -120
  21. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-payload-transport.ts +10 -8
  22. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona.ts +140 -32
  23. package/dist/bundling-sources/shared_libs/play-runtime/runtime-api-paths.ts +2 -0
  24. package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +225 -29
  25. package/dist/bundling-sources/shared_libs/play-runtime/runtime-scheduler-topology.ts +26 -0
  26. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backend.ts +21 -0
  27. package/dist/bundling-sources/shared_libs/plays/dataset.ts +32 -10
  28. package/dist/cli/index.js +335 -77
  29. package/dist/cli/index.mjs +341 -83
  30. package/dist/index.js +3 -2
  31. package/dist/index.mjs +3 -2
  32. package/package.json +1 -1
  33. package/dist/bundling-sources/shared_libs/play-runtime/adaptive-tool-dispatcher.ts +0 -355
@@ -23,7 +23,20 @@ const MAX_ACQUIRE_SLEEP_MS = 5_000;
23
23
  const STORE_FAILURE_RETRY_DELAYS_MS = [100, 250] as const;
24
24
 
25
25
  interface LeasedBlock {
26
- remaining: number;
26
+ /**
27
+ * Concurrency reservations, one per locally-cached permit. Only rules with a
28
+ * `maxConcurrency` mint lease tokens; each id maps to a durable slot that must
29
+ * be released (or TTL-expired) so the shared concurrency count drains.
30
+ */
31
+ leaseIds: string[];
32
+ /**
33
+ * Pure-rate (RPS-only) permits with no concurrency slot behind them. The
34
+ * server decremented the provider window by this count already, so drawing
35
+ * one locally is a no-op that needs no release token. Tracked separately from
36
+ * `leaseIds` so a pure-RPS block (the common `defaultPacingForTool` shape)
37
+ * actually amortizes its whole grant across acquires instead of burning it.
38
+ */
39
+ windowPermits: number;
27
40
  expiresAt: number;
28
41
  rulesKey: string;
29
42
  rules: PacingRule[];
@@ -92,10 +105,43 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
92
105
  });
93
106
  consecutiveFailures = 0;
94
107
  if (response.granted > 0) {
95
- const remaining = response.granted - 1;
96
- if (remaining > 0)
97
- this.mergeBlock(bucketId, remaining, rulesKey, ordered);
98
- return this.permitFor(bucketId, ordered);
108
+ const hasConcurrency = ordered.some(
109
+ (rule) => rule.maxConcurrency != null,
110
+ );
111
+ const leaseIds = Array.isArray(response.leaseIds)
112
+ ? response.leaseIds.filter(
113
+ (leaseId): leaseId is string =>
114
+ typeof leaseId === 'string' && leaseId.trim().length > 0,
115
+ )
116
+ : [];
117
+ if (hasConcurrency && leaseIds.length !== response.granted) {
118
+ throw new Error(
119
+ `Rate-state store granted ${response.granted} concurrency permits for ${bucketId} with ${leaseIds.length} lease tokens.`,
120
+ );
121
+ }
122
+ if (!hasConcurrency && leaseIds.length > 0) {
123
+ throw new Error(
124
+ `Rate-state store returned ${leaseIds.length} lease tokens for pure-rate bucket ${bucketId}.`,
125
+ );
126
+ }
127
+ // Serve this acquire from the grant, then cache the rest so the whole
128
+ // block amortizes into a single round trip. Concurrency rules draw a
129
+ // lease token (needs release); pure-rate rules draw a window permit
130
+ // (already decremented server-side, no release).
131
+ const currentLeaseId = hasConcurrency ? leaseIds[0]! : null;
132
+ const remainingLeaseIds = hasConcurrency ? leaseIds.slice(1) : [];
133
+ const remainingWindowPermits = hasConcurrency
134
+ ? 0
135
+ : response.granted - 1;
136
+ if (remainingLeaseIds.length > 0 || remainingWindowPermits > 0)
137
+ this.mergeBlock(
138
+ bucketId,
139
+ remainingLeaseIds,
140
+ remainingWindowPermits,
141
+ rulesKey,
142
+ ordered,
143
+ );
144
+ return this.permitFor(bucketId, ordered, currentLeaseId);
99
145
  }
100
146
  await this.sleep(
101
147
  Math.max(1, Math.min(response.waitMs, MAX_ACQUIRE_SLEEP_MS)),
@@ -137,7 +183,11 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
137
183
  });
138
184
  }
139
185
 
140
- private permitFor(bucketId: string, rules: PacingRule[]): PacingPermit {
186
+ private permitFor(
187
+ bucketId: string,
188
+ rules: PacingRule[],
189
+ leaseId: string | null,
190
+ ): PacingPermit {
141
191
  if (!rules.some((rule) => rule.maxConcurrency != null)) {
142
192
  return noopPacingPermit();
143
193
  }
@@ -146,14 +196,15 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
146
196
  release: () => {
147
197
  if (released) return;
148
198
  released = true;
149
- this.releaseReserved(bucketId, rules, 1);
199
+ this.releaseReserved(bucketId, rules, leaseId ? [leaseId] : []);
150
200
  },
151
201
  };
152
202
  }
153
203
 
154
204
  private mergeBlock(
155
205
  bucketId: string,
156
- remaining: number,
206
+ leaseIds: string[],
207
+ windowPermits: number,
157
208
  rulesKey: string,
158
209
  rules: PacingRule[],
159
210
  ): void {
@@ -164,14 +215,16 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
164
215
  existing.rulesKey === rulesKey &&
165
216
  existing.expiresAt > this.now()
166
217
  ) {
167
- existing.remaining += remaining;
218
+ existing.leaseIds.push(...leaseIds);
219
+ existing.windowPermits += windowPermits;
168
220
  existing.expiresAt = Math.min(existing.expiresAt, freshExpiresAt);
169
221
  return;
170
222
  }
171
223
  if (existing)
172
- this.releaseReserved(bucketId, existing.rules, existing.remaining);
224
+ this.releaseReserved(bucketId, existing.rules, existing.leaseIds);
173
225
  this.blocks.set(bucketId, {
174
- remaining,
226
+ leaseIds,
227
+ windowPermits,
175
228
  expiresAt: freshExpiresAt,
176
229
  rulesKey,
177
230
  rules,
@@ -186,32 +239,46 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
186
239
  if (!block) return null;
187
240
  if (block.rulesKey !== rulesKey || block.expiresAt <= this.now()) {
188
241
  this.blocks.delete(bucketId);
189
- this.releaseReserved(bucketId, block.rules, block.remaining);
242
+ // Unused window permits are forfeited on TTL expiry (the server already
243
+ // decremented the provider window for them, and the LEASE_BLOCK_TTL_MS
244
+ // window is short so at most one block's worth is stranded per bucket).
245
+ // Concurrency leases must be released so the shared slot count drains.
246
+ this.releaseReserved(bucketId, block.rules, block.leaseIds);
190
247
  return null;
191
248
  }
192
- block.remaining -= 1;
193
- if (block.remaining <= 0) this.blocks.delete(bucketId);
194
- return this.permitFor(bucketId, block.rules);
249
+ const hasConcurrency = block.rules.some(
250
+ (rule) => rule.maxConcurrency != null,
251
+ );
252
+ // Concurrency blocks draw a lease token (release semantics); pure-rate
253
+ // blocks draw an untracked window permit that the server already accounted.
254
+ const leaseId = hasConcurrency ? (block.leaseIds.pop() ?? null) : null;
255
+ if (!hasConcurrency && block.windowPermits > 0) block.windowPermits -= 1;
256
+ if (block.leaseIds.length === 0 && block.windowPermits === 0)
257
+ this.blocks.delete(bucketId);
258
+ return this.permitFor(bucketId, block.rules, leaseId);
195
259
  }
196
260
 
197
261
  private drainBlock(bucketId: string): void {
198
262
  const block = this.blocks.get(bucketId);
199
263
  if (!block) return;
200
264
  this.blocks.delete(bucketId);
201
- this.releaseReserved(bucketId, block.rules, block.remaining);
265
+ this.releaseReserved(bucketId, block.rules, block.leaseIds);
202
266
  }
203
267
 
204
268
  private releaseReserved(
205
269
  bucketId: string,
206
270
  rules: readonly PacingRule[],
207
- count: number,
271
+ leaseIds: readonly string[],
208
272
  ): void {
209
- if (count <= 0 || !rules.some((rule) => rule.maxConcurrency != null))
273
+ if (
274
+ leaseIds.length === 0 ||
275
+ !rules.some((rule) => rule.maxConcurrency != null)
276
+ )
210
277
  return;
211
278
  void releaseRateStateViaAppRuntime(this.context, {
212
279
  bucketId,
213
280
  rules: [...rules],
214
- count,
281
+ leaseIds: [...leaseIds],
215
282
  schedulerSchema: this.schedulerSchema,
216
283
  }).catch((error) => {
217
284
  const normalized = normalizeError(error);
@@ -0,0 +1,141 @@
1
+ import type {
2
+ PlayRunnerBudgetCharge,
3
+ PlayRunnerBudgetChargeResult,
4
+ } from '../protocol';
5
+ import type { BudgetStateBackend } from './budget-state-backend';
6
+ import { BudgetStateLimitError } from './budget-state-backend';
7
+
8
+ const RESERVATION_BLOCKS: Record<string, number> = {
9
+ toolCall: 256,
10
+ retry: 16,
11
+ waterfallStep: 256,
12
+ playCall: 16,
13
+ descendant: 16,
14
+ };
15
+
16
+ export type BudgetStateChargePort = (input: {
17
+ rootRunId: string;
18
+ reservationId: string;
19
+ charges: readonly PlayRunnerBudgetCharge[];
20
+ }) => Promise<PlayRunnerBudgetChargeResult>;
21
+
22
+ /**
23
+ * Reserves durable budget in coarse blocks while charging each local operation
24
+ * exactly once. The durable port remains the authority across sandboxes and
25
+ * isolates; this cache only removes one RPC from the hot path per operation.
26
+ */
27
+ export class BlockReservingBudgetStateBackend implements BudgetStateBackend {
28
+ private readonly remaining = new Map<string, number>();
29
+ private chargeTail: Promise<void> = Promise.resolve();
30
+
31
+ constructor(private readonly chargePort: BudgetStateChargePort) {}
32
+
33
+ charge(input: {
34
+ rootRunId: string;
35
+ charges: readonly PlayRunnerBudgetCharge[];
36
+ }): Promise<PlayRunnerBudgetChargeResult> {
37
+ const result = this.chargeTail.then(() => this.chargeSerial(input));
38
+ this.chargeTail = result.then(
39
+ () => undefined,
40
+ () => undefined,
41
+ );
42
+ return result;
43
+ }
44
+
45
+ private async chargeSerial(input: {
46
+ rootRunId: string;
47
+ charges: readonly PlayRunnerBudgetCharge[];
48
+ }): Promise<PlayRunnerBudgetChargeResult> {
49
+ const reservations: PlayRunnerBudgetCharge[] = [];
50
+ const exactReservations: PlayRunnerBudgetCharge[] = [];
51
+ const previousRemaining = new Map<string, number | undefined>();
52
+ for (const charge of input.charges) {
53
+ const remaining = this.remaining.get(charge.key) ?? 0;
54
+ previousRemaining.set(charge.key, this.remaining.get(charge.key));
55
+ if (remaining >= charge.amount) {
56
+ this.remaining.set(charge.key, remaining - charge.amount);
57
+ continue;
58
+ }
59
+ const shortfall = charge.amount - remaining;
60
+ const block = Math.min(
61
+ charge.limit,
62
+ Math.max(shortfall, RESERVATION_BLOCKS[charge.key] ?? 1),
63
+ );
64
+ reservations.push({ ...charge, amount: block });
65
+ exactReservations.push({ ...charge, amount: shortfall });
66
+ this.remaining.set(charge.key, block - shortfall);
67
+ }
68
+ if (reservations.length === 0) return { counters: {} };
69
+
70
+ const restorePreviousRemaining = () => {
71
+ for (const [key, remaining] of previousRemaining) {
72
+ if (remaining === undefined) this.remaining.delete(key);
73
+ else this.remaining.set(key, remaining);
74
+ }
75
+ };
76
+ const request = async (
77
+ charges: readonly PlayRunnerBudgetCharge[],
78
+ reservationId: string,
79
+ ) =>
80
+ await this.chargePort({
81
+ rootRunId: input.rootRunId,
82
+ reservationId,
83
+ charges,
84
+ });
85
+ const requestWithIdempotentRetry = async (
86
+ charges: readonly PlayRunnerBudgetCharge[],
87
+ reservationId: string,
88
+ ) => {
89
+ try {
90
+ return await request(charges, reservationId);
91
+ } catch (error) {
92
+ const normalized = normalizeBudgetError(error);
93
+ if (normalized instanceof BudgetStateLimitError) throw normalized;
94
+ return await request(charges, reservationId).catch((retryError) => {
95
+ throw normalizeBudgetError(retryError);
96
+ });
97
+ }
98
+ };
99
+ try {
100
+ return await requestWithIdempotentRetry(
101
+ reservations,
102
+ crypto.randomUUID(),
103
+ );
104
+ } catch (error) {
105
+ const normalized = normalizeBudgetError(error);
106
+ if (
107
+ normalized instanceof BudgetStateLimitError &&
108
+ reservations.some(
109
+ (reserved, index) =>
110
+ reserved.amount >
111
+ (exactReservations[index]?.amount ?? reserved.amount),
112
+ )
113
+ ) {
114
+ for (const reservation of exactReservations) {
115
+ this.remaining.set(reservation.key, 0);
116
+ }
117
+ try {
118
+ return await requestWithIdempotentRetry(
119
+ exactReservations,
120
+ crypto.randomUUID(),
121
+ );
122
+ } catch (exactError) {
123
+ restorePreviousRemaining();
124
+ throw normalizeBudgetError(exactError);
125
+ }
126
+ }
127
+ restorePreviousRemaining();
128
+ throw normalized;
129
+ }
130
+ }
131
+ }
132
+
133
+ function normalizeBudgetError(error: unknown): Error {
134
+ const normalized = error instanceof Error ? error : new Error(String(error));
135
+ const match = /Play execution (.+) budget exceeded \((\d+)\/(\d+)\)/i.exec(
136
+ normalized.message,
137
+ );
138
+ return match
139
+ ? new BudgetStateLimitError(match[1]!, Number(match[2]), Number(match[3]))
140
+ : normalized;
141
+ }
@@ -0,0 +1,43 @@
1
+ import type {
2
+ PlayRunnerBudgetCharge,
3
+ PlayRunnerBudgetChargeResult,
4
+ } from '../protocol';
5
+
6
+ export interface BudgetStateBackend {
7
+ charge(input: {
8
+ rootRunId: string;
9
+ charges: readonly PlayRunnerBudgetCharge[];
10
+ }): Promise<PlayRunnerBudgetChargeResult>;
11
+ }
12
+
13
+ export class BudgetStateLimitError extends Error {
14
+ constructor(
15
+ readonly key: string,
16
+ readonly observed: number,
17
+ readonly limit: number,
18
+ ) {
19
+ super(`Play execution ${key} budget exceeded (${observed}/${limit}).`);
20
+ this.name = 'BudgetStateLimitError';
21
+ }
22
+ }
23
+
24
+ export class InMemoryBudgetStateBackend implements BudgetStateBackend {
25
+ private readonly counters = new Map<string, Record<string, number>>();
26
+
27
+ async charge(input: {
28
+ rootRunId: string;
29
+ charges: readonly PlayRunnerBudgetCharge[];
30
+ }): Promise<PlayRunnerBudgetChargeResult> {
31
+ const current = this.counters.get(input.rootRunId) ?? {};
32
+ const next = { ...current };
33
+ for (const charge of input.charges) {
34
+ const observed = (next[charge.key] ?? 0) + charge.amount;
35
+ if (observed > charge.limit) {
36
+ throw new BudgetStateLimitError(charge.key, observed, charge.limit);
37
+ }
38
+ next[charge.key] = observed;
39
+ }
40
+ this.counters.set(input.rootRunId, next);
41
+ return { counters: { ...next } };
42
+ }
43
+ }
@@ -24,6 +24,11 @@ import {
24
24
  createInMemoryAdaptiveAdmission,
25
25
  type RuntimeAdaptiveAdmission,
26
26
  } from './adaptive-admission';
27
+ import {
28
+ BudgetStateLimitError,
29
+ InMemoryBudgetStateBackend,
30
+ type BudgetStateBackend,
31
+ } from './budget-state-backend';
27
32
 
28
33
  export interface WorkLease {
29
34
  /** Free the slot / pacing permit. Idempotent. MUST be called in a finally. */
@@ -121,7 +126,7 @@ export interface PlayExecutionGovernor {
121
126
  suggestedParallelism(toolId: string, fallback: number): Promise<number>;
122
127
 
123
128
  /** Increment a monotonic budget counter; throws GovernorBudgetError on breach. */
124
- chargeBudget(kind: BudgetKind, amount?: number): void;
129
+ chargeBudget(kind: BudgetKind, amount?: number): Promise<void>;
125
130
 
126
131
  /**
127
132
  * Reserve depth + per-parent + descendant budget for a child play and return
@@ -140,7 +145,7 @@ export interface PlayExecutionGovernor {
140
145
  forkChild(input: {
141
146
  childPlayName: string;
142
147
  childRunId: string;
143
- }): GovernanceSnapshot;
148
+ }): Promise<GovernanceSnapshot>;
144
149
 
145
150
  /** Effective row concurrency: explicit request clamped to [1, rowMax], else default. */
146
151
  resolveRowConcurrency(requested?: number): number;
@@ -156,6 +161,11 @@ export interface PlayExecutionGovernor {
156
161
  provider: string;
157
162
  latencyMs?: number | null;
158
163
  }): void;
164
+ /** Feed success back using the same tool-to-provider resolution as acquire. */
165
+ observeToolSuccess(input: {
166
+ toolId: string;
167
+ latencyMs?: number | null;
168
+ }): void;
159
169
 
160
170
  snapshot(): GovernanceSnapshot;
161
171
  }
@@ -164,6 +174,7 @@ interface GovernorInput {
164
174
  adapter: AdapterId;
165
175
  scope: { orgId: string; rootRunId: string };
166
176
  rateState: RateStateBackend;
177
+ budgetState?: BudgetStateBackend;
167
178
  resolvePacing: PacingResolver;
168
179
  adaptiveAdmission?: RuntimeAdaptiveAdmission;
169
180
  resume?: GovernanceSnapshot;
@@ -258,6 +269,8 @@ export function createPlayExecutionGovernor(
258
269
  initialRequestsPerSecond: policy.pacing.defaultProviderRequestsPerSecond,
259
270
  maxRequestsPerSecond: policy.pacing.suggestedMaxParallelism,
260
271
  });
272
+ const budgetState = input.budgetState ?? new InMemoryBudgetStateBackend();
273
+ const providerByTool = new Map<string, string>();
261
274
 
262
275
  const bucketId = (provider: string) => `${input.scope.orgId}:${provider}`;
263
276
 
@@ -268,63 +281,85 @@ export function createPlayExecutionGovernor(
268
281
  const resolvedPacing = await input.resolvePacing(toolId);
269
282
  const declaredPacing =
270
283
  resolvedPacing && resolvedPacing.rules.length > 0 ? resolvedPacing : null;
271
- return adaptiveAdmission.resolvePacing({
284
+ const pacing = adaptiveAdmission.resolvePacing({
272
285
  orgId: input.scope.orgId,
273
286
  toolId,
274
287
  declaredPacing,
275
288
  fallbackPacing: defaultPacingForTool(toolId, policy),
276
289
  });
290
+ providerByTool.set(toolId, pacing.provider);
291
+ return pacing;
277
292
  }
278
293
 
279
- function chargeBudget(kind: BudgetKind, amount = 1): void {
294
+ const budgetLimit = (kind: BudgetKind): number => {
295
+ switch (kind) {
296
+ case 'playCall':
297
+ return policy.budgets.maxPlayCallCount;
298
+ case 'toolCall':
299
+ return policy.budgets.maxToolCallCount;
300
+ case 'retry':
301
+ return policy.budgets.maxRetryCount;
302
+ case 'descendant':
303
+ return policy.budgets.maxDescendants;
304
+ case 'waterfallStep':
305
+ return policy.budgets.maxWaterfallStepExecutions;
306
+ }
307
+ };
308
+
309
+ async function reserveBudgets(
310
+ charges: Array<{ kind: BudgetKind; amount: number }>,
311
+ ): Promise<void> {
312
+ try {
313
+ await budgetState.charge({
314
+ rootRunId: state.rootRunId,
315
+ charges: charges.map((charge) => ({
316
+ key: charge.kind,
317
+ amount: charge.amount,
318
+ limit: budgetLimit(charge.kind),
319
+ })),
320
+ });
321
+ } catch (error) {
322
+ if (error instanceof BudgetStateLimitError) {
323
+ throw new GovernorBudgetError(
324
+ error.key as BudgetKind,
325
+ error.observed,
326
+ error.limit,
327
+ );
328
+ }
329
+ throw error;
330
+ }
331
+ }
332
+
333
+ async function chargeBudget(kind: BudgetKind, amount = 1): Promise<void> {
334
+ const current =
335
+ kind === 'playCall'
336
+ ? state.playCallCount
337
+ : kind === 'toolCall'
338
+ ? state.toolCallCount
339
+ : kind === 'retry'
340
+ ? state.retryCount
341
+ : kind === 'descendant'
342
+ ? state.descendantCount
343
+ : state.waterfallStepExecutions;
344
+ if (current + amount > budgetLimit(kind)) {
345
+ throw new GovernorBudgetError(kind, current + amount, budgetLimit(kind));
346
+ }
347
+ await reserveBudgets([{ kind, amount }]);
280
348
  switch (kind) {
281
349
  case 'playCall':
282
350
  state.playCallCount += amount;
283
- if (state.playCallCount > policy.budgets.maxPlayCallCount)
284
- throw new GovernorBudgetError(
285
- 'playCall',
286
- state.playCallCount,
287
- policy.budgets.maxPlayCallCount,
288
- );
289
351
  return;
290
352
  case 'toolCall':
291
353
  state.toolCallCount += amount;
292
- if (state.toolCallCount > policy.budgets.maxToolCallCount)
293
- throw new GovernorBudgetError(
294
- 'toolCall',
295
- state.toolCallCount,
296
- policy.budgets.maxToolCallCount,
297
- );
298
354
  return;
299
355
  case 'retry':
300
356
  state.retryCount += amount;
301
- if (state.retryCount > policy.budgets.maxRetryCount)
302
- throw new GovernorBudgetError(
303
- 'retry',
304
- state.retryCount,
305
- policy.budgets.maxRetryCount,
306
- );
307
357
  return;
308
358
  case 'descendant':
309
359
  state.descendantCount += amount;
310
- if (state.descendantCount > policy.budgets.maxDescendants)
311
- throw new GovernorBudgetError(
312
- 'descendant',
313
- state.descendantCount,
314
- policy.budgets.maxDescendants,
315
- );
316
360
  return;
317
361
  case 'waterfallStep':
318
362
  state.waterfallStepExecutions += amount;
319
- if (
320
- state.waterfallStepExecutions >
321
- policy.budgets.maxWaterfallStepExecutions
322
- )
323
- throw new GovernorBudgetError(
324
- 'waterfallStep',
325
- state.waterfallStepExecutions,
326
- policy.budgets.maxWaterfallStepExecutions,
327
- );
328
363
  return;
329
364
  }
330
365
  }
@@ -356,7 +391,7 @@ export function createPlayExecutionGovernor(
356
391
  // 3. charge the budget only once the call is actually cleared to run, so a
357
392
  // failed/aborted acquisition never permanently consumes tool budget.
358
393
  try {
359
- chargeBudget('toolCall');
394
+ await chargeBudget('toolCall');
360
395
  } catch (error) {
361
396
  permit.release();
362
397
  slot.release();
@@ -388,7 +423,7 @@ export function createPlayExecutionGovernor(
388
423
 
389
424
  chargeBudget,
390
425
 
391
- forkChild(childInput) {
426
+ async forkChild(childInput) {
392
427
  if (state.ancestryPlayIds.includes(childInput.childPlayName)) {
393
428
  const chain = [...state.ancestryPlayIds, childInput.childPlayName].join(
394
429
  ' -> ',
@@ -410,11 +445,53 @@ export function createPlayExecutionGovernor(
410
445
  nextParent,
411
446
  policy.budgets.maxChildPlayCallsPerParent,
412
447
  );
448
+ if (state.playCallCount + 1 > policy.budgets.maxPlayCallCount)
449
+ throw new GovernorBudgetError(
450
+ 'playCall',
451
+ state.playCallCount + 1,
452
+ policy.budgets.maxPlayCallCount,
453
+ );
454
+ if (state.descendantCount + 1 > policy.budgets.maxDescendants)
455
+ throw new GovernorBudgetError(
456
+ 'descendant',
457
+ state.descendantCount + 1,
458
+ policy.budgets.maxDescendants,
459
+ );
413
460
  // Charge the run-wide play/descendant budgets on the parent. Charged at
414
461
  // fork time (not after the caller's child-play slot acquire) and never
415
462
  // refunded if that acquire fails — see the forkChild interface doc.
416
- chargeBudget('playCall');
417
- chargeBudget('descendant');
463
+ try {
464
+ await budgetState.charge({
465
+ rootRunId: state.rootRunId,
466
+ charges: [
467
+ {
468
+ key: 'playCall',
469
+ amount: 1,
470
+ limit: policy.budgets.maxPlayCallCount,
471
+ },
472
+ {
473
+ key: 'descendant',
474
+ amount: 1,
475
+ limit: policy.budgets.maxDescendants,
476
+ },
477
+ {
478
+ key: `childPerParent:${state.currentRunId}`,
479
+ amount: 1,
480
+ limit: policy.budgets.maxChildPlayCallsPerParent,
481
+ },
482
+ ],
483
+ });
484
+ } catch (error) {
485
+ if (error instanceof BudgetStateLimitError) {
486
+ const budget = error.key.startsWith('childPerParent:')
487
+ ? 'childPerParent'
488
+ : (error.key as BudgetKind);
489
+ throw new GovernorBudgetError(budget, error.observed, error.limit);
490
+ }
491
+ throw error;
492
+ }
493
+ state.playCallCount += 1;
494
+ state.descendantCount += 1;
418
495
  state.parentChildCalls[parentKey] = nextParent;
419
496
  // Child seeds from the parent's accumulated counters → lineage-global budget.
420
497
  return {
@@ -456,6 +533,17 @@ export function createPlayExecutionGovernor(
456
533
  });
457
534
  },
458
535
 
536
+ observeToolSuccess(success) {
537
+ const provider =
538
+ providerByTool.get(success.toolId) ??
539
+ defaultPacingForTool(success.toolId, policy).provider;
540
+ adaptiveAdmission.observeProviderSuccess({
541
+ orgId: input.scope.orgId,
542
+ provider,
543
+ latencyMs: success.latencyMs,
544
+ });
545
+ },
546
+
459
547
  snapshot: () => ({
460
548
  ...state,
461
549
  ancestryPlayIds: [...state.ancestryPlayIds],