deepline 0.1.178 → 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.
Files changed (26) hide show
  1. package/dist/bundling-sources/apps/play-runner-workers/src/coordinator-entry.ts +132 -7
  2. package/dist/bundling-sources/apps/play-runner-workers/src/dedup-do.ts +25 -8
  3. package/dist/bundling-sources/apps/play-runner-workers/src/durable-object-deploy-handoff.ts +8 -2
  4. package/dist/bundling-sources/apps/play-runner-workers/src/entry.ts +414 -196
  5. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/batching.ts +42 -2
  6. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/coordinator-progress.ts +289 -0
  7. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/tool-receipts.ts +112 -1
  8. package/dist/bundling-sources/sdk/src/client.ts +237 -0
  9. package/dist/bundling-sources/sdk/src/http.ts +100 -9
  10. package/dist/bundling-sources/sdk/src/release.ts +2 -2
  11. package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +10 -2
  12. package/dist/bundling-sources/shared_libs/play-runtime/batch-runtime.ts +58 -2
  13. package/dist/bundling-sources/shared_libs/play-runtime/context.ts +99 -25
  14. package/dist/bundling-sources/shared_libs/play-runtime/db-session.ts +7 -2
  15. package/dist/bundling-sources/shared_libs/play-runtime/persistence-latch.ts +167 -0
  16. package/dist/bundling-sources/shared_libs/play-runtime/receipt-salvage.ts +158 -0
  17. package/dist/bundling-sources/shared_libs/play-runtime/run-failure.ts +44 -0
  18. package/dist/bundling-sources/shared_libs/play-runtime/run-ledger.ts +74 -30
  19. package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +6 -0
  20. package/dist/cli/index.js +338 -13
  21. package/dist/cli/index.mjs +338 -13
  22. package/dist/index.d.mts +44 -0
  23. package/dist/index.d.ts +44 -0
  24. package/dist/index.js +229 -9
  25. package/dist/index.mjs +229 -9
  26. package/package.json +1 -1
@@ -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';
@@ -181,6 +181,10 @@ import {
181
181
  setHarnessBinding,
182
182
  } from '../../../sdk/src/plays/harness-stub';
183
183
  import { createHarnessWorkerReceiptStore } from './runtime/harness-receipt-store';
184
+ import {
185
+ createCoordinatorProgressPublisher,
186
+ isCoordinatorProgressAuthorizationError,
187
+ } from './runtime/coordinator-progress';
184
188
  import {
185
189
  hydrateSerializedResultDatasets,
186
190
  projectTerminalResultDatasets,
@@ -212,6 +216,22 @@ import {
212
216
  } from '../../../shared_libs/play-runtime/csv-rename';
213
217
  import { coordinatorRequestHeaders } from '../../../shared_libs/play-runtime/coordinator-headers';
214
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';
215
235
  import { createSecretRedactionContext } from '../../../shared_libs/play-runtime/secret-redaction';
216
236
  import {
217
237
  assertNoSecretTaint,
@@ -1735,10 +1755,23 @@ function isRuntimeReceiptPersistenceError(error: unknown): boolean {
1735
1755
 
1736
1756
  function isRunFatalWorkerRowError(error: unknown): boolean {
1737
1757
  return (
1738
- isRowIsolationExemptError(error) || isRuntimeReceiptPersistenceError(error)
1758
+ isCoordinatorProgressAuthorizationError(error) ||
1759
+ isRowIsolationExemptError(error) ||
1760
+ isRuntimeReceiptPersistenceError(error) ||
1761
+ isRuntimePersistenceCircuitOpenError(error)
1739
1762
  );
1740
1763
  }
1741
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
+
1742
1775
  // Fallback batch-chunk parallelism when a tool declares no provider rate hints.
1743
1776
  // Matches the prior hardcoded `Math.min(4, ...)` so undeclared providers keep
1744
1777
  // their previous batching behavior; declared providers tighten via the
@@ -1814,8 +1847,96 @@ class WorkerToolBatchScheduler {
1814
1847
  private readonly receiptStore?: WorkerRuntimeReceiptStore,
1815
1848
  private readonly allowLocalRetryReceipts = false,
1816
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,
1817
1857
  ) {}
1818
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
+
1819
1940
  /**
1820
1941
  * Report a provider 429 / Retry-After back into the Governor's shared pacer
1821
1942
  * so future acquires for this (org, provider) bucket back off across all
@@ -1858,9 +1979,7 @@ class WorkerToolBatchScheduler {
1858
1979
  id,
1859
1980
  cacheKey: receiptKey,
1860
1981
  receiptKey,
1861
- force:
1862
- options?.force === true ||
1863
- !!this.req.force,
1982
+ force: options?.force === true || !!this.req.force,
1864
1983
  receiptWaitMaxAttempts: resolveRuntimeToolReceiptWaitMaxAttempts(
1865
1984
  typeof runtimeOptions?.receiptWaitMs === 'number'
1866
1985
  ? { max_wait_ms: runtimeOptions.receiptWaitMs }
@@ -2050,37 +2169,73 @@ class WorkerToolBatchScheduler {
2050
2169
  );
2051
2170
  return [];
2052
2171
  }
2053
- let waitError: unknown = null;
2054
- try {
2055
- const receipt = await this.waitForDurableToolReceipt({
2056
- receiptKey: input.receiptKey,
2057
- maxAttempts: resolveWorkerToolReceiptGroupWaitMaxAttempts(
2058
- input.group,
2059
- (groupRequest) => groupRequest.receiptWaitMaxAttempts,
2060
- ),
2061
- });
2062
- await this.resolveCompletedDurableToolReceiptGroup({
2063
- group: input.group,
2064
- receiptKey: input.receiptKey,
2065
- receipt,
2066
- source: 'in_flight',
2067
- });
2068
- return [];
2069
- } catch (error) {
2070
- if (!(error instanceof RuntimeReceiptWaitTimeoutError)) {
2071
- this.rejectRawRequests(input.group, error);
2072
- return [];
2073
- }
2074
- waitError = error;
2075
- }
2076
- if (
2077
- !canReclaimTimedOutWorkerToolReceipt({
2078
- ownerRunId: input.runningReceipt.runId,
2079
- currentRunId: this.req.runId,
2080
- updatedAt: input.runningReceipt.updatedAt,
2081
- })
2082
- ) {
2083
- 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);
2084
2239
  return [];
2085
2240
  }
2086
2241
  let claim: WorkerRuntimeReceiptClaim;
@@ -2096,7 +2251,13 @@ class WorkerToolBatchScheduler {
2096
2251
  return [];
2097
2252
  }
2098
2253
  if (claim.disposition === 'claimed') {
2099
- return [{ request, receiptKey: input.receiptKey, followers }];
2254
+ return [
2255
+ {
2256
+ request: input.request,
2257
+ receiptKey: input.receiptKey,
2258
+ followers: input.followers,
2259
+ },
2260
+ ];
2100
2261
  }
2101
2262
  if (claim.disposition === 'reused') {
2102
2263
  await this.resolveCompletedDurableToolReceiptGroup({
@@ -2114,7 +2275,7 @@ class WorkerToolBatchScheduler {
2114
2275
  failedReceipt: claim.receipt,
2115
2276
  });
2116
2277
  }
2117
- this.rejectRawRequests(input.group, waitError);
2278
+ this.rejectRawRequests(input.group, input.rejectionOnFallthrough);
2118
2279
  return [];
2119
2280
  }
2120
2281
 
@@ -2199,6 +2360,7 @@ class WorkerToolBatchScheduler {
2199
2360
  await this.failDurableToolRequest(claimed, error);
2200
2361
  return error;
2201
2362
  } catch (receiptError) {
2363
+ tripRuntimePersistenceLatch(this.latch, receiptError);
2202
2364
  return new RuntimeReceiptPersistenceError(
2203
2365
  'Tool call failed and durable receipt could not be marked failed',
2204
2366
  [error, receiptError],
@@ -2350,6 +2512,9 @@ class WorkerToolBatchScheduler {
2350
2512
  claimedRequests.push({ request, receiptKey, followers });
2351
2513
  }
2352
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);
2353
2518
  for (const group of receiptGroupPlan.durableGroups) {
2354
2519
  this.rejectRawRequests(group.requests, error);
2355
2520
  }
@@ -2368,12 +2533,25 @@ class WorkerToolBatchScheduler {
2368
2533
  kind: 'live',
2369
2534
  receiptKey: claimed.receiptKey,
2370
2535
  });
2371
- const completed = await this.receiptStore.completeReceipt({
2372
- playName: this.req.playName,
2373
- runId: this.req.runId,
2374
- key: claimed.receiptKey,
2375
- output: serializeDurableStepValue(ownerResult),
2376
- });
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
+ );
2377
2555
  if (
2378
2556
  completed &&
2379
2557
  (completed.status === 'completed' || completed.status === 'skipped') &&
@@ -2449,14 +2627,32 @@ class WorkerToolBatchScheduler {
2449
2627
  );
2450
2628
  return results;
2451
2629
  }
2452
- const completed = await this.receiptStore.completeReceipts({
2453
- playName: this.req.playName,
2454
- receipts: durableEntries.map((entry) => ({
2455
- runId: this.req.runId,
2456
- key: entry.claimed.receiptKey!,
2457
- output: serializeDurableStepValue(entry.ownerResult),
2458
- })),
2459
- });
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
+ );
2460
2656
  durableEntries.forEach((entry, resultIndex) => {
2461
2657
  results[entry.index] = this.completedDurableToolRequestResult({
2462
2658
  claimed: entry.claimed,
@@ -2510,6 +2706,17 @@ class WorkerToolBatchScheduler {
2510
2706
  toolId: string,
2511
2707
  requests: WorkerToolBatchRequest[],
2512
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
+ }
2513
2720
  const { claimedRequests, deferredClaimedRequests } =
2514
2721
  await this.prepareDurableToolRequests(requests);
2515
2722
  await this.executeClaimedToolRequests(
@@ -2545,9 +2752,23 @@ class WorkerToolBatchScheduler {
2545
2752
  claimedRequests.length < 2
2546
2753
  ) {
2547
2754
  const groupStartedAt = nowMs();
2548
- await Promise.all(
2549
- claimedRequests.map(async (claimed) => {
2755
+ const dispatchOne = async (
2756
+ claimed: ClaimedWorkerToolBatchRequest,
2757
+ ): Promise<void> => {
2550
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
+ }
2551
2772
  const toolContract = await this.resolvePacing(toolId).catch(
2552
2773
  () => null,
2553
2774
  );
@@ -2586,6 +2807,7 @@ class WorkerToolBatchScheduler {
2586
2807
  await this.completeDurableToolRequest(claimed, result),
2587
2808
  );
2588
2809
  } catch (receiptError) {
2810
+ tripRuntimePersistenceLatch(this.latch, receiptError);
2589
2811
  this.rejectRequests(
2590
2812
  claimed,
2591
2813
  new RuntimeReceiptPersistenceError(
@@ -2596,8 +2818,32 @@ class WorkerToolBatchScheduler {
2596
2818
  } finally {
2597
2819
  slot.release();
2598
2820
  }
2599
- }),
2600
- );
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
+ }
2601
2847
  recordRunnerPerfTrace({
2602
2848
  req: this.req,
2603
2849
  phase: 'runner.tool.group',
@@ -2635,6 +2881,7 @@ class WorkerToolBatchScheduler {
2635
2881
  await this.failDurableToolRequests(claimedRequests, error),
2636
2882
  settleRequest: (claimed, result) => this.settleRequests(claimed, result),
2637
2883
  rejectRequest: (claimed, error) => this.rejectRequests(claimed, error),
2884
+ latch: this.latch,
2638
2885
  });
2639
2886
  recordRunnerPerfTrace({
2640
2887
  req: this.req,
@@ -2692,6 +2939,7 @@ async function executeBatchedWorkerToolGroup(input: {
2692
2939
  request: ClaimedWorkerToolBatchRequest,
2693
2940
  error: unknown,
2694
2941
  ) => void;
2942
+ latch: RuntimePersistenceLatch;
2695
2943
  }): Promise<void> {
2696
2944
  const compiledBatches = compileRequestsWithStrategy({
2697
2945
  requests: input.requests,
@@ -2719,7 +2967,20 @@ async function executeBatchedWorkerToolGroup(input: {
2719
2967
  1,
2720
2968
  Math.min(input.suggestedParallelism, compiledBatches.length || 1),
2721
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,
2722
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
+ }
2723
2984
  const toolContract = await input
2724
2985
  .resolveToolContract(batch.batchOperation)
2725
2986
  .catch(() => null);
@@ -2768,6 +3029,7 @@ async function executeBatchedWorkerToolGroup(input: {
2768
3029
  try {
2769
3030
  await input.failRequests(entry.request.memberRequests, entry.error);
2770
3031
  } catch (receiptError) {
3032
+ tripRuntimePersistenceLatch(input.latch, receiptError);
2771
3033
  rejection = new RuntimeReceiptPersistenceError(
2772
3034
  'Tool call failed and durable receipts could not be marked failed',
2773
3035
  [entry.error, receiptError],
@@ -2798,6 +3060,7 @@ async function executeBatchedWorkerToolGroup(input: {
2798
3060
  })),
2799
3061
  );
2800
3062
  } catch (receiptError) {
3063
+ tripRuntimePersistenceLatch(input.latch, receiptError);
2801
3064
  const rejection = new RuntimeReceiptPersistenceError(
2802
3065
  'Tool call succeeded but durable receipts could not be marked completed',
2803
3066
  [receiptError],
@@ -2819,6 +3082,7 @@ async function executeBatchedWorkerToolGroup(input: {
2819
3082
  try {
2820
3083
  await input.failRequests(input.requests, error);
2821
3084
  } catch (receiptError) {
3085
+ tripRuntimePersistenceLatch(input.latch, receiptError);
2822
3086
  rejection = new RuntimeReceiptPersistenceError(
2823
3087
  'Tool call failed and durable receipts could not be marked failed',
2824
3088
  [error, receiptError],
@@ -4147,10 +4411,15 @@ async function prepareMapRows(input: {
4147
4411
 
4148
4412
  function assertNotAborted(signal: AbortSignal | undefined): void {
4149
4413
  if (signal?.aborted) {
4414
+ if (isCoordinatorProgressAuthorizationError(signal.reason)) {
4415
+ throw signal.reason;
4416
+ }
4150
4417
  throw new WorkflowAbortError(
4151
- typeof signal.reason === 'string' && signal.reason
4152
- ? signal.reason
4153
- : 'Play run cancelled.',
4418
+ signal.reason instanceof Error && signal.reason.message
4419
+ ? signal.reason.message
4420
+ : typeof signal.reason === 'string' && signal.reason
4421
+ ? signal.reason
4422
+ : 'Play run cancelled.',
4154
4423
  );
4155
4424
  }
4156
4425
  }
@@ -4478,6 +4747,7 @@ function createMinimalWorkerCtx(
4478
4747
  abortSignal?: AbortSignal,
4479
4748
  callbacks?: WorkerCtxCallbacks,
4480
4749
  runtimeDeadlineMs?: number,
4750
+ receiptSalvage?: ReceiptSalvageBuffer,
4481
4751
  ): unknown {
4482
4752
  const { governor, resolvePacing: resolveToolPacing } =
4483
4753
  createGovernorForRun(req);
@@ -4591,6 +4861,10 @@ function createMinimalWorkerCtx(
4591
4861
  staleAfterSeconds,
4592
4862
  });
4593
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();
4594
4868
  const rootToolBatchScheduler = new WorkerToolBatchScheduler(
4595
4869
  req,
4596
4870
  governor,
@@ -4602,6 +4876,8 @@ function createMinimalWorkerCtx(
4602
4876
  receiptStore,
4603
4877
  true,
4604
4878
  runtimeDeadlineMs,
4879
+ rootPersistenceLatch,
4880
+ receiptSalvage,
4605
4881
  );
4606
4882
  // Local ancestry chain that always ENDS with the currently-executing play
4607
4883
  // (req.playName). The /api/v2/plays/run lineage validator requires the
@@ -4637,6 +4913,12 @@ function createMinimalWorkerCtx(
4637
4913
  ): Promise<unknown> => {
4638
4914
  const mapStartedAt = nowMs();
4639
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();
4640
4922
  const inputRows = rows;
4641
4923
  const rowCountHint = datasetRowCountHint(inputRows);
4642
4924
  const baseOffset = 0;
@@ -5080,6 +5362,8 @@ function createMinimalWorkerCtx(
5080
5362
  receiptStore,
5081
5363
  false,
5082
5364
  runtimeDeadlineMs,
5365
+ persistenceLatch,
5366
+ receiptSalvage,
5083
5367
  );
5084
5368
  let stepCellsCompleted = 0;
5085
5369
  let stepCellsSkipped = 0;
@@ -5222,6 +5506,9 @@ function createMinimalWorkerCtx(
5222
5506
  });
5223
5507
  persistFlushChain = task.catch((error) => {
5224
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);
5225
5512
  });
5226
5513
  return task;
5227
5514
  };
@@ -5370,11 +5657,18 @@ function createMinimalWorkerCtx(
5370
5657
  request.input,
5371
5658
  workflowStep,
5372
5659
  {
5373
- force:
5374
- request.force === true ||
5375
- !!req.force,
5660
+ force: request.force === true || !!req.force,
5376
5661
  staleAfterSeconds: request.staleAfterSeconds,
5377
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
+ },
5378
5672
  );
5379
5673
  },
5380
5674
  },
@@ -5668,7 +5962,13 @@ function createMinimalWorkerCtx(
5668
5962
  result.status === 'rejected',
5669
5963
  );
5670
5964
  if (rejectedWorker) {
5671
- 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.
5672
5972
  return await fatalMapChunkSummary(rejectedWorker.reason);
5673
5973
  }
5674
5974
  throw rejectedWorker.reason;
@@ -5872,7 +6172,7 @@ function createMinimalWorkerCtx(
5872
6172
  ? ` cells=${totalStepCells}/${totalStepCellsCompleted}/${totalStepCellsSkipped}`
5873
6173
  : '';
5874
6174
  callbacks?.onMapCompleted?.(mapNodeId, completedAt);
5875
- void updateMapProgress({
6175
+ await updateMapProgress({
5876
6176
  completed: totalRowsWritten,
5877
6177
  total: totalRowsWritten + totalRowsFailed,
5878
6178
  failed: totalRowsFailed,
@@ -5975,10 +6275,16 @@ function createMinimalWorkerCtx(
5975
6275
  }
5976
6276
  const chunkResult = await runChunkStep(chunkRows, chunkStart, chunkIndex);
5977
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
+ : '';
5978
6283
  throw new Error(
5979
6284
  `ctx.dataset("${name}") stopped after a runtime persistence failure ` +
5980
6285
  `outside the retryable chunk step. Provider calls already executed for ` +
5981
6286
  `${chunkResult.rowsExecuted} row(s), so the chunk was not retried. ` +
6287
+ circuitBreakerSentence +
5982
6288
  `Fix the runtime persistence cause and re-run to resume. ` +
5983
6289
  `First error: ${chunkResult.fatalError}`,
5984
6290
  );
@@ -6360,9 +6666,7 @@ function createMinimalWorkerCtx(
6360
6666
  request.input,
6361
6667
  workflowStep,
6362
6668
  {
6363
- force:
6364
- request.force === true ||
6365
- !!req.force,
6669
+ force: request.force === true || !!req.force,
6366
6670
  staleAfterSeconds: request.staleAfterSeconds,
6367
6671
  },
6368
6672
  {
@@ -7196,9 +7500,6 @@ async function executeRunRequest(
7196
7500
  let lastLedgerFlushAt = startedAt;
7197
7501
  let ledgerFlushInFlight: Promise<void> = Promise.resolve();
7198
7502
  let ledgerFlushQueueDepth = 0;
7199
- let lastCoordinatorProgressPublishAt = 0;
7200
- let coordinatorProgressPublishInFlight: Promise<void> = Promise.resolve();
7201
- let coordinatorProgressPublishQueueDepth = 0;
7202
7503
 
7203
7504
  const appendRunLogLine = (line: string) => {
7204
7505
  const trimmed = redactSecretsFromLogString(line.trim());
@@ -7225,130 +7526,24 @@ async function executeRunRequest(
7225
7526
  };
7226
7527
 
7227
7528
  const stepProgressSnapshot = () => ({ ...stepProgressByNodeId });
7228
-
7229
- const publishCoordinatorProgressEvent = async (
7230
- occurredAt: number,
7231
- ): Promise<void> => {
7232
- const coordinatorUrl = req.coordinatorUrl?.trim();
7233
- if (!coordinatorUrl) {
7234
- recordRunnerPerfTrace({
7235
- req,
7236
- phase: 'runner.coordinator_progress_publish',
7237
- ms: 0,
7238
- extra: { status: 'skipped_no_url' },
7239
- });
7240
- return;
7241
- }
7242
- const publishStartedAt = nowMs();
7243
- const liveNodeProgress = stepProgressSnapshot();
7244
- const activeEntry =
7245
- Object.entries(liveNodeProgress).find(
7246
- ([, progress]) => typeof progress.completedAt !== 'number',
7247
- ) ?? Object.entries(liveNodeProgress).at(-1);
7248
- const activeNodeId = activeEntry?.[0] ?? null;
7249
- const activeProgress = activeEntry?.[1] ?? null;
7250
- const activeArtifactTableNamespace =
7251
- typeof activeProgress?.artifactTableNamespace === 'string'
7252
- ? activeProgress.artifactTableNamespace
7253
- : null;
7254
- const activeCompleted =
7255
- typeof activeProgress?.completed === 'number'
7256
- ? activeProgress.completed
7257
- : null;
7258
- const activeTotal =
7259
- typeof activeProgress?.total === 'number' ? activeProgress.total : null;
7260
- const activeMessage =
7261
- typeof activeProgress?.message === 'string'
7262
- ? activeProgress.message
7263
- : null;
7264
- const response = await fetch(
7265
- `${coordinatorUrl.replace(/\/$/, '')}/dedup/${encodeURIComponent(
7266
- req.runId,
7267
- )}/event-add`,
7268
- {
7269
- method: 'POST',
7270
- headers: {
7271
- 'x-deepline-request-id': makeRequestId(),
7272
- ...coordinatorRequestHeaders({
7273
- runId: req.runId,
7274
- contentType: 'application/json',
7275
- internalToken: req.coordinatorInternalToken,
7276
- }),
7277
- },
7278
- body: JSON.stringify({
7279
- runId: req.runId,
7280
- type: 'progress',
7281
- status: 'running',
7282
- ts: occurredAt,
7283
- logs: runLogBuffer,
7284
- activeNodeId,
7285
- activeArtifactTableNamespace,
7286
- updatedAt: occurredAt,
7287
- liveNodeProgress,
7288
- }),
7289
- },
7290
- );
7291
- if (!response.ok) {
7529
+ const coordinatorProgressPublisher = createCoordinatorProgressPublisher({
7530
+ runId: req.runId,
7531
+ coordinatorUrl: req.coordinatorUrl,
7532
+ coordinatorInternalToken: req.coordinatorInternalToken,
7533
+ abortController,
7534
+ getRunLogBuffer: () => runLogBuffer,
7535
+ getLiveNodeProgress: stepProgressSnapshot,
7536
+ makeRequestId,
7537
+ nowMs,
7538
+ heartbeatIntervalMs: MAP_EXECUTION_HEARTBEAT_INTERVAL_MS,
7539
+ recordTrace: (trace) =>
7292
7540
  recordRunnerPerfTrace({
7293
7541
  req,
7294
- phase: 'runner.coordinator_progress_publish',
7295
- ms: nowMs() - publishStartedAt,
7296
- extra: {
7297
- status: 'failed',
7298
- httpStatus: response.status,
7299
- activeNodeId,
7300
- activeArtifactTableNamespace,
7301
- activeCompleted,
7302
- activeTotal,
7303
- activeMessage,
7304
- },
7305
- });
7306
- throw new Error(`coordinator progress event failed ${response.status}`);
7307
- }
7308
- recordRunnerPerfTrace({
7309
- req,
7310
- phase: 'runner.coordinator_progress_publish',
7311
- ms: nowMs() - publishStartedAt,
7312
- extra: {
7313
- status: 'ok',
7314
- activeNodeId,
7315
- activeArtifactTableNamespace,
7316
- activeCompleted,
7317
- activeTotal,
7318
- activeMessage,
7319
- },
7320
- });
7321
- };
7322
-
7323
- const flushCoordinatorProgressEvent = (force: boolean): Promise<void> => {
7324
- const now = nowMs();
7325
- if (
7326
- !force &&
7327
- now - lastCoordinatorProgressPublishAt <
7328
- MAP_EXECUTION_HEARTBEAT_INTERVAL_MS
7329
- ) {
7330
- return Promise.resolve();
7331
- }
7332
- if (!force && coordinatorProgressPublishQueueDepth > 0) {
7333
- return Promise.resolve();
7334
- }
7335
- lastCoordinatorProgressPublishAt = now;
7336
- coordinatorProgressPublishQueueDepth += 1;
7337
- coordinatorProgressPublishInFlight = coordinatorProgressPublishInFlight
7338
- .catch(() => undefined)
7339
- .then(async () => {
7340
- try {
7341
- await publishCoordinatorProgressEvent(now);
7342
- } finally {
7343
- coordinatorProgressPublishQueueDepth = Math.max(
7344
- 0,
7345
- coordinatorProgressPublishQueueDepth - 1,
7346
- );
7347
- }
7348
- })
7349
- .catch(() => undefined);
7350
- return force ? coordinatorProgressPublishInFlight : Promise.resolve();
7351
- };
7542
+ phase: trace.phase,
7543
+ ms: trace.ms,
7544
+ extra: trace.extra,
7545
+ }),
7546
+ });
7352
7547
 
7353
7548
  const appendStepLifecycleEvent = (event: PlayStepLifecycleEvent) => {
7354
7549
  updateStepProgress({
@@ -7537,7 +7732,7 @@ async function executeRunRequest(
7537
7732
  updateStepProgress(input);
7538
7733
  const force = Boolean(input.forceFlush);
7539
7734
  const ledgerFlush = flushLedgerEvents(force);
7540
- const progressFlush = flushCoordinatorProgressEvent(force);
7735
+ const progressFlush = coordinatorProgressPublisher.flush(force);
7541
7736
  return force
7542
7737
  ? Promise.all([ledgerFlush, progressFlush]).then(() => undefined)
7543
7738
  : Promise.resolve();
@@ -7576,6 +7771,11 @@ async function executeRunRequest(
7576
7771
  stepLifecycle?.markPreDatasetStepsStarted(startedAt);
7577
7772
  flushLedgerEvents(false);
7578
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();
7579
7779
  const ctx = createMinimalWorkerCtx(
7580
7780
  req,
7581
7781
  wrappedEmit,
@@ -7584,6 +7784,7 @@ async function executeRunRequest(
7584
7784
  abortSignal,
7585
7785
  workerCallbacks,
7586
7786
  runtimeDeadlineMs,
7787
+ receiptSalvage,
7587
7788
  );
7588
7789
  // Hard wall-clock cap on active user-code runtime. CF Workflows does not
7589
7790
  // impose a play-level execution ceiling on this substrate, so without this a
@@ -7778,8 +7979,16 @@ async function executeRunRequest(
7778
7979
  stepLifecycle?.markStartedFailed(nowMs());
7779
7980
  // A runtime-limit abort is a timeout failure, not a user cancellation, so
7780
7981
  // it should be reported as run.failed with the limit message rather than
7781
- // run.cancelled.
7782
- 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;
7783
7992
  if (aborted) {
7784
7993
  // Flip the controller so any concurrent user code observes the abort
7785
7994
  // through ctx.signal. We mark the run cancelled instead of failed.
@@ -7788,7 +7997,15 @@ async function executeRunRequest(
7788
7997
  );
7789
7998
  }
7790
7999
  const failure = normalizePlayRunFailure(error);
7791
- 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;
7792
8009
  const errorBilling = extractErrorBilling(error);
7793
8010
  if (options?.persistResultDatasets) {
7794
8011
  appendRunLogLine(
@@ -7817,6 +8034,7 @@ async function executeRunRequest(
7817
8034
  ...(failure.cause ? { cause: failure.cause } : {}),
7818
8035
  },
7819
8036
  ],
8037
+ ...(salvage && salvage.totalEntries > 0 ? { salvage } : {}),
7820
8038
  },
7821
8039
  });
7822
8040
  recordRunnerPerfTrace({