deepline 0.1.179 → 0.1.180

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.
@@ -155,10 +155,10 @@ import {
155
155
  import type { RuntimeStepReceipt } from '../../../shared_libs/play-runtime/ctx-types';
156
156
  import {
157
157
  canReclaimFailedWorkerToolReceipt,
158
- canReclaimTimedOutWorkerToolReceipt,
159
158
  markWorkerToolReceiptResultCached,
160
159
  markWorkerToolReceiptResultExecution,
161
160
  planWorkerToolReceiptGroups,
161
+ reclaimRunningReceiptGroupAfterWait,
162
162
  resolveWorkerToolReceiptGroupWaitMaxAttempts,
163
163
  resolveWorkerToolRuntimeTimeoutMs,
164
164
  } from './runtime/tool-receipts';
@@ -216,6 +216,22 @@ import {
216
216
  } from '../../../shared_libs/play-runtime/csv-rename';
217
217
  import { coordinatorRequestHeaders } from '../../../shared_libs/play-runtime/coordinator-headers';
218
218
  import { normalizePlayRunFailure } from '../../../shared_libs/play-runtime/run-failure';
219
+ import {
220
+ createRuntimePersistenceLatch,
221
+ isRuntimePersistenceCircuitOpenError,
222
+ partitionDispatchWaves,
223
+ PROVIDER_DISPATCH_WAVE_SIZE,
224
+ RuntimePersistenceCircuitOpenError,
225
+ shouldShortenCompleteReceiptLadder,
226
+ tripRuntimePersistenceLatch,
227
+ type RuntimePersistenceLatch,
228
+ } from '../../../shared_libs/play-runtime/persistence-latch';
229
+ import {
230
+ createReceiptSalvageBuffer,
231
+ receiptSalvageFailureSuffix,
232
+ type ReceiptSalvage,
233
+ type ReceiptSalvageBuffer,
234
+ } from '../../../shared_libs/play-runtime/receipt-salvage';
219
235
  import { createSecretRedactionContext } from '../../../shared_libs/play-runtime/secret-redaction';
220
236
  import {
221
237
  assertNoSecretTaint,
@@ -1741,10 +1757,21 @@ function isRunFatalWorkerRowError(error: unknown): boolean {
1741
1757
  return (
1742
1758
  isCoordinatorProgressAuthorizationError(error) ||
1743
1759
  isRowIsolationExemptError(error) ||
1744
- isRuntimeReceiptPersistenceError(error)
1760
+ isRuntimeReceiptPersistenceError(error) ||
1761
+ isRuntimePersistenceCircuitOpenError(error)
1745
1762
  );
1746
1763
  }
1747
1764
 
1765
+ /**
1766
+ * completeReceipt salvage retry ladder. On a persistence failure after a billed
1767
+ * call we retry the receipt completion past the server-side 5s connect window:
1768
+ * 1 initial attempt + 3 retries at 1s / 3s / 9s (single isolate — no jitter).
1769
+ * Bounded by the run deadline (`runtimeDeadlineMs`); we stop early unless there
1770
+ * is at least one backoff's worth plus headroom of budget left.
1771
+ */
1772
+ const COMPLETE_RECEIPT_RETRY_BACKOFFS_MS = [1_000, 3_000, 9_000] as const;
1773
+ const COMPLETE_RECEIPT_RETRY_MIN_HEADROOM_MS = 10_000;
1774
+
1748
1775
  // Fallback batch-chunk parallelism when a tool declares no provider rate hints.
1749
1776
  // Matches the prior hardcoded `Math.min(4, ...)` so undeclared providers keep
1750
1777
  // their previous batching behavior; declared providers tighten via the
@@ -1820,8 +1847,96 @@ class WorkerToolBatchScheduler {
1820
1847
  private readonly receiptStore?: WorkerRuntimeReceiptStore,
1821
1848
  private readonly allowLocalRetryReceipts = false,
1822
1849
  private readonly runtimeDeadlineMs?: number,
1850
+ // Map-wide (or ctx-wide for the root scheduler) persistence-failure circuit
1851
+ // breaker. Shared by every row worker so the first persistence failure
1852
+ // halts NEW provider dispatch across the whole map.
1853
+ private readonly latch: RuntimePersistenceLatch = createRuntimePersistenceLatch(),
1854
+ // Run-scoped buffer for orphaned billed results whose receipt could not be
1855
+ // completed even after the retry ladder.
1856
+ private readonly salvage?: ReceiptSalvageBuffer,
1823
1857
  ) {}
1824
1858
 
1859
+ /**
1860
+ * Run `completeReceipt`/`completeReceipts` with the salvage retry ladder.
1861
+ * Retries a receipt-completion failure past the server-side 5s connect window
1862
+ * so a transient blip does not orphan a billed result. Bounded by the run
1863
+ * deadline; skips retries when the latch has already burned a ladder in this
1864
+ * map so many in-flight orphans do not each serialize a long loop.
1865
+ */
1866
+ private async completeReceiptWithRetryLadder<T>(
1867
+ run: () => Promise<T>,
1868
+ onGiveUp?: (attempts: number) => void,
1869
+ ): Promise<T> {
1870
+ let attempt = 0;
1871
+ // A ladder that has already started retrying keeps its full budget even if
1872
+ // a peer trips the latch mid-flight — only FRESH entrants collapse once the
1873
+ // failure is confirmed. This is what guarantees the first ladder rides out
1874
+ // a genuine transient blip.
1875
+ let committedToFullLadder = false;
1876
+ const giveUp = (error: unknown): never => {
1877
+ this.latch.latchRetryBudgetExhausted = true;
1878
+ onGiveUp?.(attempt);
1879
+ throw error;
1880
+ };
1881
+ while (true) {
1882
+ try {
1883
+ return await run();
1884
+ } catch (error) {
1885
+ attempt += 1;
1886
+ if (
1887
+ shouldShortenCompleteReceiptLadder(this.latch, committedToFullLadder) ||
1888
+ attempt > COMPLETE_RECEIPT_RETRY_BACKOFFS_MS.length ||
1889
+ this.abortSignal?.aborted
1890
+ ) {
1891
+ return giveUp(error);
1892
+ }
1893
+ const backoffMs = COMPLETE_RECEIPT_RETRY_BACKOFFS_MS[attempt - 1]!;
1894
+ // resolveRuntimeDeadlineRemainingMs THROWS WorkflowAbortError once the
1895
+ // run deadline has passed — the correct loud outcome (there is no
1896
+ // separate per-chunk step timeout). Route that throw through giveUp so
1897
+ // the billed result is still salvaged and the ladder budget flag is
1898
+ // set: a billed output must never lose its durable trace because the
1899
+ // clock ran out mid-ladder.
1900
+ let remainingMs: number;
1901
+ try {
1902
+ remainingMs = resolveRuntimeDeadlineRemainingMs(
1903
+ this.runtimeDeadlineMs,
1904
+ );
1905
+ } catch (deadlineError) {
1906
+ return giveUp(deadlineError);
1907
+ }
1908
+ if (remainingMs <= backoffMs + COMPLETE_RECEIPT_RETRY_MIN_HEADROOM_MS) {
1909
+ return giveUp(error);
1910
+ }
1911
+ // Past every give-up gate: this ladder is now committed to the full
1912
+ // budget and must not be collapsed by a peer tripping the latch mid-sleep.
1913
+ committedToFullLadder = true;
1914
+ await sleepWorkerMs(backoffMs);
1915
+ }
1916
+ }
1917
+ }
1918
+
1919
+ /**
1920
+ * Record a billed-but-unpersisted tool output into the run-scoped salvage
1921
+ * buffer. Called only after the completeReceipt ladder is exhausted, with the
1922
+ * exact serialized payload a receipt would have stored.
1923
+ */
1924
+ private recordReceiptSalvage(input: {
1925
+ claimed: ClaimedWorkerToolBatchRequest;
1926
+ output: unknown;
1927
+ attempts: number;
1928
+ }): void {
1929
+ if (!this.salvage || !input.claimed.receiptKey) return;
1930
+ this.salvage.push({
1931
+ receiptKey: input.claimed.receiptKey,
1932
+ toolId: input.claimed.request.toolId,
1933
+ cacheKey: input.claimed.request.cacheKey,
1934
+ output: input.output,
1935
+ completeAttempts: input.attempts,
1936
+ firstErrorAt: nowMs(),
1937
+ });
1938
+ }
1939
+
1825
1940
  /**
1826
1941
  * Report a provider 429 / Retry-After back into the Governor's shared pacer
1827
1942
  * so future acquires for this (org, provider) bucket back off across all
@@ -2054,37 +2169,73 @@ class WorkerToolBatchScheduler {
2054
2169
  );
2055
2170
  return [];
2056
2171
  }
2057
- let waitError: unknown = null;
2058
- try {
2059
- const receipt = await this.waitForDurableToolReceipt({
2060
- receiptKey: input.receiptKey,
2061
- maxAttempts: resolveWorkerToolReceiptGroupWaitMaxAttempts(
2062
- input.group,
2063
- (groupRequest) => groupRequest.receiptWaitMaxAttempts,
2064
- ),
2065
- });
2066
- await this.resolveCompletedDurableToolReceiptGroup({
2067
- group: input.group,
2068
- receiptKey: input.receiptKey,
2069
- receipt,
2070
- source: 'in_flight',
2071
- });
2072
- return [];
2073
- } catch (error) {
2074
- if (!(error instanceof RuntimeReceiptWaitTimeoutError)) {
2075
- this.rejectRawRequests(input.group, error);
2076
- return [];
2077
- }
2078
- waitError = error;
2079
- }
2080
- if (
2081
- !canReclaimTimedOutWorkerToolReceipt({
2082
- ownerRunId: input.runningReceipt.runId,
2083
- currentRunId: this.req.runId,
2084
- updatedAt: input.runningReceipt.updatedAt,
2085
- })
2086
- ) {
2087
- this.rejectRawRequests(input.group, waitError);
2172
+ // A `running` receipt left by a DIFFERENT run whose last write is stale will
2173
+ // never be completed by a live worker. Polling it burns one service-binding
2174
+ // subrequest per tick, up to the receipt's wait budget (~1320) — many such
2175
+ // orphans on a resume exhaust Cloudflare's per-invocation subrequest budget
2176
+ // and kill the run. The orchestrator reclaims those immediately (skipping the
2177
+ // poll) and re-executes, matching the "on resume it reclaims and re-executes"
2178
+ // contract; live/fresh/same-run owners still poll before reclaiming. Kept as
2179
+ // an injected helper so the skip-poll decision is unit-testable without
2180
+ // loading this cloudflare:workers Worker entrypoint.
2181
+ return await reclaimRunningReceiptGroupAfterWait<ClaimedWorkerToolBatchRequest>(
2182
+ {
2183
+ ownership: {
2184
+ ownerRunId: input.runningReceipt.runId,
2185
+ currentRunId: this.req.runId,
2186
+ updatedAt: input.runningReceipt.updatedAt,
2187
+ },
2188
+ timeoutError: new RuntimeReceiptWaitTimeoutError(input.receiptKey),
2189
+ poll: async () => {
2190
+ try {
2191
+ const receipt = await this.waitForDurableToolReceipt({
2192
+ receiptKey: input.receiptKey,
2193
+ maxAttempts: resolveWorkerToolReceiptGroupWaitMaxAttempts(
2194
+ input.group,
2195
+ (groupRequest) => groupRequest.receiptWaitMaxAttempts,
2196
+ ),
2197
+ });
2198
+ await this.resolveCompletedDurableToolReceiptGroup({
2199
+ group: input.group,
2200
+ receiptKey: input.receiptKey,
2201
+ receipt,
2202
+ source: 'in_flight',
2203
+ });
2204
+ return { kind: 'completed' };
2205
+ } catch (error) {
2206
+ if (error instanceof RuntimeReceiptWaitTimeoutError) {
2207
+ return { kind: 'timed_out', error };
2208
+ }
2209
+ return { kind: 'errored', error };
2210
+ }
2211
+ },
2212
+ reclaim: (rejectionOnFallthrough) =>
2213
+ this.forceReclaimRunningDurableToolReceiptGroup({
2214
+ group: input.group,
2215
+ request,
2216
+ followers,
2217
+ receiptKey: input.receiptKey,
2218
+ rejectionOnFallthrough,
2219
+ }),
2220
+ reject: (error) => this.rejectRawRequests(input.group, error),
2221
+ },
2222
+ );
2223
+ }
2224
+
2225
+ /**
2226
+ * Steal a `running` receipt (reclaimRunning) and route by claim disposition.
2227
+ * Shared by the post-poll-timeout reclaim and the dead-owner fast path, so
2228
+ * both dispose of a reclaimed/reused/failed receipt identically.
2229
+ */
2230
+ private async forceReclaimRunningDurableToolReceiptGroup(input: {
2231
+ group: WorkerToolBatchRequest[];
2232
+ request: WorkerToolBatchRequest;
2233
+ followers: WorkerToolBatchRequest[];
2234
+ receiptKey: string;
2235
+ rejectionOnFallthrough: unknown;
2236
+ }): Promise<ClaimedWorkerToolBatchRequest[]> {
2237
+ if (!this.receiptStore) {
2238
+ this.rejectRawRequests(input.group, input.rejectionOnFallthrough);
2088
2239
  return [];
2089
2240
  }
2090
2241
  let claim: WorkerRuntimeReceiptClaim;
@@ -2100,7 +2251,13 @@ class WorkerToolBatchScheduler {
2100
2251
  return [];
2101
2252
  }
2102
2253
  if (claim.disposition === 'claimed') {
2103
- return [{ request, receiptKey: input.receiptKey, followers }];
2254
+ return [
2255
+ {
2256
+ request: input.request,
2257
+ receiptKey: input.receiptKey,
2258
+ followers: input.followers,
2259
+ },
2260
+ ];
2104
2261
  }
2105
2262
  if (claim.disposition === 'reused') {
2106
2263
  await this.resolveCompletedDurableToolReceiptGroup({
@@ -2118,7 +2275,7 @@ class WorkerToolBatchScheduler {
2118
2275
  failedReceipt: claim.receipt,
2119
2276
  });
2120
2277
  }
2121
- this.rejectRawRequests(input.group, waitError);
2278
+ this.rejectRawRequests(input.group, input.rejectionOnFallthrough);
2122
2279
  return [];
2123
2280
  }
2124
2281
 
@@ -2203,6 +2360,7 @@ class WorkerToolBatchScheduler {
2203
2360
  await this.failDurableToolRequest(claimed, error);
2204
2361
  return error;
2205
2362
  } catch (receiptError) {
2363
+ tripRuntimePersistenceLatch(this.latch, receiptError);
2206
2364
  return new RuntimeReceiptPersistenceError(
2207
2365
  'Tool call failed and durable receipt could not be marked failed',
2208
2366
  [error, receiptError],
@@ -2354,6 +2512,9 @@ class WorkerToolBatchScheduler {
2354
2512
  claimedRequests.push({ request, receiptKey, followers });
2355
2513
  }
2356
2514
  } catch (error) {
2515
+ // A receipt claim failure means the tenant Postgres is already
2516
+ // unwritable: trip the breaker so remaining groups/rows stop dispatching.
2517
+ tripRuntimePersistenceLatch(this.latch, error);
2357
2518
  for (const group of receiptGroupPlan.durableGroups) {
2358
2519
  this.rejectRawRequests(group.requests, error);
2359
2520
  }
@@ -2372,12 +2533,25 @@ class WorkerToolBatchScheduler {
2372
2533
  kind: 'live',
2373
2534
  receiptKey: claimed.receiptKey,
2374
2535
  });
2375
- const completed = await this.receiptStore.completeReceipt({
2376
- playName: this.req.playName,
2377
- runId: this.req.runId,
2378
- key: claimed.receiptKey,
2379
- output: serializeDurableStepValue(ownerResult),
2380
- });
2536
+ const serializedOutput = serializeDurableStepValue(ownerResult);
2537
+ const completed = await this.completeReceiptWithRetryLadder(
2538
+ () =>
2539
+ this.receiptStore!.completeReceipt({
2540
+ playName: this.req.playName,
2541
+ runId: this.req.runId,
2542
+ key: claimed.receiptKey!,
2543
+ output: serializedOutput,
2544
+ }),
2545
+ // Ladder exhausted: the call billed but the receipt could not land.
2546
+ // Salvage the exact serialized output onto the run record before the
2547
+ // caller turns this into a RuntimeReceiptPersistenceError.
2548
+ (attempts) =>
2549
+ this.recordReceiptSalvage({
2550
+ claimed,
2551
+ output: serializedOutput,
2552
+ attempts,
2553
+ }),
2554
+ );
2381
2555
  if (
2382
2556
  completed &&
2383
2557
  (completed.status === 'completed' || completed.status === 'skipped') &&
@@ -2453,14 +2627,32 @@ class WorkerToolBatchScheduler {
2453
2627
  );
2454
2628
  return results;
2455
2629
  }
2456
- const completed = await this.receiptStore.completeReceipts({
2457
- playName: this.req.playName,
2458
- receipts: durableEntries.map((entry) => ({
2459
- runId: this.req.runId,
2460
- key: entry.claimed.receiptKey!,
2461
- output: serializeDurableStepValue(entry.ownerResult),
2462
- })),
2463
- });
2630
+ const serializedByEntry = durableEntries.map((entry) =>
2631
+ serializeDurableStepValue(entry.ownerResult),
2632
+ );
2633
+ const completed = await this.completeReceiptWithRetryLadder(
2634
+ () =>
2635
+ this.receiptStore!.completeReceipts!({
2636
+ playName: this.req.playName,
2637
+ receipts: durableEntries.map((entry, entryIndex) => ({
2638
+ runId: this.req.runId,
2639
+ key: entry.claimed.receiptKey!,
2640
+ output: serializedByEntry[entryIndex],
2641
+ })),
2642
+ }),
2643
+ // Ladder exhausted: every entry in this batch billed but could not be
2644
+ // marked completed. Salvage each serialized output before the caller
2645
+ // turns this into a RuntimeReceiptPersistenceError.
2646
+ (attempts) => {
2647
+ durableEntries.forEach((entry, entryIndex) => {
2648
+ this.recordReceiptSalvage({
2649
+ claimed: entry.claimed,
2650
+ output: serializedByEntry[entryIndex],
2651
+ attempts,
2652
+ });
2653
+ });
2654
+ },
2655
+ );
2464
2656
  durableEntries.forEach((entry, resultIndex) => {
2465
2657
  results[entry.index] = this.completedDurableToolRequestResult({
2466
2658
  claimed: entry.claimed,
@@ -2514,6 +2706,17 @@ class WorkerToolBatchScheduler {
2514
2706
  toolId: string,
2515
2707
  requests: WorkerToolBatchRequest[],
2516
2708
  ): Promise<void> {
2709
+ // Circuit breaker, gated BEFORE the receipt claim: never-dispatched rows
2710
+ // get NO receipt (not even `running`), so a re-run claims them fresh and
2711
+ // executes them exactly once.
2712
+ if (this.latch.tripped) {
2713
+ this.latch.preventedCallCount += requests.length;
2714
+ this.rejectRawRequests(
2715
+ requests,
2716
+ new RuntimePersistenceCircuitOpenError(this.latch),
2717
+ );
2718
+ return;
2719
+ }
2517
2720
  const { claimedRequests, deferredClaimedRequests } =
2518
2721
  await this.prepareDurableToolRequests(requests);
2519
2722
  await this.executeClaimedToolRequests(
@@ -2549,9 +2752,23 @@ class WorkerToolBatchScheduler {
2549
2752
  claimedRequests.length < 2
2550
2753
  ) {
2551
2754
  const groupStartedAt = nowMs();
2552
- await Promise.all(
2553
- claimedRequests.map(async (claimed) => {
2755
+ const dispatchOne = async (
2756
+ claimed: ClaimedWorkerToolBatchRequest,
2757
+ ): Promise<void> => {
2554
2758
  const { request } = claimed;
2759
+ // Circuit breaker: the latch may have tripped after this group was
2760
+ // claimed but before this particular call started. Do NOT call the
2761
+ // provider. This row has a `running` receipt (claimed, never
2762
+ // executed); on resume it reclaims and re-executes — safe, it never
2763
+ // billed.
2764
+ if (this.latch.tripped) {
2765
+ this.latch.preventedCallCount += 1 + claimed.followers.length;
2766
+ this.rejectRequests(
2767
+ claimed,
2768
+ new RuntimePersistenceCircuitOpenError(this.latch),
2769
+ );
2770
+ return;
2771
+ }
2555
2772
  const toolContract = await this.resolvePacing(toolId).catch(
2556
2773
  () => null,
2557
2774
  );
@@ -2590,6 +2807,7 @@ class WorkerToolBatchScheduler {
2590
2807
  await this.completeDurableToolRequest(claimed, result),
2591
2808
  );
2592
2809
  } catch (receiptError) {
2810
+ tripRuntimePersistenceLatch(this.latch, receiptError);
2593
2811
  this.rejectRequests(
2594
2812
  claimed,
2595
2813
  new RuntimeReceiptPersistenceError(
@@ -2600,8 +2818,32 @@ class WorkerToolBatchScheduler {
2600
2818
  } finally {
2601
2819
  slot.release();
2602
2820
  }
2603
- }),
2604
- );
2821
+ };
2822
+ // Bounded dispatch waves: an unbatched tool group can carry a whole chunk's
2823
+ // rows (enrich compiles to one ~245-row chunk). Dispatching them all at
2824
+ // once billed every call before the first receipt-completion failure could
2825
+ // trip the latch (the 250/250 incident). Instead, dispatch one wave, AWAIT
2826
+ // it so any completion failure trips the latch, then read the latch before
2827
+ // the next wave — remaining waves are prevented (counted + rejected) exactly
2828
+ // like the mid-wave gate above. The wave calls stay fully concurrent; the
2829
+ // only added cost on the healthy path is one latch read per wave.
2830
+ for (const wave of partitionDispatchWaves(
2831
+ claimedRequests,
2832
+ () => 1,
2833
+ PROVIDER_DISPATCH_WAVE_SIZE,
2834
+ )) {
2835
+ if (this.latch.tripped) {
2836
+ for (const claimed of wave) {
2837
+ this.latch.preventedCallCount += 1 + claimed.followers.length;
2838
+ this.rejectRequests(
2839
+ claimed,
2840
+ new RuntimePersistenceCircuitOpenError(this.latch),
2841
+ );
2842
+ }
2843
+ continue;
2844
+ }
2845
+ await Promise.all(wave.map(dispatchOne));
2846
+ }
2605
2847
  recordRunnerPerfTrace({
2606
2848
  req: this.req,
2607
2849
  phase: 'runner.tool.group',
@@ -2639,6 +2881,7 @@ class WorkerToolBatchScheduler {
2639
2881
  await this.failDurableToolRequests(claimedRequests, error),
2640
2882
  settleRequest: (claimed, result) => this.settleRequests(claimed, result),
2641
2883
  rejectRequest: (claimed, error) => this.rejectRequests(claimed, error),
2884
+ latch: this.latch,
2642
2885
  });
2643
2886
  recordRunnerPerfTrace({
2644
2887
  req: this.req,
@@ -2696,6 +2939,7 @@ async function executeBatchedWorkerToolGroup(input: {
2696
2939
  request: ClaimedWorkerToolBatchRequest,
2697
2940
  error: unknown,
2698
2941
  ) => void;
2942
+ latch: RuntimePersistenceLatch;
2699
2943
  }): Promise<void> {
2700
2944
  const compiledBatches = compileRequestsWithStrategy({
2701
2945
  requests: input.requests,
@@ -2723,7 +2967,20 @@ async function executeBatchedWorkerToolGroup(input: {
2723
2967
  1,
2724
2968
  Math.min(input.suggestedParallelism, compiledBatches.length || 1),
2725
2969
  ),
2970
+ // Bounded dispatch wave: cap the provider calls (counted by batch member
2971
+ // size) a single chunk dispatches before the latch is read again, so a
2972
+ // persistence failure prevents the rest of a large batch group instead of
2973
+ // billing it all. A single over-budget batch still forms its own wave.
2974
+ maxChunkWeight: PROVIDER_DISPATCH_WAVE_SIZE,
2975
+ weightOf: (batch) => batch.memberRequests.length,
2726
2976
  execute: async (batch) => {
2977
+ // Circuit breaker: once a persistence failure has occurred in this map,
2978
+ // do NOT dispatch this batch's provider call. The chunk plumbing rejects
2979
+ // its member requests with the latch error.
2980
+ if (input.latch.tripped) {
2981
+ input.latch.preventedCallCount += batch.memberRequests.length;
2982
+ throw new RuntimePersistenceCircuitOpenError(input.latch);
2983
+ }
2727
2984
  const toolContract = await input
2728
2985
  .resolveToolContract(batch.batchOperation)
2729
2986
  .catch(() => null);
@@ -2772,6 +3029,7 @@ async function executeBatchedWorkerToolGroup(input: {
2772
3029
  try {
2773
3030
  await input.failRequests(entry.request.memberRequests, entry.error);
2774
3031
  } catch (receiptError) {
3032
+ tripRuntimePersistenceLatch(input.latch, receiptError);
2775
3033
  rejection = new RuntimeReceiptPersistenceError(
2776
3034
  'Tool call failed and durable receipts could not be marked failed',
2777
3035
  [entry.error, receiptError],
@@ -2802,6 +3060,7 @@ async function executeBatchedWorkerToolGroup(input: {
2802
3060
  })),
2803
3061
  );
2804
3062
  } catch (receiptError) {
3063
+ tripRuntimePersistenceLatch(input.latch, receiptError);
2805
3064
  const rejection = new RuntimeReceiptPersistenceError(
2806
3065
  'Tool call succeeded but durable receipts could not be marked completed',
2807
3066
  [receiptError],
@@ -2823,6 +3082,7 @@ async function executeBatchedWorkerToolGroup(input: {
2823
3082
  try {
2824
3083
  await input.failRequests(input.requests, error);
2825
3084
  } catch (receiptError) {
3085
+ tripRuntimePersistenceLatch(input.latch, receiptError);
2826
3086
  rejection = new RuntimeReceiptPersistenceError(
2827
3087
  'Tool call failed and durable receipts could not be marked failed',
2828
3088
  [error, receiptError],
@@ -4487,6 +4747,7 @@ function createMinimalWorkerCtx(
4487
4747
  abortSignal?: AbortSignal,
4488
4748
  callbacks?: WorkerCtxCallbacks,
4489
4749
  runtimeDeadlineMs?: number,
4750
+ receiptSalvage?: ReceiptSalvageBuffer,
4490
4751
  ): unknown {
4491
4752
  const { governor, resolvePacing: resolveToolPacing } =
4492
4753
  createGovernorForRun(req);
@@ -4600,6 +4861,10 @@ function createMinimalWorkerCtx(
4600
4861
  staleAfterSeconds,
4601
4862
  });
4602
4863
  };
4864
+ // Ctx-wide breaker for top-level ctx.tool calls (outside any map). Each map
4865
+ // creates its own map-scoped latch (see runMap) so per-map dispatch stats and
4866
+ // the runMap failure message stay map-local.
4867
+ const rootPersistenceLatch = createRuntimePersistenceLatch();
4603
4868
  const rootToolBatchScheduler = new WorkerToolBatchScheduler(
4604
4869
  req,
4605
4870
  governor,
@@ -4611,6 +4876,8 @@ function createMinimalWorkerCtx(
4611
4876
  receiptStore,
4612
4877
  true,
4613
4878
  runtimeDeadlineMs,
4879
+ rootPersistenceLatch,
4880
+ receiptSalvage,
4614
4881
  );
4615
4882
  // Local ancestry chain that always ENDS with the currently-executing play
4616
4883
  // (req.playName). The /api/v2/plays/run lineage validator requires the
@@ -4646,6 +4913,12 @@ function createMinimalWorkerCtx(
4646
4913
  ): Promise<unknown> => {
4647
4914
  const mapStartedAt = nowMs();
4648
4915
  const mapNodeId = `map:${name}`;
4916
+ // Map-wide persistence-failure circuit breaker. Shared across every chunk's
4917
+ // scheduler (the scheduler is rebuilt per chunk) and the map's sheet-persist
4918
+ // flush chain, so the first persistence failure halts NEW provider dispatch
4919
+ // for the rest of the map and its prevented-call count surfaces in the
4920
+ // runMap failure message below.
4921
+ const persistenceLatch = createRuntimePersistenceLatch();
4649
4922
  const inputRows = rows;
4650
4923
  const rowCountHint = datasetRowCountHint(inputRows);
4651
4924
  const baseOffset = 0;
@@ -5089,6 +5362,8 @@ function createMinimalWorkerCtx(
5089
5362
  receiptStore,
5090
5363
  false,
5091
5364
  runtimeDeadlineMs,
5365
+ persistenceLatch,
5366
+ receiptSalvage,
5092
5367
  );
5093
5368
  let stepCellsCompleted = 0;
5094
5369
  let stepCellsSkipped = 0;
@@ -5231,6 +5506,9 @@ function createMinimalWorkerCtx(
5231
5506
  });
5232
5507
  persistFlushChain = task.catch((error) => {
5233
5508
  persistFailure ??= error;
5509
+ // Output-sheet flush failed: trip the breaker so the scheduler stops
5510
+ // dispatching new provider calls for the rest of this map.
5511
+ tripRuntimePersistenceLatch(persistenceLatch, error);
5234
5512
  });
5235
5513
  return task;
5236
5514
  };
@@ -5382,6 +5660,15 @@ function createMinimalWorkerCtx(
5382
5660
  force: request.force === true || !!req.force,
5383
5661
  staleAfterSeconds: request.staleAfterSeconds,
5384
5662
  },
5663
+ // Parity with the play-level ctx.tools.execute (and the
5664
+ // cjs runner's map path): the public ToolExecutionRequest
5665
+ // runtime options must not be silently dropped for map
5666
+ // rows — receiptWaitMs bounds how long a resumed run
5667
+ // waits on a stuck `running` receipt before reclaiming.
5668
+ {
5669
+ timeoutMs: request.timeoutMs,
5670
+ receiptWaitMs: request.receiptWaitMs,
5671
+ },
5385
5672
  );
5386
5673
  },
5387
5674
  },
@@ -5675,7 +5962,13 @@ function createMinimalWorkerCtx(
5675
5962
  result.status === 'rejected',
5676
5963
  );
5677
5964
  if (rejectedWorker) {
5678
- if (isRuntimeReceiptPersistenceError(rejectedWorker.reason)) {
5965
+ if (
5966
+ isRuntimeReceiptPersistenceError(rejectedWorker.reason) ||
5967
+ isRuntimePersistenceCircuitOpenError(rejectedWorker.reason)
5968
+ ) {
5969
+ // Persistence-failure / circuit-breaker rejections are returned as a
5970
+ // fatal summary (NOT rethrown into the durable step) so the chunk is
5971
+ // not retried and provider calls are not re-billed.
5679
5972
  return await fatalMapChunkSummary(rejectedWorker.reason);
5680
5973
  }
5681
5974
  throw rejectedWorker.reason;
@@ -5982,10 +6275,16 @@ function createMinimalWorkerCtx(
5982
6275
  }
5983
6276
  const chunkResult = await runChunkStep(chunkRows, chunkStart, chunkIndex);
5984
6277
  if (chunkResult.fatalError) {
6278
+ const circuitBreakerSentence =
6279
+ persistenceLatch.tripped && persistenceLatch.preventedCallCount > 0
6280
+ ? `Circuit breaker prevented ${persistenceLatch.preventedCallCount} queued provider call(s) ` +
6281
+ `from dispatching after the first persistence failure. `
6282
+ : '';
5985
6283
  throw new Error(
5986
6284
  `ctx.dataset("${name}") stopped after a runtime persistence failure ` +
5987
6285
  `outside the retryable chunk step. Provider calls already executed for ` +
5988
6286
  `${chunkResult.rowsExecuted} row(s), so the chunk was not retried. ` +
6287
+ circuitBreakerSentence +
5989
6288
  `Fix the runtime persistence cause and re-run to resume. ` +
5990
6289
  `First error: ${chunkResult.fatalError}`,
5991
6290
  );
@@ -7472,6 +7771,11 @@ async function executeRunRequest(
7472
7771
  stepLifecycle?.markPreDatasetStepsStarted(startedAt);
7473
7772
  flushLedgerEvents(false);
7474
7773
  const runtimeDeadlineMs = nowMs() + STANDARD_PLAY_RUNTIME_LIMIT_MS;
7774
+ // Run-scoped buffer for provider results that billed but whose durable
7775
+ // receipt could not be marked completed even after the retry ladder. On
7776
+ // terminal failure the buffer is built into a size-capped salvage record and
7777
+ // attached to the run.failed ledger event so billed data is never memory-only.
7778
+ const receiptSalvage = createReceiptSalvageBuffer();
7475
7779
  const ctx = createMinimalWorkerCtx(
7476
7780
  req,
7477
7781
  wrappedEmit,
@@ -7480,6 +7784,7 @@ async function executeRunRequest(
7480
7784
  abortSignal,
7481
7785
  workerCallbacks,
7482
7786
  runtimeDeadlineMs,
7787
+ receiptSalvage,
7483
7788
  );
7484
7789
  // Hard wall-clock cap on active user-code runtime. CF Workflows does not
7485
7790
  // impose a play-level execution ceiling on this substrate, so without this a
@@ -7674,8 +7979,16 @@ async function executeRunRequest(
7674
7979
  stepLifecycle?.markStartedFailed(nowMs());
7675
7980
  // A runtime-limit abort is a timeout failure, not a user cancellation, so
7676
7981
  // it should be reported as run.failed with the limit message rather than
7677
- // run.cancelled.
7678
- const aborted = isAbortLikeError(error) && !runtimeLimitExceeded;
7982
+ // run.cancelled. The deadline can also surface as a WorkflowAbortError
7983
+ // thrown synchronously from the completeReceipt retry ladder BEFORE the
7984
+ // runtimeDeadlineTimer macrotask flips the flag — match the limit message
7985
+ // directly so that race can never misclassify a timeout as a cancel (which
7986
+ // would silently drop the salvage buffer of billed-but-unpersisted rows).
7987
+ const runtimeLimitError =
7988
+ runtimeLimitExceeded ||
7989
+ (error instanceof Error &&
7990
+ error.message === STANDARD_PLAY_RUNTIME_LIMIT_MESSAGE);
7991
+ const aborted = isAbortLikeError(error) && !runtimeLimitError;
7679
7992
  if (aborted) {
7680
7993
  // Flip the controller so any concurrent user code observes the abort
7681
7994
  // through ctx.signal. We mark the run cancelled instead of failed.
@@ -7684,7 +7997,15 @@ async function executeRunRequest(
7684
7997
  );
7685
7998
  }
7686
7999
  const failure = normalizePlayRunFailure(error);
7687
- const message = failure.message;
8000
+ // Billed-but-unpersisted salvage rides on the run.failed event's `result`.
8001
+ // The run STILL FAILS — salvage is a loud marker, never a success fallback.
8002
+ const salvage: ReceiptSalvage | null = aborted
8003
+ ? null
8004
+ : receiptSalvage.build();
8005
+ const message =
8006
+ salvage && salvage.totalEntries > 0
8007
+ ? `${failure.message} ${receiptSalvageFailureSuffix(salvage)}`
8008
+ : failure.message;
7688
8009
  const errorBilling = extractErrorBilling(error);
7689
8010
  if (options?.persistResultDatasets) {
7690
8011
  appendRunLogLine(
@@ -7713,6 +8034,7 @@ async function executeRunRequest(
7713
8034
  ...(failure.cause ? { cause: failure.cause } : {}),
7714
8035
  },
7715
8036
  ],
8037
+ ...(salvage && salvage.totalEntries > 0 ? { salvage } : {}),
7716
8038
  },
7717
8039
  });
7718
8040
  recordRunnerPerfTrace({