deepline 0.1.198 → 0.1.200

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 (72) hide show
  1. package/dist/bundling-sources/apps/play-runner-workers/src/coordinator-entry.ts +5 -1
  2. package/dist/bundling-sources/apps/play-runner-workers/src/entry.ts +51 -17
  3. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/README.md +1 -1
  4. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/harness-receipt-store.ts +16 -0
  5. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/receipts.ts +40 -9
  6. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/tool-dispatch.ts +108 -39
  7. package/dist/bundling-sources/sdk/src/http.ts +5 -2
  8. package/dist/bundling-sources/sdk/src/play.ts +1 -1
  9. package/dist/bundling-sources/sdk/src/plays/harness-stub.ts +7 -0
  10. package/dist/bundling-sources/sdk/src/release.ts +2 -2
  11. package/dist/bundling-sources/sdk/src/worker-play-entry.ts +11 -55
  12. package/dist/bundling-sources/shared_libs/play-runtime/adaptive-tool-dispatcher.ts +355 -0
  13. package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +172 -73
  14. package/dist/bundling-sources/shared_libs/play-runtime/context.ts +1794 -377
  15. package/dist/bundling-sources/shared_libs/play-runtime/ctx-contract.ts +3 -0
  16. package/dist/bundling-sources/shared_libs/play-runtime/ctx-types.ts +67 -4
  17. package/dist/bundling-sources/shared_libs/play-runtime/durable-receipt-execution.ts +161 -39
  18. package/dist/bundling-sources/shared_libs/play-runtime/governor/adaptive-admission.ts +218 -0
  19. package/dist/bundling-sources/shared_libs/play-runtime/governor/app-runtime-rate-state-backend.ts +1 -1
  20. package/dist/bundling-sources/shared_libs/play-runtime/governor/governor.ts +115 -25
  21. package/dist/bundling-sources/shared_libs/play-runtime/governor/policy.ts +17 -17
  22. package/dist/bundling-sources/shared_libs/play-runtime/lease-policy.ts +22 -0
  23. package/dist/bundling-sources/shared_libs/play-runtime/ledger-safe-payload.ts +2 -1
  24. package/dist/bundling-sources/shared_libs/play-runtime/live-events.ts +0 -1
  25. package/dist/bundling-sources/shared_libs/play-runtime/map-memory-limits.ts +265 -0
  26. package/dist/bundling-sources/shared_libs/play-runtime/output-size-limits.ts +135 -0
  27. package/dist/bundling-sources/shared_libs/play-runtime/play-input.ts +17 -0
  28. package/dist/bundling-sources/shared_libs/play-runtime/play-latency-trace.ts +0 -3
  29. package/dist/bundling-sources/shared_libs/play-runtime/protocol.ts +10 -1
  30. package/dist/bundling-sources/shared_libs/play-runtime/providers.ts +9 -15
  31. package/dist/bundling-sources/shared_libs/play-runtime/receipt-completion-sink.ts +262 -0
  32. package/dist/bundling-sources/shared_libs/play-runtime/receipt-heartbeat-supervisor.ts +64 -0
  33. package/dist/bundling-sources/shared_libs/play-runtime/receipt-sql.ts +76 -8
  34. package/dist/bundling-sources/shared_libs/play-runtime/receipt-state-sink.ts +59 -0
  35. package/dist/bundling-sources/shared_libs/play-runtime/receipt-status.ts +3 -2
  36. package/dist/bundling-sources/shared_libs/play-runtime/resource-governor.ts +269 -0
  37. package/dist/bundling-sources/shared_libs/play-runtime/run-ledger.ts +2 -3
  38. package/dist/bundling-sources/shared_libs/play-runtime/run-terminal-source.ts +0 -1
  39. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-lifecycle.ts +433 -0
  40. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-payload-transport.ts +227 -0
  41. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona.ts +882 -0
  42. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/local-process.ts +573 -0
  43. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/bundle.ts +264 -0
  44. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/index.ts +33 -0
  45. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/runner-events.ts +35 -0
  46. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/types.ts +53 -0
  47. package/dist/bundling-sources/shared_libs/play-runtime/runtime-actions.ts +0 -1
  48. package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +459 -103
  49. package/dist/bundling-sources/shared_libs/play-runtime/runtime-constants.ts +12 -0
  50. package/dist/bundling-sources/shared_libs/play-runtime/runtime-infra-mode.ts +23 -0
  51. package/dist/bundling-sources/shared_libs/play-runtime/runtime-sheet-attempt-state-machine.ts +23 -2
  52. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backend.ts +16 -24
  53. package/dist/bundling-sources/shared_libs/play-runtime/serial-task-queue.ts +26 -0
  54. package/dist/bundling-sources/shared_libs/play-runtime/submit-limits.ts +1 -1
  55. package/dist/bundling-sources/shared_libs/play-runtime/vercel-protection.ts +81 -0
  56. package/dist/bundling-sources/shared_libs/play-runtime/work-receipt-state-machine.ts +54 -9
  57. package/dist/bundling-sources/shared_libs/play-runtime/work-receipts.ts +17 -3
  58. package/dist/bundling-sources/shared_libs/play-runtime/worker-api-types.ts +0 -20
  59. package/dist/bundling-sources/shared_libs/plays/dataset.ts +105 -4
  60. package/dist/bundling-sources/shared_libs/plays/row-identity.ts +4 -11
  61. package/dist/cli/index.js +441 -101
  62. package/dist/cli/index.mjs +441 -101
  63. package/dist/index.d.mts +1 -1
  64. package/dist/index.d.ts +1 -1
  65. package/dist/index.js +8 -8
  66. package/dist/index.mjs +8 -8
  67. package/dist/plays/bundle-play-file.mjs +15 -20
  68. package/package.json +1 -1
  69. package/dist/bundling-sources/shared_libs/play-runtime/hatchet-cold-execution-diagnosis.ts +0 -321
  70. package/dist/bundling-sources/shared_libs/play-runtime/hatchet-cold-execution-target.ts +0 -158
  71. package/dist/bundling-sources/shared_libs/temporal/constants.ts +0 -39
  72. package/dist/bundling-sources/shared_libs/temporal/preview-config.ts +0 -150
@@ -2,18 +2,21 @@
2
2
  * PlayContextImpl — the cloud execution engine.
3
3
  *
4
4
  * Batching model:
5
- * 1. ctx.dataset("table_key", rows).withColumn("field", resolver).run() starts all row column resolvers concurrently
6
- * 2. ctx.tools.execute() calls inside field resolvers QUEUE requests
7
- * 3. After all rows have queued, executeBatchedToolCalls() runs provider calls
8
- * 4. Results resolve suspended row promises, rows complete
5
+ * 1. ctx.dataset("table_key", rows).withColumn("field", resolver).run()
6
+ * admits a bounded row worker pool.
7
+ * 2. ctx.tools.execute() calls inside admitted field resolvers QUEUE requests.
8
+ * 3. The drain loop runs queued provider batches while row workers wait.
9
+ * 4. Provider results resolve rows, and the next pending rows are admitted.
9
10
  *
10
- * Temporal integration:
11
- * - checkpoint: recovered from heartbeatDetails on retry (skip completed batches)
12
- * - onBatchComplete: called after each provider batch (heartbeat checkpoint to Temporal)
11
+ * Runtime integration:
12
+ * - checkpoint: recovered on retry (skip completed batches)
13
+ * - onBatchComplete: called after each provider batch for scheduler checkpointing
13
14
  */
14
15
  import { AsyncLocalStorage } from 'async_hooks';
15
16
  import {
16
17
  createDeferredPlayDataset,
18
+ isPlayDataset,
19
+ iteratePlayDatasetInputPages,
17
20
  materializePlayDatasetInput,
18
21
  } from '@shared_libs/plays/dataset';
19
22
  import type { PlayDataset, PlayDatasetInput } from '@shared_libs/plays/dataset';
@@ -21,6 +24,12 @@ import {
21
24
  compileRequestsWithStrategy,
22
25
  executeChunkedRequests,
23
26
  } from './batch-runtime';
27
+ import { AdaptiveToolDispatcher } from './adaptive-tool-dispatcher';
28
+ import {
29
+ PLAY_RUNTIME_WORK_RECEIPT_LEASE_TTL_MS,
30
+ runtimeLeaseHeartbeatIntervalFromExpiry,
31
+ } from './lease-policy';
32
+ import { createRuntimeReceiptHeartbeatSupervisor } from './receipt-heartbeat-supervisor';
24
33
  import type { PlayQueueHint } from './governor/rate-state-backend';
25
34
  import type { MapRowOutcome } from './durability-store';
26
35
  import type { WorkReceiptFailureKind } from './work-receipts';
@@ -41,6 +50,7 @@ import {
41
50
  PLAY_RUNTIME_API_COMPAT_PATH,
42
51
  PLAY_RUNTIME_CHILD_EXECUTOR_TOKEN_COMPAT_PATH,
43
52
  } from './runtime-api-paths';
53
+ import { vercelProtectionBypassHeader } from './vercel-protection';
44
54
  export {
45
55
  RuntimeSheetRowsBlockedError,
46
56
  type RuntimeSheetBlockedRowDetail,
@@ -52,6 +62,10 @@ import {
52
62
  type PacingResolver,
53
63
  type PlayExecutionGovernor,
54
64
  } from './governor/governor';
65
+ import {
66
+ createRuntimeResourceGovernor,
67
+ type RuntimeResourceGovernor,
68
+ } from './resource-governor';
55
69
  import { CTX_FETCH_EGRESS_TOOL_ID } from './builtin-pacing';
56
70
  import { InMemoryRateStateBackend } from './governor/in-memory-rate-state-backend';
57
71
  import { pacingPolicyForTool } from './pacing';
@@ -101,6 +115,7 @@ import {
101
115
  runtimeReceiptFailureKindForError,
102
116
  runtimeReceiptOutput as durableRuntimeReceiptOutput,
103
117
  waitForCompletedRuntimeReceipt,
118
+ waitForCompletedRuntimeReceipts,
104
119
  type DurableReceiptExecutionStore,
105
120
  } from './durable-receipt-execution';
106
121
  import {
@@ -116,6 +131,10 @@ import {
116
131
  tripRuntimePersistenceLatch,
117
132
  type RuntimePersistenceLatch,
118
133
  } from './persistence-latch';
134
+ import {
135
+ assertCustomerOutputObjectWithinLimit,
136
+ assertRuntimeReceiptOutputWithinLimit,
137
+ } from './output-size-limits';
119
138
  import { createRuntimeDatasetId } from './dataset-id';
120
139
  import { dedupeExplicitMapKeyRows } from './map-row-identity';
121
140
  import {
@@ -127,6 +146,7 @@ import {
127
146
  sha256Hex,
128
147
  stableStringify,
129
148
  } from '@shared_libs/plays/row-identity';
149
+ import { sqlSafePlayColumnName } from '@shared_libs/plays/static-pipeline';
130
150
  import {
131
151
  DEEPLINE_CELL_META_FIELD,
132
152
  previousCellFromValue,
@@ -138,6 +158,15 @@ import {
138
158
  stripCsvProjectionMetadata,
139
159
  toSerializableCsvAliasedRow,
140
160
  } from './csv-rename';
161
+ import {
162
+ createRuntimeMapMaterializationTracker,
163
+ createRuntimeMapRetainedRowsTracker,
164
+ estimateRuntimeMapRowsMemory,
165
+ resolveRuntimeMapRowAdmission,
166
+ runtimeMapJsonByteLength,
167
+ resolveRuntimeMapMemoryLimits,
168
+ RuntimeMapMemoryLimitError,
169
+ } from './map-memory-limits';
141
170
  import { setSpanAttributes, withActiveSpan } from './tracing';
142
171
  import { DISALLOWED_RUN_JAVASCRIPT_TOOL_MESSAGE } from './runtime-constraints';
143
172
  import {
@@ -234,11 +263,83 @@ const TOOL_RETRY_HEARTBEAT_INTERVAL_MS = 30_000;
234
263
  const DEEPLINEAGENT_TOOL_RUNTIME_TIMEOUT_MS = 15 * 60 * 1000;
235
264
  const FETCH_TRANSPORT_MAX_ATTEMPTS = 3;
236
265
  const FETCH_TRANSPORT_RETRY_DELAY_MS = 100;
266
+ const CHILD_PLAY_LAUNCH_WRITE_CONFLICT_RETRY_DELAYS_MS = [
267
+ 50, 100, 200, 400, 800,
268
+ ] as const;
269
+ const CHILD_PLAY_LAUNCH_ADMISSION_RETRY_DELAYS_MS = [
270
+ 250, 500, 1_000, 2_000, 4_000, 8_000, 8_000, 8_000, 8_000, 8_000, 8_000,
271
+ 8_000, 8_000, 8_000, 8_000,
272
+ ] as const;
237
273
  const NODE_RUNTIME_MAP_VISIBILITY_MAX_ATTEMPTS = 100;
238
274
  const NODE_RUNTIME_MAP_VISIBILITY_RETRY_MS = 25;
275
+ // A single batched runtime-step-receipt request (get/claim/complete/fail/seed)
276
+ // carries at most this many keys. At map scale a run can hold 5k-10k receipt
277
+ // keys; sending them all in one request made the server claim/complete them
278
+ // one-per-key serially in a single HTTP call, which crossed the ~100s origin
279
+ // cap and 524'd (ADR-0012 receipt fence at 5k/10k rows). Chunking bounds each
280
+ // request's server-side work while keeping per-key results faithful — chunks
281
+ // are dispatched in order and their aligned result arrays concatenated, so the
282
+ // key→receipt mapping is identical to the unchunked path.
283
+ export const RUNTIME_STEP_RECEIPT_REQUEST_CHUNK_SIZE = 1_000;
284
+
285
+ export function chunkArrayForReceiptRequest<T>(items: readonly T[]): T[][] {
286
+ if (items.length <= RUNTIME_STEP_RECEIPT_REQUEST_CHUNK_SIZE) {
287
+ return items.length === 0 ? [] : [[...items]];
288
+ }
289
+ const chunks: T[][] = [];
290
+ for (
291
+ let start = 0;
292
+ start < items.length;
293
+ start += RUNTIME_STEP_RECEIPT_REQUEST_CHUNK_SIZE
294
+ ) {
295
+ chunks.push(
296
+ items.slice(start, start + RUNTIME_STEP_RECEIPT_REQUEST_CHUNK_SIZE),
297
+ );
298
+ }
299
+ return chunks;
300
+ }
301
+
239
302
  type SafeFetchModule = typeof import('@shared_libs/security/safe-fetch');
240
303
  let safeFetchModule: Promise<SafeFetchModule> | null = null;
241
304
 
305
+ function sleepTransientRuntimeMs(ms: number): Promise<void> {
306
+ return new Promise((resolve) => setTimeout(resolve, ms));
307
+ }
308
+
309
+ function isConvexWriteConflictMessage(message: string): boolean {
310
+ return (
311
+ message.includes('OptimisticConcurrencyControlFailure') ||
312
+ /changed while this mutation was being run/i.test(message) ||
313
+ /write conflict/i.test(message)
314
+ );
315
+ }
316
+
317
+ function isChildPlayLaunchAdmissionMessage(message: string): boolean {
318
+ return /too many play runs (?:are )?(?:starting|active)/i.test(message);
319
+ }
320
+
321
+ function childPlayLaunchRetryDelayMs(input: {
322
+ status: number;
323
+ message: string;
324
+ attempt: number;
325
+ }): number | null {
326
+ if (
327
+ input.status === 429 &&
328
+ isChildPlayLaunchAdmissionMessage(input.message)
329
+ ) {
330
+ return CHILD_PLAY_LAUNCH_ADMISSION_RETRY_DELAYS_MS[input.attempt] ?? null;
331
+ }
332
+ if (
333
+ ![409, 429, 500, 502, 503, 504].includes(input.status) ||
334
+ !isConvexWriteConflictMessage(input.message)
335
+ ) {
336
+ return null;
337
+ }
338
+ return (
339
+ CHILD_PLAY_LAUNCH_WRITE_CONFLICT_RETRY_DELAYS_MS[input.attempt] ?? null
340
+ );
341
+ }
342
+
242
343
  export async function waitForNodeRuntimeMapRowsVisible(input: {
243
344
  mapName: string;
244
345
  tableNamespace: string;
@@ -291,6 +392,27 @@ export async function waitForNodeRuntimeMapRowsVisible(input: {
291
392
  );
292
393
  }
293
394
 
395
+ /**
396
+ * Persisted sheet fields to layer onto a fresh map input row during seeding,
397
+ * excluding any column the current run's input already provides. Input columns
398
+ * stay authoritative (a persisted row is matched only by the map key and may
399
+ * belong to a different run that merely shares that key); runtime fields like
400
+ * __deeplineRowKey and re-derivable output columns — never present on the raw
401
+ * input row — are restored.
402
+ */
403
+ export function persistedFieldsAbsentFromInputRow(
404
+ inputRow: Record<string, unknown>,
405
+ persisted: Record<string, unknown>,
406
+ ): Record<string, unknown> {
407
+ const extra: Record<string, unknown> = {};
408
+ for (const key of Object.keys(persisted)) {
409
+ if (!Object.prototype.hasOwnProperty.call(inputRow, key)) {
410
+ extra[key] = persisted[key];
411
+ }
412
+ }
413
+ return extra;
414
+ }
415
+
294
416
  export async function reconcileNodeRuntimeMapResultsWithPersistedSheet(input: {
295
417
  mapName: string;
296
418
  tableNamespace: string;
@@ -443,6 +565,10 @@ type ToolExecutionApiOptions = {
443
565
  durableCallReceiptKey?: string | null;
444
566
  executionAuthScopeDigest?: string | null;
445
567
  providerIdempotencyKey?: string | null;
568
+ receiptLeaseExpiresAt?: string | null;
569
+ beforeProviderCall?: () => Promise<void> | void;
570
+ heartbeatReceipt?: () => Promise<void> | void;
571
+ parkProviderCall?: () => Promise<void> | void;
446
572
  customerDbDataset?: {
447
573
  limit: number;
448
574
  offset: number;
@@ -458,6 +584,10 @@ function shouldPersistMapCellField(fieldName: string): boolean {
458
584
  return !fieldName.startsWith('_') || fieldName === '_metadata';
459
585
  }
460
586
 
587
+ function runtimeSheetPatchFieldName(fieldName: string): string {
588
+ return fieldName.includes('.') ? sqlSafePlayColumnName(fieldName) : fieldName;
589
+ }
590
+
461
591
  type PersistableMapRow = MapRowOutcome;
462
592
 
463
593
  function comparePersistableMapRowsByInputIndex(
@@ -486,6 +616,22 @@ function persistableMapRowBytes(row: PersistableMapRow): number {
486
616
  return JSON.stringify(row).length;
487
617
  }
488
618
 
619
+ function assertPersistableMapRowWithinCustomerOutputLimits(input: {
620
+ mapName: string;
621
+ row: PersistableMapRow;
622
+ }): void {
623
+ const rowLabel =
624
+ input.row.key?.trim() ||
625
+ (typeof input.row.inputIndex === 'number' &&
626
+ Number.isFinite(input.row.inputIndex)
627
+ ? `input:${Math.floor(input.row.inputIndex)}`
628
+ : 'unknown');
629
+ assertCustomerOutputObjectWithinLimit({
630
+ value: input.row.data,
631
+ path: `ctx.dataset("${input.mapName}").row("${rowLabel}")`,
632
+ });
633
+ }
634
+
489
635
  function failedMapRowLogLabel(row: PersistableMapRow): string {
490
636
  const rowId = row.data?.row_id;
491
637
  if (typeof rowId === 'string' && rowId.trim()) return rowId.trim();
@@ -786,6 +932,10 @@ class RuntimeDatasetBuilder<T extends Record<string, unknown>> {
786
932
 
787
933
  const WAITING_ROW = Symbol('deepline.waiting_row');
788
934
  const FAILED_ROW = Symbol('deepline.failed_row');
935
+ type MapRowExecutionResult =
936
+ | Record<string, unknown>
937
+ | typeof WAITING_ROW
938
+ | typeof FAILED_ROW;
789
939
 
790
940
  function normalizeFetchHeaders(
791
941
  headers: RequestInit['headers'],
@@ -933,6 +1083,7 @@ export class PlayContextImpl {
933
1083
  * via `chargeBudget`; child plays fork lineage-global counters via `forkChild`.
934
1084
  */
935
1085
  private readonly governor: PlayExecutionGovernor;
1086
+ private readonly resourceGovernor: RuntimeResourceGovernor;
936
1087
  /**
937
1088
  * Lineage identity for this context (run/play ids + ancestry). Seeded from the
938
1089
  * Governor snapshot; used only for receipt keys, durable-boundary scoping, and
@@ -1071,6 +1222,9 @@ export class PlayContextImpl {
1071
1222
  ancestryPlayIds: [rootPlayId],
1072
1223
  },
1073
1224
  });
1225
+ this.resourceGovernor = createRuntimeResourceGovernor({
1226
+ executionGovernor: this.governor,
1227
+ });
1074
1228
  this.governance = this.governor.snapshot();
1075
1229
  }
1076
1230
 
@@ -1191,7 +1345,13 @@ export class PlayContextImpl {
1191
1345
  }
1192
1346
  }
1193
1347
 
1194
- private runtimeApiHeaders(): Record<string, string> {
1348
+ private async vercelProtectionHeaders(): Promise<Record<string, string>> {
1349
+ return vercelProtectionBypassHeader(
1350
+ this.#options.vercelProtectionBypassToken,
1351
+ );
1352
+ }
1353
+
1354
+ private async runtimeApiHeaders(): Promise<Record<string, string>> {
1195
1355
  if (!this.#options.executorToken?.trim()) {
1196
1356
  throw new Error(
1197
1357
  'ctx.runPlay scheduled child launch requires an executor token.',
@@ -1207,12 +1367,7 @@ export class PlayContextImpl {
1207
1367
  [SYNTHETIC_RUN_HEADER]: '1',
1208
1368
  }
1209
1369
  : {}),
1210
- ...(this.#options.vercelProtectionBypassToken?.trim()
1211
- ? {
1212
- 'x-vercel-protection-bypass':
1213
- this.#options.vercelProtectionBypassToken.trim(),
1214
- }
1215
- : {}),
1370
+ ...(await this.vercelProtectionHeaders()),
1216
1371
  };
1217
1372
  }
1218
1373
 
@@ -1250,25 +1405,36 @@ export class PlayContextImpl {
1250
1405
  descendantCount: input.childGovernance.descendantCount,
1251
1406
  waterfallStepExecutions: input.childGovernance.waterfallStepExecutions,
1252
1407
  };
1253
- const response = await fetch(
1254
- `${this.runtimeApiBaseUrl()}/api/v2/plays/run`,
1255
- {
1256
- method: 'POST',
1257
- headers: this.runtimeApiHeaders(),
1258
- body: JSON.stringify({
1259
- name: input.childPlayName,
1260
- workflowId: input.childRunId,
1261
- input: input.childInput,
1262
- profile: 'hatchet',
1263
- waitForCompletionMs: 0,
1264
- internalRunPlay: {
1265
- ...governance,
1266
- description: input.description ?? null,
1267
- },
1268
- }),
1408
+ const requestBody = JSON.stringify({
1409
+ name: input.childPlayName,
1410
+ workflowId: input.childRunId,
1411
+ input: input.childInput,
1412
+ // Inherit the parent's scheduler profile so an `absurd` parent launches
1413
+ // its child on the same scheduler. Absent threading defaults to absurd.
1414
+ profile: this.#options.childRunProfile?.trim() || 'absurd',
1415
+ waitForCompletionMs: 0,
1416
+ internalRunPlay: {
1417
+ ...governance,
1418
+ description: input.description ?? null,
1269
1419
  },
1270
- );
1271
- if (!response.ok) {
1420
+ });
1421
+ for (let attempt = 0; ; attempt += 1) {
1422
+ const response = await fetch(
1423
+ `${this.runtimeApiBaseUrl()}/api/v2/plays/run`,
1424
+ {
1425
+ method: 'POST',
1426
+ headers: await this.runtimeApiHeaders(),
1427
+ body: requestBody,
1428
+ },
1429
+ );
1430
+ if (response.ok) {
1431
+ if (attempt > 0) {
1432
+ this.log(
1433
+ `ctx.runPlay(${input.key}): child launch recovered after ${attempt} retry(s)`,
1434
+ );
1435
+ }
1436
+ return;
1437
+ }
1272
1438
  const body = (await response.json().catch(() => null)) as {
1273
1439
  error?: unknown;
1274
1440
  } | null;
@@ -1276,9 +1442,24 @@ export class PlayContextImpl {
1276
1442
  typeof body?.error === 'string' && body.error.trim()
1277
1443
  ? body.error.trim()
1278
1444
  : response.statusText;
1279
- throw new Error(
1280
- `ctx.runPlay(${input.key}) failed to launch child play "${input.childPlayName}": ${message} (status ${response.status}).`,
1445
+ const retryDelayMs = childPlayLaunchRetryDelayMs({
1446
+ status: response.status,
1447
+ message,
1448
+ attempt,
1449
+ });
1450
+ if (retryDelayMs === null) {
1451
+ throw new Error(
1452
+ `ctx.runPlay(${input.key}) failed to launch child play "${input.childPlayName}": ${message} (status ${response.status}).`,
1453
+ );
1454
+ }
1455
+ const retryReason =
1456
+ response.status === 429 && isChildPlayLaunchAdmissionMessage(message)
1457
+ ? 'start admission backpressure'
1458
+ : 'transient write conflict';
1459
+ this.log(
1460
+ `ctx.runPlay(${input.key}): retrying child launch after ${retryReason} (attempt ${attempt + 1})`,
1281
1461
  );
1462
+ await sleepTransientRuntimeMs(retryDelayMs);
1282
1463
  }
1283
1464
  }
1284
1465
 
@@ -1294,7 +1475,7 @@ export class PlayContextImpl {
1294
1475
  `${this.runtimeApiBaseUrl()}${PLAY_RUNTIME_API_COMPAT_PATH}`,
1295
1476
  {
1296
1477
  method: 'POST',
1297
- headers: this.runtimeApiHeaders(),
1478
+ headers: await this.runtimeApiHeaders(),
1298
1479
  body: JSON.stringify({
1299
1480
  action: 'read_child_run_terminal_snapshot',
1300
1481
  parentRunId: this.governance.currentRunId,
@@ -1412,12 +1593,7 @@ export class PlayContextImpl {
1412
1593
  headers: {
1413
1594
  Authorization: `Bearer ${this.#options.executorToken}`,
1414
1595
  'Content-Type': 'application/json',
1415
- ...(this.#options.vercelProtectionBypassToken?.trim()
1416
- ? {
1417
- 'x-vercel-protection-bypass':
1418
- this.#options.vercelProtectionBypassToken.trim(),
1419
- }
1420
- : {}),
1596
+ ...(await this.vercelProtectionHeaders()),
1421
1597
  },
1422
1598
  body: JSON.stringify({
1423
1599
  action: 'resolve_secret',
@@ -1459,7 +1635,8 @@ export class PlayContextImpl {
1459
1635
  artifactTableNamespace: string;
1460
1636
  mapNodeId?: string | null;
1461
1637
  explicitKey?:
1462
- ((row: Record<string, unknown>, index: number) => string) | null;
1638
+ | ((row: Record<string, unknown>, index: number) => string)
1639
+ | null;
1463
1640
  }): MapExecutionScope {
1464
1641
  const mapInvocationId = `${input.logicalNamespace}:${this.mapInvocationIndex}`;
1465
1642
  this.mapInvocationIndex += 1;
@@ -1507,7 +1684,7 @@ export class PlayContextImpl {
1507
1684
  return receipt
1508
1685
  ? {
1509
1686
  key: key.trim(),
1510
- status: 'pending',
1687
+ status: 'queued',
1511
1688
  }
1512
1689
  : null;
1513
1690
  }
@@ -1548,6 +1725,10 @@ export class PlayContextImpl {
1548
1725
  leaseId?: string | null,
1549
1726
  ): Promise<RuntimeStepReceipt | null> {
1550
1727
  assertNoSecretTaint(output, 'ctx.step receipt output');
1728
+ assertRuntimeReceiptOutputWithinLimit({
1729
+ output,
1730
+ path: `ctx.step receipt "${key}"`,
1731
+ });
1551
1732
  if (!this.#options.completeRuntimeStepReceipt) {
1552
1733
  return null;
1553
1734
  }
@@ -1639,6 +1820,30 @@ export class PlayContextImpl {
1639
1820
  byLeaseId.set(target.leaseId, keys);
1640
1821
  }
1641
1822
 
1823
+ if (
1824
+ this.#options.markRuntimeStepReceiptsRunning ||
1825
+ this.#options.markRuntimeStepReceiptRunning
1826
+ ) {
1827
+ const receipts = await this.markRuntimeStepReceiptsRunning(
1828
+ targets.map((target) => ({
1829
+ key: target.receiptKey,
1830
+ runId: this.currentRunId,
1831
+ leaseId: target.leaseId,
1832
+ })),
1833
+ );
1834
+ for (const target of targets) {
1835
+ const receipt = receipts.get(target.receiptKey);
1836
+ if (!this.runtimeToolReceiptStillOwned(receipt, target.leaseId)) {
1837
+ throw new RuntimeReceiptLeaseLostError({
1838
+ receiptKey: target.receiptKey,
1839
+ runId: this.currentRunId,
1840
+ leaseId: target.leaseId,
1841
+ });
1842
+ }
1843
+ }
1844
+ return;
1845
+ }
1846
+
1642
1847
  if (this.#options.heartbeatRuntimeStepReceipts) {
1643
1848
  for (const [leaseId, keys] of byLeaseId) {
1644
1849
  const receipts = await this.#options.heartbeatRuntimeStepReceipts({
@@ -1702,7 +1907,11 @@ export class PlayContextImpl {
1702
1907
  receipt: RuntimeStepReceipt | null | undefined,
1703
1908
  leaseId: string,
1704
1909
  ): boolean {
1705
- if (!receipt || receipt.status !== 'running') return false;
1910
+ if (!receipt) return false;
1911
+ if (receipt.status === 'completed' || receipt.status === 'skipped') {
1912
+ return true;
1913
+ }
1914
+ if (receipt.status !== 'running') return false;
1706
1915
  if (receipt.leaseId !== leaseId) return false;
1707
1916
  const ownerRunId = receipt.leaseOwnerRunId ?? receipt.runId ?? null;
1708
1917
  return !ownerRunId || ownerRunId === this.currentRunId;
@@ -1727,6 +1936,18 @@ export class PlayContextImpl {
1727
1936
  return ownerAttempt === this.currentRunAttempt;
1728
1937
  }
1729
1938
 
1939
+ private isOwnedClaimedRuntimeReceipt(
1940
+ receipt: RuntimeStepReceipt | null | undefined,
1941
+ ): receipt is RuntimeStepReceipt {
1942
+ return (
1943
+ Boolean(receipt) &&
1944
+ receipt?.claimState !== 'existing' &&
1945
+ (receipt?.status === 'queued' ||
1946
+ receipt?.status === 'pending' ||
1947
+ receipt?.status === 'running')
1948
+ );
1949
+ }
1950
+
1730
1951
  private async failRuntimeStepReceipt(
1731
1952
  key: string,
1732
1953
  runId: string,
@@ -1774,6 +1995,38 @@ export class PlayContextImpl {
1774
1995
  };
1775
1996
  }
1776
1997
 
1998
+ /**
1999
+ * Dispatch a batched runtime-step-receipt request in bounded chunks and
2000
+ * concatenate the aligned result arrays back into one array positioned
2001
+ * identically to `items`. Every batch adapter returns receipts index-aligned
2002
+ * to the keys/receipts it was given, and chunks are dispatched sequentially in
2003
+ * input order, so the concatenation preserves the exact per-key mapping the
2004
+ * callers rely on. Sequential dispatch also keeps at most one receipt request
2005
+ * in flight, bounding tenant-DB and origin load at map scale. See
2006
+ * RUNTIME_STEP_RECEIPT_REQUEST_CHUNK_SIZE.
2007
+ */
2008
+ private async dispatchChunkedRuntimeReceiptRequest<TItem>(
2009
+ items: readonly TItem[],
2010
+ dispatchChunk: (
2011
+ chunk: TItem[],
2012
+ ) =>
2013
+ | Promise<Array<RuntimeStepReceipt | null>>
2014
+ | Array<RuntimeStepReceipt | null>,
2015
+ ): Promise<Array<RuntimeStepReceipt | null>> {
2016
+ const chunks = chunkArrayForReceiptRequest(items);
2017
+ if (chunks.length <= 1) {
2018
+ return chunks.length === 0 ? [] : dispatchChunk(chunks[0]!);
2019
+ }
2020
+ const results: Array<RuntimeStepReceipt | null> = [];
2021
+ for (const chunk of chunks) {
2022
+ const chunkResults = await dispatchChunk(chunk);
2023
+ for (const result of chunkResults) {
2024
+ results.push(result);
2025
+ }
2026
+ }
2027
+ return results;
2028
+ }
2029
+
1777
2030
  private async getRuntimeStepReceipts(
1778
2031
  keys: string[],
1779
2032
  ): Promise<Map<string, RuntimeStepReceipt>> {
@@ -1781,8 +2034,11 @@ export class PlayContextImpl {
1781
2034
  ...new Set(keys.map((key) => key.trim()).filter(Boolean)),
1782
2035
  ];
1783
2036
  if (uniqueKeys.length === 0) return new Map();
1784
- const receipts = this.#options.getRuntimeStepReceipts
1785
- ? await this.#options.getRuntimeStepReceipts({ keys: uniqueKeys })
2037
+ const getReceipts = this.#options.getRuntimeStepReceipts;
2038
+ const receipts = getReceipts
2039
+ ? await this.dispatchChunkedRuntimeReceiptRequest(uniqueKeys, (chunk) =>
2040
+ getReceipts({ keys: chunk }),
2041
+ )
1786
2042
  : await Promise.all(
1787
2043
  uniqueKeys.map((key) => this.getRuntimeStepReceipt(key)),
1788
2044
  );
@@ -1808,16 +2064,19 @@ export class PlayContextImpl {
1808
2064
  ...new Set(keys.map((key) => key.trim()).filter(Boolean)),
1809
2065
  ];
1810
2066
  if (uniqueKeys.length === 0) return new Map();
1811
- const receipts = this.#options.claimRuntimeStepReceipts
1812
- ? await this.#options.claimRuntimeStepReceipts({
1813
- keys: uniqueKeys,
1814
- runId,
1815
- runAttempt: this.currentRunAttempt,
1816
- leaseAware: true,
1817
- ...(reclaimRunning ? { reclaimRunning: true } : {}),
1818
- ...(forceRefresh ? { forceRefresh: true } : {}),
1819
- ...(forceFailedRefresh ? { forceFailedRefresh: true } : {}),
1820
- })
2067
+ const claimReceipts = this.#options.claimRuntimeStepReceipts;
2068
+ const receipts = claimReceipts
2069
+ ? await this.dispatchChunkedRuntimeReceiptRequest(uniqueKeys, (chunk) =>
2070
+ claimReceipts({
2071
+ keys: chunk,
2072
+ runId,
2073
+ runAttempt: this.currentRunAttempt,
2074
+ leaseAware: true,
2075
+ ...(reclaimRunning ? { reclaimRunning: true } : {}),
2076
+ ...(forceRefresh ? { forceRefresh: true } : {}),
2077
+ ...(forceFailedRefresh ? { forceFailedRefresh: true } : {}),
2078
+ }),
2079
+ )
1821
2080
  : await Promise.all(
1822
2081
  uniqueKeys.map((key) =>
1823
2082
  this.claimRuntimeStepReceipt(
@@ -1840,6 +2099,111 @@ export class PlayContextImpl {
1840
2099
  return byKey;
1841
2100
  }
1842
2101
 
2102
+ private async markRuntimeStepReceiptRunning(input: {
2103
+ key: string;
2104
+ runId: string;
2105
+ leaseId?: string | null;
2106
+ }): Promise<RuntimeStepReceipt | null> {
2107
+ if (!this.#options.markRuntimeStepReceiptRunning) {
2108
+ if (input.leaseId && this.#options.heartbeatRuntimeStepReceipts) {
2109
+ return await this.heartbeatRuntimeStepReceipt(
2110
+ input.key,
2111
+ input.runId,
2112
+ input.leaseId,
2113
+ );
2114
+ }
2115
+ return await this.getRuntimeStepReceipt(input.key);
2116
+ }
2117
+ const receipt = await this.#options.markRuntimeStepReceiptRunning({
2118
+ key: input.key,
2119
+ runId: input.runId,
2120
+ runAttempt: this.currentRunAttempt,
2121
+ ...(input.leaseId ? { leaseId: input.leaseId } : {}),
2122
+ });
2123
+ return this.normalizeRuntimeStepReceipt(input.key, receipt);
2124
+ }
2125
+
2126
+ private async markRuntimeStepReceiptsRunning(
2127
+ receipts: Array<{
2128
+ key: string;
2129
+ runId: string;
2130
+ leaseId?: string | null;
2131
+ }>,
2132
+ ): Promise<Map<string, RuntimeStepReceipt>> {
2133
+ const normalizedInputs = receipts.filter((receipt) => receipt.key.trim());
2134
+ if (normalizedInputs.length === 0) return new Map();
2135
+ let marked: Array<RuntimeStepReceipt | null>;
2136
+ const markReceipts = this.#options.markRuntimeStepReceiptsRunning;
2137
+ if (markReceipts) {
2138
+ marked = await this.dispatchChunkedRuntimeReceiptRequest(
2139
+ normalizedInputs.map((receipt) => ({
2140
+ ...receipt,
2141
+ runAttempt: this.currentRunAttempt,
2142
+ })),
2143
+ (chunk) => markReceipts({ receipts: chunk }),
2144
+ );
2145
+ } else {
2146
+ marked = await Promise.all(
2147
+ normalizedInputs.map((receipt) =>
2148
+ this.markRuntimeStepReceiptRunning(receipt),
2149
+ ),
2150
+ );
2151
+ }
2152
+ const byKey = new Map<string, RuntimeStepReceipt>();
2153
+ for (let index = 0; index < marked.length; index += 1) {
2154
+ const normalized = this.normalizeRuntimeStepReceipt(
2155
+ normalizedInputs[index]?.key ?? '',
2156
+ marked[index],
2157
+ );
2158
+ if (normalized) byKey.set(normalized.key, normalized);
2159
+ }
2160
+ return byKey;
2161
+ }
2162
+
2163
+ private async markRuntimeStepReceiptsQueued(
2164
+ receipts: Array<{
2165
+ key: string;
2166
+ runId: string;
2167
+ leaseId?: string | null;
2168
+ }>,
2169
+ ): Promise<Map<string, RuntimeStepReceipt>> {
2170
+ const normalizedInputs = receipts.filter((receipt) => receipt.key.trim());
2171
+ if (normalizedInputs.length === 0) return new Map();
2172
+ if (!this.#options.markRuntimeStepReceiptsQueued) {
2173
+ return new Map();
2174
+ }
2175
+ const queued = await this.dispatchChunkedRuntimeReceiptRequest(
2176
+ normalizedInputs.map((receipt) => ({
2177
+ ...receipt,
2178
+ runAttempt: this.currentRunAttempt,
2179
+ })),
2180
+ (chunk) =>
2181
+ this.#options.markRuntimeStepReceiptsQueued!({ receipts: chunk }),
2182
+ );
2183
+ const byKey = new Map<string, RuntimeStepReceipt>();
2184
+ for (let index = 0; index < queued.length; index += 1) {
2185
+ const normalized = this.normalizeRuntimeStepReceipt(
2186
+ normalizedInputs[index]?.key ?? '',
2187
+ queued[index],
2188
+ );
2189
+ if (normalized) byKey.set(normalized.key, normalized);
2190
+ }
2191
+ return byKey;
2192
+ }
2193
+
2194
+ private async parkRuntimeToolReceiptsQueued(
2195
+ requests: ToolCallRequest[],
2196
+ ): Promise<void> {
2197
+ const targets = requests.flatMap((request) => {
2198
+ const receiptKey = request.receiptKey?.trim() || null;
2199
+ const leaseId = request.receiptLeaseId?.trim() || null;
2200
+ if (!receiptKey || !leaseId) return [];
2201
+ return [{ key: receiptKey, runId: this.currentRunId, leaseId }];
2202
+ });
2203
+ if (targets.length === 0) return;
2204
+ await this.markRuntimeStepReceiptsQueued(targets);
2205
+ }
2206
+
1843
2207
  private async completeRuntimeStepReceipts(
1844
2208
  receipts: Array<{
1845
2209
  key: string;
@@ -1853,16 +2217,22 @@ export class PlayContextImpl {
1853
2217
  if (normalizedInputs.length === 0) return new Map();
1854
2218
  for (const receipt of normalizedInputs) {
1855
2219
  assertNoSecretTaint(receipt.output, 'ctx.tool receipt output');
2220
+ assertRuntimeReceiptOutputWithinLimit({
2221
+ output: receipt.output,
2222
+ path: `ctx.tool receipt "${receipt.key}"`,
2223
+ });
1856
2224
  }
1857
2225
  let completed: Array<RuntimeStepReceipt | null>;
1858
2226
  try {
1859
- completed = this.#options.completeRuntimeStepReceipts
1860
- ? await this.#options.completeRuntimeStepReceipts({
1861
- receipts: normalizedInputs.map((receipt) => ({
2227
+ const completeReceipts = this.#options.completeRuntimeStepReceipts;
2228
+ completed = completeReceipts
2229
+ ? await this.dispatchChunkedRuntimeReceiptRequest(
2230
+ normalizedInputs.map((receipt) => ({
1862
2231
  ...receipt,
1863
2232
  runAttempt: receipt.runAttempt ?? this.currentRunAttempt,
1864
2233
  })),
1865
- })
2234
+ (chunk) => completeReceipts({ receipts: chunk }),
2235
+ )
1866
2236
  : await Promise.all(
1867
2237
  normalizedInputs.map((receipt) =>
1868
2238
  this.completeRuntimeStepReceipt(
@@ -1998,13 +2368,15 @@ export class PlayContextImpl {
1998
2368
  ): Promise<Map<string, RuntimeStepReceipt>> {
1999
2369
  const normalizedInputs = receipts.filter((receipt) => receipt.key.trim());
2000
2370
  if (normalizedInputs.length === 0) return new Map();
2001
- const failed = this.#options.failRuntimeStepReceipts
2002
- ? await this.#options.failRuntimeStepReceipts({
2003
- receipts: normalizedInputs.map((receipt) => ({
2371
+ const failReceipts = this.#options.failRuntimeStepReceipts;
2372
+ const failed = failReceipts
2373
+ ? await this.dispatchChunkedRuntimeReceiptRequest(
2374
+ normalizedInputs.map((receipt) => ({
2004
2375
  ...receipt,
2005
2376
  runAttempt: receipt.runAttempt ?? this.currentRunAttempt,
2006
2377
  })),
2007
- })
2378
+ (chunk) => failReceipts({ receipts: chunk }),
2379
+ )
2008
2380
  : await Promise.all(
2009
2381
  normalizedInputs.map((receipt) =>
2010
2382
  this.failRuntimeStepReceipt(
@@ -2114,6 +2486,17 @@ export class PlayContextImpl {
2114
2486
  forceRefresh,
2115
2487
  forceFailedRefresh,
2116
2488
  ),
2489
+ ...(this.#options.markRuntimeStepReceiptRunning ||
2490
+ this.#options.markRuntimeStepReceiptsRunning
2491
+ ? {
2492
+ markRunning: (receiptKey, runId, leaseId) =>
2493
+ this.markRuntimeStepReceiptRunning({
2494
+ key: receiptKey,
2495
+ runId,
2496
+ leaseId,
2497
+ }),
2498
+ }
2499
+ : {}),
2117
2500
  complete: (receiptKey, runId, output, leaseId) =>
2118
2501
  this.completeRuntimeStepReceipt(receiptKey, runId, output, leaseId),
2119
2502
  release: (receiptKey, runId, leaseId) =>
@@ -2133,7 +2516,10 @@ export class PlayContextImpl {
2133
2516
  failureKind,
2134
2517
  ),
2135
2518
  canPersistFailure: Boolean(this.#options.failRuntimeStepReceipt),
2136
- canPersistCompletion: Boolean(this.#options.completeRuntimeStepReceipt),
2519
+ canPersistCompletion: Boolean(
2520
+ this.#options.completeRuntimeStepReceipt ||
2521
+ this.#options.completeRuntimeStepReceipts,
2522
+ ),
2137
2523
  };
2138
2524
  }
2139
2525
 
@@ -2152,6 +2538,21 @@ export class PlayContextImpl {
2152
2538
  });
2153
2539
  }
2154
2540
 
2541
+ private async waitForCompletedRuntimeToolReceipts(
2542
+ keys: string[],
2543
+ maxAttempts?: number,
2544
+ ): Promise<{
2545
+ completed: Map<string, RuntimeStepReceipt>;
2546
+ failed: Map<string, Error>;
2547
+ timedOut: Set<string>;
2548
+ }> {
2549
+ return await waitForCompletedRuntimeReceipts({
2550
+ receiptKeys: keys,
2551
+ store: this.durableReceiptExecutionStore(),
2552
+ maxAttempts,
2553
+ });
2554
+ }
2555
+
2155
2556
  private async executeWithRuntimeReceipt<T>(
2156
2557
  operation: DurableCtxOperation,
2157
2558
  id: string,
@@ -2174,6 +2575,7 @@ export class PlayContextImpl {
2174
2575
  ) => T;
2175
2576
  onClaimedResult?: (output: T, receiptKey: string) => T;
2176
2577
  shouldPersistFailure?: (error: unknown) => boolean;
2578
+ markRunningBeforeExecute?: boolean;
2177
2579
  execute: (context: { leaseId: string | null }) => Promise<T>;
2178
2580
  },
2179
2581
  ): Promise<T> {
@@ -2204,6 +2606,7 @@ export class PlayContextImpl {
2204
2606
  onRecovered: opts.onRecovered,
2205
2607
  onClaimedResult: opts.onClaimedResult,
2206
2608
  shouldPersistFailure: opts.shouldPersistFailure,
2609
+ markRunningBeforeExecute: opts.markRunningBeforeExecute,
2207
2610
  formatError: (error) => this.formatRuntimeError(error),
2208
2611
  log: (message) => this.log(message),
2209
2612
  execute: opts.execute,
@@ -2820,8 +3223,8 @@ export class PlayContextImpl {
2820
3223
  completed?.status !== 'completed' &&
2821
3224
  completed?.status !== 'skipped'
2822
3225
  ) {
2823
- throw new Error(
2824
- `Durable tool call ${receiptKey} lost receipt ownership before completion.`,
3226
+ this.log(
3227
+ `Durable tool call ${receiptKey} completed live after its receipt lease moved; using live result without overwriting the receipt.`,
2825
3228
  );
2826
3229
  }
2827
3230
  const finalWrapped =
@@ -2885,6 +3288,7 @@ export class PlayContextImpl {
2885
3288
  let rejectionError = error;
2886
3289
  const receiptKey = request.receiptKey?.trim() || null;
2887
3290
  if (receiptKey && options?.persistReceiptFailure !== false) {
3291
+ const persistenceAlreadyFailed = this.persistenceLatch.tripped;
2888
3292
  try {
2889
3293
  await this.failRuntimeStepReceipts([
2890
3294
  {
@@ -2897,10 +3301,13 @@ export class PlayContextImpl {
2897
3301
  ]);
2898
3302
  } catch (receiptError) {
2899
3303
  tripRuntimePersistenceLatch(this.persistenceLatch, receiptError);
2900
- rejectionError = new AggregateError(
2901
- [error, receiptError],
2902
- 'Tool call failed and durable receipt could not be marked failed',
2903
- );
3304
+ if (!persistenceAlreadyFailed) {
3305
+ const receiptFailureMessage = this.formatRuntimeError(receiptError);
3306
+ rejectionError = new AggregateError(
3307
+ [error, receiptError],
3308
+ `Tool call failed and durable receipt could not be marked failed: ${message}; receipt failure: ${receiptFailureMessage}`,
3309
+ );
3310
+ }
2904
3311
  }
2905
3312
  }
2906
3313
  const resolver = this.toolCallResolvers.get(request.callId);
@@ -2951,7 +3358,7 @@ export class PlayContextImpl {
2951
3358
  // The activity loads the actual data before creating the ctx
2952
3359
  throw new Error(
2953
3360
  `ctx.csv("${path}") is handled by the workflow. ` +
2954
- `CSV data is loaded as a Temporal activity and passed to your play via input.`,
3361
+ `CSV data is loaded by the runtime and passed to your play via input.`,
2955
3362
  );
2956
3363
  }
2957
3364
 
@@ -3006,7 +3413,8 @@ export class PlayContextImpl {
3006
3413
  _key: string,
3007
3414
  _items: PlayDatasetInput<T>,
3008
3415
  _input?:
3009
- MapFieldDefinition<T, Record<string, unknown>> | RuntimeStepProgram,
3416
+ | MapFieldDefinition<T, Record<string, unknown>>
3417
+ | RuntimeStepProgram,
3010
3418
  _options?: DatasetOptions<T>,
3011
3419
  ): never {
3012
3420
  void _key;
@@ -3092,7 +3500,8 @@ export class PlayContextImpl {
3092
3500
 
3093
3501
  const userKeyOption = options?.key;
3094
3502
  let explicitKeyResolver:
3095
- ((row: Record<string, unknown>, index: number) => string) | null = null;
3503
+ | ((row: Record<string, unknown>, index: number) => string)
3504
+ | null = null;
3096
3505
  if (userKeyOption !== undefined) {
3097
3506
  explicitKeyResolver = (row, index) => {
3098
3507
  const inputRow = stripFieldOutputs(row);
@@ -3143,7 +3552,516 @@ export class PlayContextImpl {
3143
3552
  : derivePlayRowIdentity(stableInputRow(row), resolvedTableNamespace);
3144
3553
  };
3145
3554
 
3146
- const rawMaterializedItems = await materializePlayDatasetInput(items);
3555
+ // Identity for SEED rows (pre-execution raw inputs), matching the server's
3556
+ // own derivation in startRuntimeSheetDataset: hash the FULL cleaned input
3557
+ // row, output-named columns INCLUDED. Two distinct input rows that differ
3558
+ // only in a column sharing an output's name (e.g. a CSV `domain` column +
3559
+ // withColumn('domain', ...)) must stay two rows — the stripped-row
3560
+ // `rowIdentity` fallback would collapse them to one key, silently dropping
3561
+ // a row from execution and the sheet. Output-insensitivity across the
3562
+ // execution round-trip is provided by CARRYING this key on the row
3563
+ // (__deeplineRowKey, which rowIdentity prefers), not by stripping.
3564
+ const seedRowIdentity = (row: Record<string, unknown>, index = 0) => {
3565
+ const runtimeKey = resolveMapRowOutcomeKey(row);
3566
+ if (runtimeKey) return runtimeKey;
3567
+ if (explicitKeyResolver) {
3568
+ return derivePlayRowIdentityFromKey(
3569
+ explicitKeyResolver(row, index),
3570
+ resolvedTableNamespace,
3571
+ );
3572
+ }
3573
+ return derivePlayRowIdentity(
3574
+ toSerializableCsvAliasedRow(row),
3575
+ resolvedTableNamespace,
3576
+ );
3577
+ };
3578
+
3579
+ const mapMemoryLimits = resolveRuntimeMapMemoryLimits(
3580
+ this.#options.runtimeMapMemoryLimits,
3581
+ );
3582
+ const materializationMemoryTracker = createRuntimeMapMaterializationTracker(
3583
+ {
3584
+ mapName: normalizedMapNamespace,
3585
+ budgetBytes: mapMemoryLimits.materializedBudgetBytes,
3586
+ },
3587
+ );
3588
+ const shouldStreamRuntimeBackedDataset =
3589
+ isPlayDataset<T>(items) &&
3590
+ this.#options.runtimeSheetBackedMapDatasets === true &&
3591
+ Boolean(
3592
+ this.#options.onMapStart &&
3593
+ this.#options.onMapRowsCompleted &&
3594
+ this.#options.baseUrl &&
3595
+ this.#options.executorToken &&
3596
+ this.#options.playName &&
3597
+ this.#options.runId,
3598
+ );
3599
+ if (shouldStreamRuntimeBackedDataset) {
3600
+ totalInputCount = await items.count();
3601
+ const mapScope = this.createMapExecutionScope({
3602
+ logicalNamespace: normalizedMapNamespace,
3603
+ artifactTableNamespace: resolvedTableNamespace,
3604
+ explicitKey: explicitKeyResolver,
3605
+ });
3606
+ this.setMapFrame({
3607
+ mapInvocationId: mapScope.mapInvocationId,
3608
+ mapNodeId: mapScope.mapNodeId ?? null,
3609
+ logicalNamespace: mapScope.logicalNamespace,
3610
+ artifactTableNamespace: mapScope.artifactTableNamespace,
3611
+ status: 'running',
3612
+ totalRows: totalInputCount,
3613
+ completedRowKeys: [],
3614
+ pendingRowKeys: [],
3615
+ completedRowsCount: 0,
3616
+ pendingRowsCount: totalInputCount,
3617
+ failedRowsCount: 0,
3618
+ startedAt: Date.now(),
3619
+ updatedAt: Date.now(),
3620
+ });
3621
+ this.emitExecutionEvent({
3622
+ type: 'map.started',
3623
+ mapInvocationId: mapScope.mapInvocationId,
3624
+ mapNodeId: mapScope.mapNodeId ?? null,
3625
+ logicalNamespace: mapScope.logicalNamespace,
3626
+ artifactTableNamespace: mapScope.artifactTableNamespace,
3627
+ totalRows: totalInputCount,
3628
+ completedRows: 0,
3629
+ pendingRows: totalInputCount,
3630
+ at: Date.now(),
3631
+ });
3632
+
3633
+ const seenExplicitKeys = new Set<string>();
3634
+ let processedCount = 0;
3635
+ let successfulCount = 0;
3636
+ let failedCount = 0;
3637
+ let duplicateReuseCount = 0;
3638
+ let executedCount = 0;
3639
+ const previewRows: Record<string, unknown>[] = [];
3640
+
3641
+ const persistMapRows = async (rows: PersistableMapRow[]) => {
3642
+ if (!this.#options.onMapRowsCompleted || rows.length === 0) {
3643
+ return;
3644
+ }
3645
+ for (const row of rows) {
3646
+ assertPersistableMapRowWithinCustomerOutputLimits({
3647
+ mapName: normalizedMapNamespace,
3648
+ row,
3649
+ });
3650
+ }
3651
+ let chunk: PersistableMapRow[] = [];
3652
+ let chunkBytes = 0;
3653
+ const flushChunk = async () => {
3654
+ if (chunk.length === 0) return;
3655
+ try {
3656
+ const sheetFlushLease =
3657
+ await this.resourceGovernor.acquireSheetFlush({
3658
+ estimatedBytes: chunkBytes,
3659
+ rowCount: chunk.length,
3660
+ });
3661
+ const flushStartedAt = Date.now();
3662
+ try {
3663
+ await this.#options.onMapRowsCompleted!({
3664
+ playName: this.#options.playName,
3665
+ playId: this.#options.playId,
3666
+ runId: this.#options.runId,
3667
+ executorToken: this.#options.executorToken,
3668
+ tableNamespace: resolvedTableNamespace,
3669
+ rows: chunk,
3670
+ outputFields: datasetColumnNames.filter((field) =>
3671
+ shouldPersistMapCellField(field),
3672
+ ),
3673
+ staticPipeline: this.#options.staticPipeline ?? null,
3674
+ });
3675
+ this.resourceGovernor.observe({
3676
+ sheetFlushBytes: chunkBytes,
3677
+ sheetFlushLatencyMs: Date.now() - flushStartedAt,
3678
+ });
3679
+ } finally {
3680
+ sheetFlushLease.release();
3681
+ }
3682
+ } catch (error) {
3683
+ tripRuntimePersistenceLatch(this.persistenceLatch, error);
3684
+ throw error;
3685
+ }
3686
+ chunk = [];
3687
+ chunkBytes = 0;
3688
+ };
3689
+ for (const row of rows) {
3690
+ const rowBytes = persistableMapRowBytes(row);
3691
+ if (
3692
+ chunk.length >= MAP_PERSIST_CHUNK_ROWS ||
3693
+ (chunkBytes + rowBytes > MAP_PERSIST_CHUNK_BYTES &&
3694
+ chunk.length > 0)
3695
+ ) {
3696
+ await flushChunk();
3697
+ }
3698
+ chunk.push(row);
3699
+ chunkBytes += rowBytes;
3700
+ }
3701
+ await flushChunk();
3702
+ };
3703
+
3704
+ for await (const page of iteratePlayDatasetInputPages(items)) {
3705
+ const pageRawItems = page.rows.map((item) =>
3706
+ this.toOutputRow(item as Record<string, unknown>),
3707
+ );
3708
+ const pageMemoryEstimate = estimateRuntimeMapRowsMemory(pageRawItems);
3709
+ if (
3710
+ pageMemoryEstimate.estimatedResidentBytes >
3711
+ mapMemoryLimits.materializedBudgetBytes
3712
+ ) {
3713
+ throw new RuntimeMapMemoryLimitError({
3714
+ mapName: normalizedMapNamespace,
3715
+ phase: 'materialize',
3716
+ estimate: pageMemoryEstimate,
3717
+ budgetBytes: mapMemoryLimits.materializedBudgetBytes,
3718
+ });
3719
+ }
3720
+ const pageStartRows = pageRawItems.map((row, index) => {
3721
+ const originalIndex = page.offset + index;
3722
+ return {
3723
+ ...toSerializableCsvAliasedRow(row),
3724
+ ...mapRowOutcomeRuntimeFields({
3725
+ key: seedRowIdentity(row, originalIndex),
3726
+ inputIndex: originalIndex,
3727
+ }),
3728
+ };
3729
+ });
3730
+ const mapStartResult = await this.#options.onMapStart!(
3731
+ pageStartRows,
3732
+ resolvedTableNamespace,
3733
+ {
3734
+ playName: this.#options.playName,
3735
+ playId: this.#options.playId,
3736
+ runId: this.#options.runId,
3737
+ executorToken: this.#options.executorToken,
3738
+ staticPipeline: this.#options.staticPipeline,
3739
+ forceRefresh: this.#options.cachePolicy?.forceToolRefresh === true,
3740
+ inputOffset: page.offset,
3741
+ },
3742
+ );
3743
+ resolvedTableNamespace = normalizeTableNamespace(
3744
+ mapStartResult.tableNamespace,
3745
+ );
3746
+ if ((mapStartResult.blockedRows?.length ?? 0) > 0) {
3747
+ throw new RuntimeSheetRowsBlockedError({
3748
+ tableNamespace: resolvedTableNamespace,
3749
+ blockedRows: mapStartResult.blockedRows ?? [],
3750
+ });
3751
+ }
3752
+
3753
+ const persistedRowIdentity = (
3754
+ row: Record<string, unknown>,
3755
+ index = 0,
3756
+ ) => resolveMapRowOutcomeKey(row) ?? rowIdentity(row, index);
3757
+ const pendingRowsByKey = new Map<string, Record<string, unknown>>();
3758
+ for (
3759
+ let index = 0;
3760
+ index < mapStartResult.pendingRows.length;
3761
+ index += 1
3762
+ ) {
3763
+ const row = mapStartResult.pendingRows[index]!;
3764
+ const rowKey = persistedRowIdentity(row, page.offset + index);
3765
+ if (rowKey) pendingRowsByKey.set(rowKey, row);
3766
+ }
3767
+ const completedRowsByKey = new Map<string, Record<string, unknown>>();
3768
+ for (const row of mapStartResult.completedRows ?? []) {
3769
+ const rowKey = persistedRowIdentity(row);
3770
+ if (!rowKey) continue;
3771
+ completedRowsByKey.set(rowKey, row);
3772
+ }
3773
+
3774
+ const seededItems = page.rows.map((item, index) => {
3775
+ const originalIndex = page.offset + index;
3776
+ const row = this.toOutputRow(item as Record<string, unknown>);
3777
+ const rowKey = seedRowIdentity(row, originalIndex);
3778
+ const persisted =
3779
+ pendingRowsByKey.get(rowKey) ?? completedRowsByKey.get(rowKey);
3780
+ return {
3781
+ row: persisted
3782
+ ? cloneCsvAliasedRow(
3783
+ row,
3784
+ persistedFieldsAbsentFromInputRow(row, persisted),
3785
+ )
3786
+ : row,
3787
+ index: originalIndex,
3788
+ };
3789
+ });
3790
+
3791
+ const rowsToExecuteByKey = new Map<
3792
+ string,
3793
+ { row: Record<string, unknown>; originalIndex: number }
3794
+ >();
3795
+ for (const { row, index } of seededItems) {
3796
+ if (explicitKeyResolver) {
3797
+ const explicitKey = explicitKeyResolver(row, index);
3798
+ if (seenExplicitKeys.has(explicitKey)) {
3799
+ duplicateReuseCount += 1;
3800
+ processedCount += 1;
3801
+ continue;
3802
+ }
3803
+ seenExplicitKeys.add(explicitKey);
3804
+ }
3805
+ const rowKey = rowIdentity(row, index);
3806
+ if (!rowsToExecuteByKey.has(rowKey)) {
3807
+ rowsToExecuteByKey.set(rowKey, { row, originalIndex: index });
3808
+ } else {
3809
+ duplicateReuseCount += 1;
3810
+ processedCount += 1;
3811
+ }
3812
+ }
3813
+ const rowsToExecuteEntries = [...rowsToExecuteByKey.entries()].map(
3814
+ ([rowKey, entry]) => ({ rowKey, ...entry }),
3815
+ );
3816
+ const rowsToExecute = rowsToExecuteEntries.map((entry) => entry.row);
3817
+ const rowsToExecuteMemoryEstimate =
3818
+ estimateRuntimeMapRowsMemory(rowsToExecute);
3819
+ const incrementalPersistence =
3820
+ createIncrementalMapRowPersistence(persistMapRows);
3821
+
3822
+ this.activeMapCellMeta = new Map();
3823
+ this.setMapFrame({
3824
+ mapInvocationId: mapScope.mapInvocationId,
3825
+ mapNodeId: mapScope.mapNodeId ?? null,
3826
+ logicalNamespace: mapScope.logicalNamespace,
3827
+ artifactTableNamespace: resolvedTableNamespace,
3828
+ status: 'running',
3829
+ totalRows: totalInputCount,
3830
+ completedRowKeys: [],
3831
+ pendingRowKeys: rowsToExecuteEntries.map((entry) => entry.rowKey),
3832
+ completedRowsCount: Math.max(0, processedCount - failedCount),
3833
+ pendingRowsCount: Math.max(0, totalInputCount - processedCount),
3834
+ failedRowsCount: failedCount,
3835
+ startedAt:
3836
+ this.checkpoint.mapFrames?.[mapScope.mapInvocationId]?.startedAt ??
3837
+ Date.now(),
3838
+ updatedAt: Date.now(),
3839
+ });
3840
+
3841
+ let mapResult: FieldMapRunResult;
3842
+ try {
3843
+ mapResult = await this.runFieldMap(
3844
+ rowsToExecute,
3845
+ mapScope,
3846
+ input as MapFieldDefinition<Record<string, unknown>>,
3847
+ options?.description,
3848
+ {
3849
+ totalRows: totalInputCount,
3850
+ completedRows: duplicateReuseCount,
3851
+ },
3852
+ {
3853
+ emitTerminalEvent: false,
3854
+ onRowError: options?.onRowError,
3855
+ executionRowKeys: rowsToExecuteEntries.map(
3856
+ (entry) => entry.rowKey,
3857
+ ),
3858
+ executionRowIndexes: rowsToExecuteEntries.map(
3859
+ (entry) => entry.originalIndex,
3860
+ ),
3861
+ progressCompletedOffset: Math.max(
3862
+ 0,
3863
+ processedCount - failedCount,
3864
+ ),
3865
+ progressFailedOffset: failedCount,
3866
+ progressTotalRows: totalInputCount,
3867
+ largestRowBytes: rowsToExecuteMemoryEstimate.largestRowBytes,
3868
+ retainedRowsMemoryBudgetBytes:
3869
+ mapMemoryLimits.materializedBudgetBytes,
3870
+ activeRowsMemoryBudgetBytes:
3871
+ mapMemoryLimits.activeRowsBudgetBytes,
3872
+ incrementalPersistence,
3873
+ },
3874
+ );
3875
+ } catch (error) {
3876
+ if (error instanceof FailFastMapRowsError) {
3877
+ const rowsToPersist = [...error.completedRows, ...error.failedRows];
3878
+ await incrementalPersistence.flush();
3879
+ const unpersistedRows = rowsToPersist.filter(
3880
+ (row) => !incrementalPersistence.isPersisted(row),
3881
+ );
3882
+ await persistMapRows(unpersistedRows);
3883
+ this.activeMapCellMeta = null;
3884
+ throw error.cause;
3885
+ }
3886
+ this.activeMapCellMeta = null;
3887
+ throw error;
3888
+ }
3889
+
3890
+ await incrementalPersistence.flush();
3891
+ const mapCellMeta = this.activeMapCellMeta;
3892
+ const persistRows: PersistableMapRow[] = [];
3893
+ for (const row of mapResult.completedRows) {
3894
+ if (incrementalPersistence.isPersisted(row)) continue;
3895
+ const meta = mapCellMeta?.get(row.key);
3896
+ persistRows.push(
3897
+ completedMapRowOutcome({
3898
+ key: row.key,
3899
+ data: row.data,
3900
+ inputIndex: row.inputIndex,
3901
+ cellMetaPatch: {
3902
+ ...(row.cellMetaPatch ?? {}),
3903
+ ...(meta ?? {}),
3904
+ },
3905
+ }),
3906
+ );
3907
+ }
3908
+ persistRows.push(
3909
+ ...mapResult.failedRows.filter(
3910
+ (row) => !incrementalPersistence.isPersisted(row),
3911
+ ),
3912
+ );
3913
+ await persistMapRows(persistRows);
3914
+ this.activeMapCellMeta = null;
3915
+
3916
+ for (const row of mapResult.completedRows) {
3917
+ if (previewRows.length >= 5) break;
3918
+ previewRows.push(this.toMaterializedOutputRow(row.data));
3919
+ }
3920
+ executedCount += rowsToExecute.length;
3921
+ successfulCount += mapResult.completedRows.length;
3922
+ failedCount += mapResult.failedRows.length;
3923
+ processedCount += rowsToExecute.length;
3924
+ page.rows.length = 0;
3925
+ }
3926
+
3927
+ this.setMapFrame({
3928
+ mapInvocationId: mapScope.mapInvocationId,
3929
+ mapNodeId: mapScope.mapNodeId ?? null,
3930
+ logicalNamespace: mapScope.logicalNamespace,
3931
+ artifactTableNamespace: resolvedTableNamespace,
3932
+ status: 'completed',
3933
+ totalRows: totalInputCount,
3934
+ completedRowKeys: [],
3935
+ pendingRowKeys: [],
3936
+ completedRowsCount: Math.max(0, processedCount - failedCount),
3937
+ pendingRowsCount: 0,
3938
+ failedRowsCount: failedCount,
3939
+ startedAt:
3940
+ this.checkpoint.mapFrames?.[mapScope.mapInvocationId]?.startedAt ??
3941
+ Date.now(),
3942
+ updatedAt: Date.now(),
3943
+ });
3944
+ this.emitExecutionEvent({
3945
+ type: 'map.completed',
3946
+ mapInvocationId: mapScope.mapInvocationId,
3947
+ mapNodeId: mapScope.mapNodeId ?? null,
3948
+ logicalNamespace: mapScope.logicalNamespace,
3949
+ artifactTableNamespace: resolvedTableNamespace,
3950
+ completedRows: Math.max(0, processedCount - failedCount),
3951
+ failedRows: failedCount,
3952
+ totalRows: totalInputCount,
3953
+ at: Date.now(),
3954
+ });
3955
+
3956
+ const readRuntimeBackedMapRows = async (input: {
3957
+ limit: number;
3958
+ offset: number;
3959
+ }) => {
3960
+ const result = await readRuntimeSheetDatasetRows(
3961
+ {
3962
+ baseUrl: this.#options.baseUrl!,
3963
+ executorToken: this.#options.executorToken!,
3964
+ playName: this.#options.playName!,
3965
+ userEmail: this.#options.userEmail,
3966
+ runId: this.#options.runId!,
3967
+ },
3968
+ {
3969
+ tableNamespace: resolvedTableNamespace,
3970
+ runId: this.#options.runId!,
3971
+ limit: input.limit,
3972
+ offset: input.offset,
3973
+ },
3974
+ );
3975
+ return result.rows.map((row) => this.toMaterializedOutputRow(row));
3976
+ };
3977
+ const materializeRuntimeBackedMapRows = async (limit?: number) => {
3978
+ if (limit !== undefined && limit <= 0) return [];
3979
+ const pageSize = 1000;
3980
+ const materialized: Record<string, unknown>[] = [];
3981
+ let offset = 0;
3982
+ while (true) {
3983
+ const remaining =
3984
+ limit === undefined
3985
+ ? pageSize
3986
+ : Math.max(0, limit - materialized.length);
3987
+ if (remaining === 0) break;
3988
+ const rows = await readRuntimeBackedMapRows({
3989
+ limit: Math.min(pageSize, remaining),
3990
+ offset,
3991
+ });
3992
+ if (rows.length === 0) break;
3993
+ materialized.push(...rows);
3994
+ if (limit !== undefined && materialized.length >= limit) {
3995
+ return materialized.slice(0, limit);
3996
+ }
3997
+ offset += rows.length;
3998
+ }
3999
+ return materialized;
4000
+ };
4001
+
4002
+ return createDeferredPlayDataset({
4003
+ datasetKind: 'map',
4004
+ datasetId: createRuntimeDatasetId(
4005
+ this.#options.playName ?? this.#options.playId ?? 'play',
4006
+ resolvedTableNamespace,
4007
+ ),
4008
+ count: successfulCount,
4009
+ backing: {
4010
+ storage: 'neon_sheet',
4011
+ sheet: {
4012
+ playName: this.#options.playName!,
4013
+ tableNamespace: resolvedTableNamespace,
4014
+ },
4015
+ },
4016
+ previewRows,
4017
+ tableNamespace: resolvedTableNamespace,
4018
+ workProgress: {
4019
+ total: totalInputCount,
4020
+ executed: executedCount,
4021
+ reused: duplicateReuseCount,
4022
+ skipped: duplicateReuseCount,
4023
+ pending: 0,
4024
+ failed: failedCount,
4025
+ ...(duplicateReuseCount > 0
4026
+ ? { duplicates: { exact: duplicateReuseCount } }
4027
+ : {}),
4028
+ },
4029
+ resolvers: {
4030
+ count: async () => successfulCount,
4031
+ peek: async (limit) =>
4032
+ limit <= 0
4033
+ ? []
4034
+ : await readRuntimeBackedMapRows({
4035
+ limit,
4036
+ offset: 0,
4037
+ }),
4038
+ materialize: materializeRuntimeBackedMapRows,
4039
+ iterate: () =>
4040
+ ({
4041
+ async *[Symbol.asyncIterator]() {
4042
+ const pageSize = 1000;
4043
+ let offset = 0;
4044
+ while (true) {
4045
+ const rows = await readRuntimeBackedMapRows({
4046
+ limit: pageSize,
4047
+ offset,
4048
+ });
4049
+ if (rows.length === 0) return;
4050
+ for (const row of rows) {
4051
+ yield row;
4052
+ }
4053
+ offset += rows.length;
4054
+ }
4055
+ },
4056
+ }) as AsyncIterable<Record<string, unknown>>,
4057
+ },
4058
+ });
4059
+ }
4060
+ const rawMaterializedItems = await materializePlayDatasetInput(items, {
4061
+ onRow: (row) => {
4062
+ materializationMemoryTracker.track(row);
4063
+ },
4064
+ });
3147
4065
  // Silent dedupe of duplicate explicit map keys: keep the first row per
3148
4066
  // canonical key and drop subsequent duplicates. Deduping the materialized
3149
4067
  // input here keeps every downstream derivation (rawItems, itemsToProcess,
@@ -3176,19 +4094,32 @@ export class PlayContextImpl {
3176
4094
  itemOriginalIndexes = materializedItems.map((_item, index) => index);
3177
4095
  const mapStartSeedRowsByKey = new Map<string, Record<string, unknown>>();
3178
4096
  if (this.#options.onMapStart) {
3179
- const shouldPassRowKey = explicitKeyResolver != null;
4097
+ // ALWAYS stamp the canonical seed row key — including no-explicit-key
4098
+ // datasets — using seedRowIdentity (full-input-row hash, matching the
4099
+ // server's own derivation). Without the stamp, seed and completion keys
4100
+ // diverge whenever an input column shares a name with an output column
4101
+ // (e.g. a CSV `domain` column + withColumn('domain', ...)): the seed
4102
+ // row persists under the full-row key while completion/visibility used
4103
+ // a stripped-row key, so the input-index repair flipped the seed row
4104
+ // terminal under the OLD key and the visibility barrier polled a key
4105
+ // that never existed — "Runtime sheet visibility mismatch ... saw 0;
4106
+ // write reported 1" (28-packaged-literal-csv on the Daytona runner).
4107
+ // Stamping a STRIPPED-row key instead would collapse distinct input
4108
+ // rows differing only in output-named columns into one sheet row
4109
+ // (resultView expected 2, got 1). Full-input identity keeps them
4110
+ // distinct; the carried __deeplineRowKey keeps identity stable across
4111
+ // the execution round-trip.
4112
+ //
3180
4113
  // toSerializableCsvAliasedRow (not a plain spread): projected CSV
3181
4114
  // aliases ride on non-enumerable props after stripCsvProjectionMetadata,
3182
4115
  // so a spread would silently drop them before the sheet write and the
3183
4116
  // aliases would never persist as visible input cells.
3184
- const mapStartRows = shouldPassRowKey
3185
- ? rawItems.map((row, index) => ({
3186
- ...toSerializableCsvAliasedRow(row),
3187
- ...mapRowOutcomeRuntimeFields({
3188
- key: rowIdentity(row, index),
3189
- }),
3190
- }))
3191
- : rawItems.map((row) => toSerializableCsvAliasedRow(row));
4117
+ const mapStartRows = rawItems.map((row, index) => ({
4118
+ ...toSerializableCsvAliasedRow(row),
4119
+ ...mapRowOutcomeRuntimeFields({
4120
+ key: seedRowIdentity(row, index),
4121
+ }),
4122
+ }));
3192
4123
  const mapStartResult = await this.#options.onMapStart(
3193
4124
  mapStartRows,
3194
4125
  resolvedTableNamespace,
@@ -3233,15 +4164,31 @@ export class PlayContextImpl {
3233
4164
  index,
3234
4165
  }))
3235
4166
  .map(({ row, index }) => {
3236
- const rowKey = rowIdentity(row, index);
4167
+ // Look up by the SAME seed identity the stamp above used — the
4168
+ // merged persisted row then carries __deeplineRowKey, which every
4169
+ // downstream rowIdentity call prefers over re-derivation.
4170
+ const rowKey = seedRowIdentity(row, index);
3237
4171
  const persisted =
3238
4172
  pendingRowsByKey.get(rowKey) ?? mapStartSeedRowsByKey.get(rowKey);
3239
- // Persisted rows lose the non-enumerable CSV alias projections on
3240
- // the sheet round-trip. Merge the persisted data/meta over the
3241
- // in-memory row so key functions and column resolvers keep seeing
3242
- // projected aliases.
4173
+ // Restore persisted OUTPUT columns, cell meta, and the carried
4174
+ // __deeplineRowKey so key functions and column resolvers keep seeing
4175
+ // projected values/aliases across the sheet round-trip. But the
4176
+ // CURRENT run's INPUT columns are authoritative and must win: a
4177
+ // persisted row is matched only by the map key, so for a play whose
4178
+ // key does not capture every input column it can be a DIFFERENT run's
4179
+ // row that merely shares that key. Letting its stale input columns
4180
+ // overwrite this run's inputs corrupts input-derived tool arguments
4181
+ // (e.g. scenario 87 vs 78 collide on key `id`='target' but pass
4182
+ // distinct `key` columns to the probe tool, flipping a terminal
4183
+ // failure into a spurious success). Merge only persisted fields
4184
+ // absent from the fresh input row.
3243
4185
  return {
3244
- row: persisted ? cloneCsvAliasedRow(row, persisted) : row,
4186
+ row: persisted
4187
+ ? cloneCsvAliasedRow(
4188
+ row,
4189
+ persistedFieldsAbsentFromInputRow(row, persisted),
4190
+ )
4191
+ : row,
3245
4192
  index,
3246
4193
  };
3247
4194
  });
@@ -3298,6 +4245,8 @@ export class PlayContextImpl {
3298
4245
  ([rowKey, entry]) => ({ rowKey, ...entry }),
3299
4246
  );
3300
4247
  const rowsToExecute = rowsToExecuteEntries.map((entry) => entry.row);
4248
+ const rowsToExecuteMemoryEstimate =
4249
+ estimateRuntimeMapRowsMemory(rowsToExecute);
3301
4250
  const duplicateReuseCount = Math.max(
3302
4251
  0,
3303
4252
  itemsToProcess.length - rowsToExecute.length,
@@ -3308,23 +4257,44 @@ export class PlayContextImpl {
3308
4257
  if (!this.#options.onMapRowsCompleted || rows.length === 0) {
3309
4258
  return;
3310
4259
  }
4260
+ for (const row of rows) {
4261
+ assertPersistableMapRowWithinCustomerOutputLimits({
4262
+ mapName: normalizedMapNamespace,
4263
+ row,
4264
+ });
4265
+ }
3311
4266
  let chunk: PersistableMapRow[] = [];
3312
4267
  let chunkBytes = 0;
3313
4268
  const flushChunk = async () => {
3314
4269
  if (chunk.length === 0) return;
3315
4270
  try {
3316
- await this.#options.onMapRowsCompleted!({
3317
- playName: this.#options.playName,
3318
- playId: this.#options.playId,
3319
- runId: this.#options.runId,
3320
- executorToken: this.#options.executorToken,
3321
- tableNamespace: resolvedTableNamespace,
3322
- rows: chunk,
3323
- outputFields: datasetColumnNames.filter((field) =>
3324
- shouldPersistMapCellField(field),
3325
- ),
3326
- staticPipeline: this.#options.staticPipeline ?? null,
3327
- });
4271
+ const sheetFlushLease = await this.resourceGovernor.acquireSheetFlush(
4272
+ {
4273
+ estimatedBytes: chunkBytes,
4274
+ rowCount: chunk.length,
4275
+ },
4276
+ );
4277
+ const flushStartedAt = Date.now();
4278
+ try {
4279
+ await this.#options.onMapRowsCompleted!({
4280
+ playName: this.#options.playName,
4281
+ playId: this.#options.playId,
4282
+ runId: this.#options.runId,
4283
+ executorToken: this.#options.executorToken,
4284
+ tableNamespace: resolvedTableNamespace,
4285
+ rows: chunk,
4286
+ outputFields: datasetColumnNames.filter((field) =>
4287
+ shouldPersistMapCellField(field),
4288
+ ),
4289
+ staticPipeline: this.#options.staticPipeline ?? null,
4290
+ });
4291
+ this.resourceGovernor.observe({
4292
+ sheetFlushBytes: chunkBytes,
4293
+ sheetFlushLatencyMs: Date.now() - flushStartedAt,
4294
+ });
4295
+ } finally {
4296
+ sheetFlushLease.release();
4297
+ }
3328
4298
  } catch (error) {
3329
4299
  // Output-sheet flush failed: trip the breaker so the dispatch loops
3330
4300
  // stop dispatching new provider calls for the rest of this map.
@@ -3368,6 +4338,10 @@ export class PlayContextImpl {
3368
4338
  executionRowIndexes: rowsToExecuteEntries.map(
3369
4339
  (entry) => entry.originalIndex,
3370
4340
  ),
4341
+ largestRowBytes: rowsToExecuteMemoryEstimate.largestRowBytes,
4342
+ retainedRowsMemoryBudgetBytes:
4343
+ mapMemoryLimits.materializedBudgetBytes,
4344
+ activeRowsMemoryBudgetBytes: mapMemoryLimits.activeRowsBudgetBytes,
3371
4345
  incrementalPersistence,
3372
4346
  },
3373
4347
  );
@@ -3499,6 +4473,63 @@ export class PlayContextImpl {
3499
4473
  });
3500
4474
  }
3501
4475
 
4476
+ const readRuntimeBackedMapRows =
4477
+ this.#options.runtimeSheetBackedMapDatasets === true &&
4478
+ this.#options.baseUrl &&
4479
+ this.#options.executorToken &&
4480
+ this.#options.playName &&
4481
+ this.#options.runId
4482
+ ? async (input: { limit: number; offset: number }) => {
4483
+ const result = await readRuntimeSheetDatasetRows(
4484
+ {
4485
+ baseUrl: this.#options.baseUrl!,
4486
+ executorToken: this.#options.executorToken!,
4487
+ playName: this.#options.playName!,
4488
+ userEmail: this.#options.userEmail,
4489
+ runId: this.#options.runId!,
4490
+ },
4491
+ {
4492
+ tableNamespace: resolvedTableNamespace,
4493
+ runId: this.#options.runId!,
4494
+ limit: input.limit,
4495
+ offset: input.offset,
4496
+ },
4497
+ );
4498
+ return result.rows.map((row) => this.toMaterializedOutputRow(row));
4499
+ }
4500
+ : null;
4501
+ const materializeRuntimeBackedMapRows = async (limit?: number) => {
4502
+ if (!readRuntimeBackedMapRows) {
4503
+ return limit === undefined
4504
+ ? results.map((row) => this.toMaterializedOutputRow(row))
4505
+ : results
4506
+ .slice(0, Math.max(0, limit))
4507
+ .map((row) => this.toMaterializedOutputRow(row));
4508
+ }
4509
+ if (limit !== undefined && limit <= 0) return [];
4510
+ const pageSize = 1000;
4511
+ const materialized: Record<string, unknown>[] = [];
4512
+ let offset = 0;
4513
+ while (true) {
4514
+ const remaining =
4515
+ limit === undefined
4516
+ ? pageSize
4517
+ : Math.max(0, limit - materialized.length);
4518
+ if (remaining === 0) break;
4519
+ const rows = await readRuntimeBackedMapRows({
4520
+ limit: Math.min(pageSize, remaining),
4521
+ offset,
4522
+ });
4523
+ if (rows.length === 0) break;
4524
+ materialized.push(...rows);
4525
+ if (limit !== undefined && materialized.length >= limit) {
4526
+ return materialized.slice(0, limit);
4527
+ }
4528
+ offset += rows.length;
4529
+ }
4530
+ return materialized;
4531
+ };
4532
+
3502
4533
  return createDeferredPlayDataset({
3503
4534
  datasetKind: 'map',
3504
4535
  datasetId: createRuntimeDatasetId(
@@ -3506,6 +4537,15 @@ export class PlayContextImpl {
3506
4537
  resolvedTableNamespace,
3507
4538
  ),
3508
4539
  count: results.length,
4540
+ backing: readRuntimeBackedMapRows
4541
+ ? {
4542
+ storage: 'neon_sheet',
4543
+ sheet: {
4544
+ playName: this.#options.playName!,
4545
+ tableNamespace: resolvedTableNamespace,
4546
+ },
4547
+ }
4548
+ : undefined,
3509
4549
  previewRows: results
3510
4550
  .slice(0, 5)
3511
4551
  .map((row) => this.toMaterializedOutputRow(row)),
@@ -3524,18 +4564,35 @@ export class PlayContextImpl {
3524
4564
  resolvers: {
3525
4565
  count: async () => results.length,
3526
4566
  peek: async (limit) =>
3527
- results
3528
- .slice(0, Math.max(0, limit))
3529
- .map((row) => this.toMaterializedOutputRow(row)),
3530
- materialize: async (limit) =>
3531
- limit === undefined
3532
- ? results.map((row) => this.toMaterializedOutputRow(row))
4567
+ readRuntimeBackedMapRows
4568
+ ? limit <= 0
4569
+ ? []
4570
+ : await readRuntimeBackedMapRows({
4571
+ limit,
4572
+ offset: 0,
4573
+ })
3533
4574
  : results
3534
4575
  .slice(0, Math.max(0, limit))
3535
4576
  .map((row) => this.toMaterializedOutputRow(row)),
4577
+ materialize: materializeRuntimeBackedMapRows,
3536
4578
  iterate: () =>
3537
4579
  ({
3538
4580
  async *[Symbol.asyncIterator]() {
4581
+ if (readRuntimeBackedMapRows) {
4582
+ const pageSize = 1000;
4583
+ let offset = 0;
4584
+ while (true) {
4585
+ const rows = await readRuntimeBackedMapRows({
4586
+ limit: pageSize,
4587
+ offset,
4588
+ });
4589
+ if (rows.length === 0) return;
4590
+ for (const row of rows) {
4591
+ yield row;
4592
+ }
4593
+ offset += rows.length;
4594
+ }
4595
+ }
3539
4596
  for (const row of results) {
3540
4597
  yield row;
3541
4598
  }
@@ -3561,12 +4618,16 @@ export class PlayContextImpl {
3561
4618
  executionRowIndexes?: number[];
3562
4619
  /**
3563
4620
  * Opt-in cap on how many row resolvers run their body concurrently. When
3564
- * unset (the default), all rows in the batch start together — the
3565
- * established behavior the batch executor relies on. When set, excess
3566
- * rows wait for a slot, bounding in-flight per-row work for callers who
3567
- * want to throttle a wide map.
4621
+ * unset, the Governor's rowDefault is used. Excess rows stay pending as
4622
+ * indexes until a worker is free.
3568
4623
  */
3569
4624
  concurrency?: number;
4625
+ largestRowBytes?: number;
4626
+ retainedRowsMemoryBudgetBytes?: number;
4627
+ activeRowsMemoryBudgetBytes?: number;
4628
+ progressCompletedOffset?: number;
4629
+ progressFailedOffset?: number;
4630
+ progressTotalRows?: number;
3570
4631
  incrementalPersistence?: IncrementalMapRowPersistence | null;
3571
4632
  },
3572
4633
  ): Promise<FieldMapRunResult> {
@@ -3596,7 +4657,9 @@ export class PlayContextImpl {
3596
4657
  runtimeOptions?.executionRowIndexes?.[index] ?? index;
3597
4658
 
3598
4659
  const totalRows = Math.max(
3599
- executionSummary?.totalRows ?? items.length,
4660
+ runtimeOptions?.progressTotalRows ??
4661
+ executionSummary?.totalRows ??
4662
+ items.length,
3600
4663
  items.length,
3601
4664
  );
3602
4665
  const completedRows = Math.min(
@@ -3610,6 +4673,10 @@ export class PlayContextImpl {
3610
4673
  const failedRowsToPersist: PersistableMapRow[] = [];
3611
4674
  const incrementalPersistence =
3612
4675
  runtimeOptions?.incrementalPersistence ?? null;
4676
+ const retainedRowsMemoryTracker = createRuntimeMapRetainedRowsTracker({
4677
+ mapName: normalizedTableNamespace,
4678
+ budgetBytes: runtimeOptions?.retainedRowsMemoryBudgetBytes,
4679
+ });
3613
4680
  const enqueueIncrementalPersist = (row: PersistableMapRow): void => {
3614
4681
  void incrementalPersistence?.persistRows([row]).catch(() => {
3615
4682
  // The final map-level flush awaits the same chain and surfaces the
@@ -3728,17 +4795,21 @@ export class PlayContextImpl {
3728
4795
  ? 'map.progress'
3729
4796
  : null);
3730
4797
  if (progressEventType) {
4798
+ const failedRows = Math.max(
4799
+ 0,
4800
+ (runtimeOptions?.progressFailedOffset ?? 0) +
4801
+ (items.length - completedRowKeys.size - pendingRowKeys.size),
4802
+ );
3731
4803
  this.emitExecutionEvent({
3732
4804
  type: progressEventType,
3733
4805
  mapInvocationId: mapScope.mapInvocationId,
3734
4806
  mapNodeId: mapScope.mapNodeId ?? null,
3735
4807
  logicalNamespace: mapScope.logicalNamespace,
3736
4808
  artifactTableNamespace: mapScope.artifactTableNamespace,
3737
- completedRows: completedRowKeys.size,
3738
- failedRows: Math.max(
3739
- 0,
3740
- totalRows - completedRowKeys.size - pendingRowKeys.size,
3741
- ),
4809
+ completedRows:
4810
+ (runtimeOptions?.progressCompletedOffset ?? 0) +
4811
+ completedRowKeys.size,
4812
+ failedRows,
3742
4813
  totalRows,
3743
4814
  at: Date.now(),
3744
4815
  } as PlayExecutionEvent);
@@ -3753,6 +4824,22 @@ export class PlayContextImpl {
3753
4824
  normalizedTableNamespace,
3754
4825
  executionRowKey,
3755
4826
  );
4827
+ const pureResultsMemoryEstimate = estimateRuntimeMapRowsMemory(results);
4828
+ const retainedRowsBudgetBytes =
4829
+ runtimeOptions?.retainedRowsMemoryBudgetBytes ??
4830
+ resolveRuntimeMapMemoryLimits(this.#options.runtimeMapMemoryLimits)
4831
+ .materializedBudgetBytes;
4832
+ if (
4833
+ pureResultsMemoryEstimate.estimatedResidentBytes >
4834
+ retainedRowsBudgetBytes
4835
+ ) {
4836
+ throw new RuntimeMapMemoryLimitError({
4837
+ mapName: normalizedTableNamespace,
4838
+ phase: 'retained_rows',
4839
+ estimate: pureResultsMemoryEstimate,
4840
+ budgetBytes: retainedRowsBudgetBytes,
4841
+ });
4842
+ }
3756
4843
  // One batched frame update for the whole pure map. The per-row loop
3757
4844
  // this replaces ran AFTER every row had already computed, emitting 150k
3758
4845
  // post-hoc map.progress events and re-copying the completed-keys array
@@ -3794,46 +4881,64 @@ export class PlayContextImpl {
3794
4881
 
3795
4882
  this.initializeRowStates(items);
3796
4883
 
3797
- // Bound on how many row resolver bodies run at once. An explicit
4884
+ // Bound on how many row resolver bodies exist at once. An explicit
3798
4885
  // `concurrency` is clamped to [1, policy.concurrency.rowMax]; unset uses
3799
- // policy.concurrency.rowDefault (resolved by the Governor). The promises array is still created up
3800
- // front so drainQueuedWork is unaffected only body execution is gated, so
3801
- // excess rows wait for a slot rather than allocating unbounded in-flight
3802
- // work. (Pure-JS maps use a separate fast path and never reach here.)
3803
- const rowConcurrencyLimit = this.governor.resolveRowConcurrency(
4886
+ // policy.concurrency.rowDefault (resolved by the Governor). Pending rows are
4887
+ // indexes, not promises, so they do not pre-claim receipts, queue tools, or
4888
+ // retain row closures before admission. Pure-JS maps use a separate fast
4889
+ // path and never reach here.
4890
+ const requestedRowConcurrency = this.resourceGovernor.resolveRowConcurrency(
3804
4891
  runtimeOptions?.concurrency,
3805
4892
  );
3806
- // Local per-map slot enforces THIS map's `concurrency`. It is wrapped by the
3807
- // Governor's GLOBAL row slot (acquired first, below) so N concurrent ctx.map
3808
- // calls cannot exceed policy.concurrency.rowMax total in-flight rows — the
3809
- // same global backstop the esm_workers path applies per row. Without the
3810
- // global slot, sum(perMapLimit) rows could run with no run-wide ceiling.
3811
- const rowSlotWaiters: Array<() => void> = [];
3812
- let activeRowResolvers = 0;
3813
- const acquireLocalRowSlot = async (): Promise<void> => {
3814
- while (activeRowResolvers >= rowConcurrencyLimit) {
3815
- await new Promise<void>((resolve) => {
3816
- rowSlotWaiters.push(resolve);
3817
- });
3818
- }
3819
- activeRowResolvers += 1;
3820
- };
3821
- const releaseLocalRowSlot = (): void => {
3822
- activeRowResolvers = Math.max(0, activeRowResolvers - 1);
3823
- rowSlotWaiters.shift()?.();
3824
- };
4893
+ const largestRowBytes =
4894
+ runtimeOptions?.largestRowBytes ??
4895
+ estimateRuntimeMapRowsMemory(items).largestRowBytes;
4896
+ const rowAdmission = resolveRuntimeMapRowAdmission({
4897
+ rowCount: items.length,
4898
+ requestedConcurrency: requestedRowConcurrency,
4899
+ largestRowBytes,
4900
+ activeRowsBudgetBytes: runtimeOptions?.activeRowsMemoryBudgetBytes,
4901
+ });
4902
+ const rowConcurrencyLimit = rowAdmission.concurrency;
4903
+ if (items.length > 0 && rowConcurrencyLimit === 0) {
4904
+ throw new RuntimeMapMemoryLimitError({
4905
+ mapName: normalizedTableNamespace,
4906
+ phase: 'active_rows',
4907
+ estimate: {
4908
+ rowCount: items.length,
4909
+ serializedBytes: largestRowBytes,
4910
+ largestRowBytes,
4911
+ estimatedResidentBytes: rowAdmission.estimatedBytesPerActiveRow,
4912
+ },
4913
+ budgetBytes:
4914
+ runtimeOptions?.activeRowsMemoryBudgetBytes ??
4915
+ resolveRuntimeMapMemoryLimits().activeRowsBudgetBytes,
4916
+ activeRows: 1,
4917
+ rowConcurrency: requestedRowConcurrency,
4918
+ });
4919
+ }
4920
+ this.resourceGovernor.observe({
4921
+ memoryBytes: rowAdmission.estimatedResidentBytes,
4922
+ });
4923
+ const settledResults = new Array<MapRowExecutionResult | undefined>(
4924
+ items.length,
4925
+ );
4926
+ let nextRowIndex = 0;
4927
+ let firstRowError: unknown = null;
3825
4928
 
3826
- const promises = items.map(async (item, idx) => {
4929
+ const runRow = async (idx: number): Promise<MapRowExecutionResult> => {
4930
+ const item = items[idx];
3827
4931
  const baseRow = this.toOutputRow(item);
3828
4932
  const rowKey = executionRowKey(baseRow, idx);
3829
4933
  const rowIndex = executionRowIndex(idx);
3830
4934
  const computedFields: Record<string, unknown> = {};
3831
4935
  let activeFieldName: string | null = null;
3832
4936
 
3833
- // Global row slot first (run-wide rowMax backstop), then the local per-map
3834
- // slot (this map's concurrency). Released in reverse in the finally below.
3835
- const globalRowSlot = await this.governor.acquireRowSlot();
3836
- await acquireLocalRowSlot();
4937
+ // Global row slot keeps concurrent maps in the same run under rowMax. The
4938
+ // worker pool above enforces this map's requested/default concurrency.
4939
+ const globalRowSlot = await this.resourceGovernor.acquireRow({
4940
+ estimatedBytes: runtimeMapJsonByteLength(baseRow),
4941
+ });
3837
4942
  try {
3838
4943
  for (const [fieldName, resolver] of fieldEntries) {
3839
4944
  activeFieldName = fieldName;
@@ -3910,6 +5015,7 @@ export class PlayContextImpl {
3910
5015
  : {}),
3911
5016
  error: formattedError,
3912
5017
  });
5018
+ retainedRowsMemoryTracker.track(failedRow);
3913
5019
  failedRowsToPersist.push(failedRow);
3914
5020
  throw error;
3915
5021
  }
@@ -3924,6 +5030,7 @@ export class PlayContextImpl {
3924
5030
  ...(cellMetaPatch ? { cellMetaPatch } : {}),
3925
5031
  error: formattedError,
3926
5032
  });
5033
+ retainedRowsMemoryTracker.track(failedRow);
3927
5034
  failedRowsToPersist.push(failedRow);
3928
5035
  updateMapFrameProgress({
3929
5036
  failedRowKey: rowKey,
@@ -3942,6 +5049,31 @@ export class PlayContextImpl {
3942
5049
  const cellValue = this.serializeCellValue(value);
3943
5050
  computedFields[fieldName] = cellValue;
3944
5051
  this.rowStates.get(idx)?.results.set(fieldName, cellValue);
5052
+ const currentCellMeta =
5053
+ this.activeMapCellMeta?.get(rowKey)?.[fieldName];
5054
+ const currentCellStatus =
5055
+ currentCellMeta &&
5056
+ typeof currentCellMeta === 'object' &&
5057
+ !Array.isArray(currentCellMeta) &&
5058
+ 'status' in currentCellMeta
5059
+ ? (currentCellMeta as { status?: unknown }).status
5060
+ : null;
5061
+ if (currentCellStatus === 'skipped') {
5062
+ if (
5063
+ shouldPersistMapCellField(fieldName) &&
5064
+ Object.prototype.hasOwnProperty.call(computedFields, fieldName)
5065
+ ) {
5066
+ this.emitScopedRowUpdate(rowKey, normalizedTableNamespace, {
5067
+ rowId: idx,
5068
+ status: 'running',
5069
+ stage: 'skipped',
5070
+ provider: null,
5071
+ error: null,
5072
+ dataPatch: { [fieldName]: cellValue },
5073
+ });
5074
+ }
5075
+ continue;
5076
+ }
3945
5077
  this.emitScopedFieldMetaUpdate({
3946
5078
  rowId: idx,
3947
5079
  key: rowKey,
@@ -3978,6 +5110,7 @@ export class PlayContextImpl {
3978
5110
  ? { cellMetaPatch: this.activeMapCellMeta.get(rowKey) }
3979
5111
  : {}),
3980
5112
  });
5113
+ retainedRowsMemoryTracker.track(completedRow);
3981
5114
  completedRowsToPersist.push(completedRow);
3982
5115
  enqueueIncrementalPersist(completedRow);
3983
5116
  return publicRow;
@@ -4023,16 +5156,30 @@ export class PlayContextImpl {
4023
5156
  });
4024
5157
  throw error;
4025
5158
  } finally {
4026
- releaseLocalRowSlot();
4027
5159
  globalRowSlot.release();
4028
5160
  }
5161
+ };
5162
+
5163
+ const workerCount = Math.min(rowConcurrencyLimit, items.length);
5164
+ const workers = Array.from({ length: workerCount }, async () => {
5165
+ while (firstRowError == null) {
5166
+ const idx = nextRowIndex;
5167
+ nextRowIndex += 1;
5168
+ if (idx >= items.length) return;
5169
+ try {
5170
+ settledResults[idx] = await runRow(idx);
5171
+ } catch (error) {
5172
+ firstRowError = error;
5173
+ throw error;
5174
+ }
5175
+ }
4029
5176
  });
4030
5177
 
4031
- await this.drainQueuedWork(promises);
5178
+ await this.drainQueuedWork(workers);
4032
5179
  this.lastDatasetStep = datasetStep;
4033
5180
  this.activeDatasetStep = null;
4034
5181
 
4035
- const settled = await Promise.allSettled(promises);
5182
+ const settled = await Promise.allSettled(workers);
4036
5183
  const rejected = settled.find(
4037
5184
  (result): result is PromiseRejectedResult => result.status === 'rejected',
4038
5185
  );
@@ -4043,12 +5190,15 @@ export class PlayContextImpl {
4043
5190
  failedRows: failedRowsToPersist,
4044
5191
  });
4045
5192
  }
4046
- const settledResults = settled.map((result) => {
4047
- if (result.status === 'rejected') {
4048
- throw result.reason;
4049
- }
4050
- return result.value;
4051
- });
5193
+ const missingResultIndex = settledResults.findIndex(
5194
+ (result) => result === undefined,
5195
+ );
5196
+ if (missingResultIndex !== -1) {
5197
+ throw new Error(
5198
+ `Runtime map execution worker pool ended before row ${missingResultIndex} settled for ctx.dataset("${normalizedTableNamespace}").`,
5199
+ );
5200
+ }
5201
+ const rowResults = settledResults as MapRowExecutionResult[];
4052
5202
  const accountedRowIndexes = new Set<number>();
4053
5203
  for (const row of completedRowsToPersist) {
4054
5204
  if (typeof row.inputIndex === 'number') {
@@ -4062,8 +5212,8 @@ export class PlayContextImpl {
4062
5212
  }
4063
5213
  if (this.pendingRowEventBoundaries.length > 0) {
4064
5214
  const completedRowKeys = new Set<string>();
4065
- for (let index = 0; index < settledResults.length; index += 1) {
4066
- const result = settledResults[index];
5215
+ for (let index = 0; index < rowResults.length; index += 1) {
5216
+ const result = rowResults[index];
4067
5217
  const rawItem = this.toOutputRow(
4068
5218
  items[index] as Record<string, unknown>,
4069
5219
  );
@@ -4129,7 +5279,7 @@ export class PlayContextImpl {
4129
5279
  `missing input index(es): ${missingRowIndexes.slice(0, 10).join(', ') || 'unknown'}.`,
4130
5280
  );
4131
5281
  }
4132
- const results = settledResults.filter(
5282
+ const results = rowResults.filter(
4133
5283
  (result): result is Record<string, unknown> =>
4134
5284
  result !== WAITING_ROW && result !== FAILED_ROW,
4135
5285
  );
@@ -4232,6 +5382,25 @@ export class PlayContextImpl {
4232
5382
  undefined,
4233
5383
  );
4234
5384
  produced[step.name] = value;
5385
+ const rowStore = rowContext.getStore();
5386
+ const fieldName = stepPath.join('.');
5387
+ const patchFieldName = runtimeSheetPatchFieldName(fieldName);
5388
+ if (rowStore && shouldPersistMapCellField(patchFieldName)) {
5389
+ const cellValue = this.serializeCellValue(value);
5390
+ this.emitScopedFieldMetaUpdate({
5391
+ rowId: rowStore.rowId,
5392
+ key: rowStore.rowKey ?? null,
5393
+ tableNamespace: rowStore.tableNamespace ?? null,
5394
+ fieldName,
5395
+ status: 'completed',
5396
+ rowStatus: 'running',
5397
+ stage: 'completed',
5398
+ provider: null,
5399
+ error: null,
5400
+ completedAt: Date.now(),
5401
+ dataPatch: { [patchFieldName]: cellValue },
5402
+ });
5403
+ }
4235
5404
  currentRow = cloneCsvAliasedRow(currentRow, { [step.name]: value });
4236
5405
  }
4237
5406
  if (typeof program.returnResolver === 'function') {
@@ -4626,6 +5795,7 @@ export class PlayContextImpl {
4626
5795
  ? `Calling tool: ${toolId} (force)`
4627
5796
  : `Calling tool: ${toolId}`,
4628
5797
  );
5798
+ const directReceiptLeaseId = context?.leaseId?.trim() || null;
4629
5799
  const execution = await this.callToolExecutionAPI(toolId, input, {
4630
5800
  ...(cacheableToolResult
4631
5801
  ? {
@@ -4634,11 +5804,43 @@ export class PlayContextImpl {
4634
5804
  providerIdempotencyKey: this.providerIdempotencyKeyForToolCall({
4635
5805
  cacheKey: directCacheKey,
4636
5806
  force: toolCachePolicy.force,
4637
- leaseId: context?.leaseId ?? null,
5807
+ leaseId: directReceiptLeaseId,
4638
5808
  }),
4639
5809
  }
4640
5810
  : {}),
4641
5811
  timeoutMs: resolveToolRuntimeTimeoutMs(toolId, options?.timeoutMs),
5812
+ ...(directReceiptLeaseId &&
5813
+ (this.#options.markRuntimeStepReceiptRunning ||
5814
+ this.#options.markRuntimeStepReceiptsRunning)
5815
+ ? {
5816
+ beforeProviderCall: async () => {
5817
+ await this.assertRuntimeToolReceiptOwnership([
5818
+ {
5819
+ callId: 'direct',
5820
+ cacheKey: directCacheKey,
5821
+ receiptKey: directCacheKey,
5822
+ receiptLeaseId: directReceiptLeaseId,
5823
+ rowId: -1,
5824
+ toolId,
5825
+ input,
5826
+ },
5827
+ ]);
5828
+ },
5829
+ ...(this.#options.markRuntimeStepReceiptsQueued
5830
+ ? {
5831
+ parkProviderCall: async () => {
5832
+ await this.markRuntimeStepReceiptsQueued([
5833
+ {
5834
+ key: directCacheKey,
5835
+ runId: this.currentRunId,
5836
+ leaseId: directReceiptLeaseId,
5837
+ },
5838
+ ]);
5839
+ },
5840
+ }
5841
+ : {}),
5842
+ }
5843
+ : {}),
4642
5844
  });
4643
5845
  const wrapped = await this.wrapToolExecutionResult({
4644
5846
  toolId,
@@ -4788,6 +5990,7 @@ export class PlayContextImpl {
4788
5990
  ),
4789
5991
  shouldPersistFailure: (error) =>
4790
5992
  !(error instanceof ToolExecuteAuthScopeChangedError),
5993
+ markRunningBeforeExecute: false,
4791
5994
  execute: ({ leaseId }) => executeTool({ leaseId }),
4792
5995
  runningReceiptWaitMaxAttempts:
4793
5996
  resolveRuntimeToolReceiptWaitMaxAttempts(
@@ -5262,12 +6465,7 @@ export class PlayContextImpl {
5262
6465
  headers: {
5263
6466
  Authorization: `Bearer ${this.#options.executorToken}`,
5264
6467
  'Content-Type': 'application/json',
5265
- ...(this.#options.vercelProtectionBypassToken?.trim()
5266
- ? {
5267
- 'x-vercel-protection-bypass':
5268
- this.#options.vercelProtectionBypassToken.trim(),
5269
- }
5270
- : {}),
6468
+ ...(await this.vercelProtectionHeaders()),
5271
6469
  },
5272
6470
  body: JSON.stringify({
5273
6471
  parentRunId: input.parentRunId,
@@ -5469,9 +6667,11 @@ export class PlayContextImpl {
5469
6667
  // ctx.fetch is arbitrary customer-directed egress. Pace it through
5470
6668
  // the same generic_http_request lane as the explicit Generic HTTP
5471
6669
  // integration tool so switching syntax cannot bypass per-org limits.
5472
- const egressSlot = await this.governor.acquireToolSlot(
5473
- CTX_FETCH_EGRESS_TOOL_ID,
5474
- );
6670
+ const egressSlot = await this.resourceGovernor.acquireTool({
6671
+ orgId: this.#options.orgId ?? null,
6672
+ providerResourceKey: CTX_FETCH_EGRESS_TOOL_ID,
6673
+ toolId: CTX_FETCH_EGRESS_TOOL_ID,
6674
+ });
5475
6675
  let response: Response | null = null;
5476
6676
  try {
5477
6677
  const canRetryTransport = ['GET', 'HEAD', 'OPTIONS'].includes(
@@ -5781,11 +6981,12 @@ export class PlayContextImpl {
5781
6981
  ): Promise<void> => {
5782
6982
  const followers = liveFollowersByOwnerCallId.get(owner.callId) ?? [];
5783
6983
  liveFollowersByOwnerCallId.delete(owner.callId);
6984
+ const shouldPersistReceiptFailure =
6985
+ !(error instanceof ToolExecuteAuthScopeChangedError) &&
6986
+ !this.persistenceLatch.tripped;
5784
6987
  for (const request of [owner, ...followers]) {
5785
6988
  await this.rejectToolCall(toolId, request, error, {
5786
- persistReceiptFailure: !(
5787
- error instanceof ToolExecuteAuthScopeChangedError
5788
- ),
6989
+ persistReceiptFailure: shouldPersistReceiptFailure,
5789
6990
  });
5790
6991
  }
5791
6992
  };
@@ -5833,7 +7034,13 @@ export class PlayContextImpl {
5833
7034
  return timeoutMs > 0 ? timeoutMs : undefined;
5834
7035
  };
5835
7036
 
5836
- const durableExistingRunningHandlers: Promise<void>[] = [];
7037
+ const durableExistingRunningWaits: Array<{
7038
+ receiptKey: string;
7039
+ requestsForKey: ToolCallRequest[];
7040
+ forceAfterWait: boolean;
7041
+ waitMaxAttempts?: number;
7042
+ }> = [];
7043
+ let durableExistingRunningHandler: Promise<void> | null = null;
5837
7044
  if (
5838
7045
  pendingRequests.length > 0 &&
5839
7046
  (this.#options.claimRuntimeStepReceipts ||
@@ -5882,7 +7089,12 @@ export class PlayContextImpl {
5882
7089
  : new Map<string, RuntimeStepReceipt>();
5883
7090
  const forcedRunningReceiptKeys = new Set(
5884
7091
  [...forcedExisting]
5885
- .filter(([, receipt]) => receipt.status === 'running')
7092
+ .filter(
7093
+ ([, receipt]) =>
7094
+ receipt.status === 'queued' ||
7095
+ receipt.status === 'pending' ||
7096
+ receipt.status === 'running',
7097
+ )
5886
7098
  .map(([receiptKey]) => receiptKey),
5887
7099
  );
5888
7100
  const claimableForcedReceiptKeys = forcedReceiptKeys.filter(
@@ -5982,44 +7194,75 @@ export class PlayContextImpl {
5982
7194
  liveFollowersByOwnerCallId.set(owner.callId, followers);
5983
7195
  }
5984
7196
  };
7197
+ const resolveReceiptWaitMaxAttempts = (
7198
+ requestsForKey: ToolCallRequest[],
7199
+ ): number | undefined =>
7200
+ requestsForKey.length > 0
7201
+ ? Math.max(
7202
+ ...requestsForKey.map((request) =>
7203
+ resolveRuntimeToolReceiptWaitMaxAttempts(
7204
+ typeof request.receiptWaitMs === 'number'
7205
+ ? { max_wait_ms: request.receiptWaitMs }
7206
+ : request.input,
7207
+ ),
7208
+ ),
7209
+ )
7210
+ : undefined;
7211
+ const queueExistingRunningReceiptWait = (
7212
+ receiptKey: string,
7213
+ requestsForKey: ToolCallRequest[],
7214
+ forceAfterWait = false,
7215
+ ): void => {
7216
+ durableExistingRunningWaits.push({
7217
+ receiptKey,
7218
+ requestsForKey,
7219
+ forceAfterWait,
7220
+ waitMaxAttempts: resolveReceiptWaitMaxAttempts(requestsForKey),
7221
+ });
7222
+ };
5985
7223
  const reclaimExistingRunningReceiptAfterWait = async (
5986
7224
  receiptKey: string,
5987
7225
  requestsForKey: ToolCallRequest[],
5988
7226
  forceAfterWait = false,
7227
+ waitResult?: {
7228
+ completedReceipt?: RuntimeStepReceipt;
7229
+ skipWait?: boolean;
7230
+ },
5989
7231
  ): Promise<void> => {
5990
- const waitMaxAttempts =
5991
- requestsForKey.length > 0
5992
- ? Math.max(
5993
- ...requestsForKey.map((request) =>
5994
- resolveRuntimeToolReceiptWaitMaxAttempts(
5995
- typeof request.receiptWaitMs === 'number'
5996
- ? { max_wait_ms: request.receiptWaitMs }
5997
- : request.input,
5998
- ),
5999
- ),
6000
- )
6001
- : undefined;
6002
- try {
6003
- const completed = await this.waitForCompletedRuntimeToolReceipt(
6004
- receiptKey,
6005
- waitMaxAttempts,
6006
- );
7232
+ if (waitResult?.completedReceipt) {
6007
7233
  if (!forceAfterWait) {
6008
7234
  await resolveRequestsFromReceipt(
6009
7235
  requestsForKey,
6010
- completed,
7236
+ waitResult.completedReceipt,
6011
7237
  'in_flight',
6012
7238
  );
6013
7239
  return;
6014
7240
  }
6015
- } catch (error) {
6016
- if (!(error instanceof RuntimeReceiptWaitTimeoutError)) {
6017
- for (const request of requestsForKey) {
6018
- await this.rejectToolCall(toolId, request, error, {
6019
- persistReceiptFailure: false,
6020
- });
7241
+ } else if (waitResult?.skipWait !== true) {
7242
+ const waitMaxAttempts =
7243
+ resolveReceiptWaitMaxAttempts(requestsForKey);
7244
+ try {
7245
+ const completed = await this.waitForCompletedRuntimeToolReceipt(
7246
+ receiptKey,
7247
+ waitMaxAttempts,
7248
+ );
7249
+ if (!forceAfterWait) {
7250
+ await resolveRequestsFromReceipt(
7251
+ requestsForKey,
7252
+ completed,
7253
+ 'in_flight',
7254
+ );
7255
+ return;
7256
+ }
7257
+ } catch (error) {
7258
+ if (!(error instanceof RuntimeReceiptWaitTimeoutError)) {
7259
+ for (const request of requestsForKey) {
7260
+ await this.rejectToolCall(toolId, request, error, {
7261
+ persistReceiptFailure: false,
7262
+ });
7263
+ }
7264
+ return;
6021
7265
  }
6022
- return;
6023
7266
  }
6024
7267
  }
6025
7268
  const reclaimed = (
@@ -6052,10 +7295,7 @@ export class PlayContextImpl {
6052
7295
  }
6053
7296
  return;
6054
7297
  }
6055
- if (
6056
- reclaimed?.status === 'running' &&
6057
- reclaimed.claimState !== 'existing'
6058
- ) {
7298
+ if (this.isOwnedClaimedRuntimeReceipt(reclaimed)) {
6059
7299
  const [owner, ...waiters] = requestsForKey;
6060
7300
  if (!owner) return;
6061
7301
  if (!reclaimed.leaseId) {
@@ -6066,17 +7306,24 @@ export class PlayContextImpl {
6066
7306
  });
6067
7307
  }
6068
7308
  owner.receiptLeaseId = reclaimed.leaseId ?? null;
7309
+ owner.receiptLeaseExpiresAt = reclaimed.leaseExpiresAt ?? null;
6069
7310
  if (waiters.length > 0) {
6070
7311
  liveFollowersByOwnerCallId.set(owner.callId, waiters);
6071
7312
  }
6072
7313
  try {
6073
- await this.assertRuntimeToolReceiptOwnership([owner]);
6074
7314
  const execution = await this.callToolExecutionAPI(
6075
7315
  toolId,
6076
7316
  owner.input,
6077
7317
  {
7318
+ beforeProviderCall: () =>
7319
+ this.assertRuntimeToolReceiptOwnership([owner]),
7320
+ parkProviderCall: () =>
7321
+ this.parkRuntimeToolReceiptsQueued([owner]),
6078
7322
  durableCallReceiptKey: receiptKey,
6079
7323
  executionAuthScopeDigest: owner.executionAuthScopeDigest,
7324
+ receiptLeaseExpiresAt: owner.receiptLeaseExpiresAt,
7325
+ heartbeatReceipt: () =>
7326
+ this.assertRuntimeToolReceiptOwnership([owner]),
6080
7327
  providerIdempotencyKey:
6081
7328
  this.providerIdempotencyKeyForToolCall({
6082
7329
  cacheKey: owner.cacheKey,
@@ -6112,17 +7359,50 @@ export class PlayContextImpl {
6112
7359
  );
6113
7360
  }
6114
7361
  };
7362
+ const processExistingRunningReceiptWaits =
7363
+ async (): Promise<void> => {
7364
+ if (durableExistingRunningWaits.length === 0) return;
7365
+ const waitMaxAttempts = Math.max(
7366
+ ...durableExistingRunningWaits.map(
7367
+ (wait) => wait.waitMaxAttempts ?? 0,
7368
+ ),
7369
+ );
7370
+ const waited = await this.waitForCompletedRuntimeToolReceipts(
7371
+ durableExistingRunningWaits.map((wait) => wait.receiptKey),
7372
+ waitMaxAttempts > 0 ? waitMaxAttempts : undefined,
7373
+ );
7374
+ await Promise.allSettled(
7375
+ durableExistingRunningWaits.map(async (wait) => {
7376
+ const failed = waited.failed.get(wait.receiptKey);
7377
+ if (failed) {
7378
+ for (const request of wait.requestsForKey) {
7379
+ await this.rejectToolCall(toolId, request, failed, {
7380
+ persistReceiptFailure: false,
7381
+ });
7382
+ }
7383
+ return;
7384
+ }
7385
+ await reclaimExistingRunningReceiptAfterWait(
7386
+ wait.receiptKey,
7387
+ wait.requestsForKey,
7388
+ wait.forceAfterWait,
7389
+ {
7390
+ completedReceipt: waited.completed.get(wait.receiptKey),
7391
+ skipWait: true,
7392
+ },
7393
+ );
7394
+ }),
7395
+ );
7396
+ };
6115
7397
 
6116
7398
  for (const [receiptKey, group] of requestsByReceiptKey) {
6117
7399
  const requestsForKey = group.requests;
6118
7400
  const claim = claims.get(receiptKey);
6119
7401
  if (!claim) {
6120
- durableExistingRunningHandlers.push(
6121
- reclaimExistingRunningReceiptAfterWait(
6122
- receiptKey,
6123
- requestsForKey,
6124
- group.forceRefresh,
6125
- ),
7402
+ queueExistingRunningReceiptWait(
7403
+ receiptKey,
7404
+ requestsForKey,
7405
+ group.forceRefresh,
6126
7406
  );
6127
7407
  continue;
6128
7408
  }
@@ -6142,9 +7422,19 @@ export class PlayContextImpl {
6142
7422
  continue;
6143
7423
  }
6144
7424
  if (
6145
- claim?.status === 'running' &&
6146
- claim.claimState !== 'existing'
7425
+ (claim?.status === 'queued' ||
7426
+ claim?.status === 'pending' ||
7427
+ claim?.status === 'running') &&
7428
+ claim.claimState === 'existing'
6147
7429
  ) {
7430
+ queueExistingRunningReceiptWait(
7431
+ receiptKey,
7432
+ requestsForKey,
7433
+ group.forceRefresh,
7434
+ );
7435
+ continue;
7436
+ }
7437
+ if (this.isOwnedClaimedRuntimeReceipt(claim)) {
6148
7438
  const [owner, ...waiters] = requestsForKey;
6149
7439
  if (!claim.leaseId) {
6150
7440
  throw new RuntimeReceiptLeaseLostError({
@@ -6155,34 +7445,24 @@ export class PlayContextImpl {
6155
7445
  }
6156
7446
  if (owner) {
6157
7447
  owner.receiptLeaseId = claim.leaseId ?? null;
7448
+ owner.receiptLeaseExpiresAt = claim.leaseExpiresAt ?? null;
6158
7449
  }
6159
7450
  claimOwnerWithLiveFollowers(owner, waiters);
6160
7451
  continue;
6161
7452
  }
6162
- if (
6163
- claim?.status === 'running' &&
6164
- claim.claimState === 'existing'
6165
- ) {
6166
- durableExistingRunningHandlers.push(
6167
- reclaimExistingRunningReceiptAfterWait(
6168
- receiptKey,
6169
- requestsForKey,
6170
- group.forceRefresh,
6171
- ),
6172
- );
6173
- continue;
6174
- }
6175
- durableExistingRunningHandlers.push(
6176
- reclaimExistingRunningReceiptAfterWait(
6177
- receiptKey,
6178
- requestsForKey,
6179
- group.forceRefresh,
6180
- ),
7453
+ queueExistingRunningReceiptWait(
7454
+ receiptKey,
7455
+ requestsForKey,
7456
+ group.forceRefresh,
6181
7457
  );
6182
7458
  }
6183
7459
 
6184
7460
  pendingRequests.length = 0;
6185
7461
  pendingRequests.push(...claimedRequests);
7462
+ durableExistingRunningHandler =
7463
+ durableExistingRunningWaits.length > 0
7464
+ ? processExistingRunningReceiptWaits()
7465
+ : null;
6186
7466
  recordToolStep(durableRecoveredRequests);
6187
7467
  }
6188
7468
 
@@ -6199,7 +7479,7 @@ export class PlayContextImpl {
6199
7479
 
6200
7480
  const batchSize =
6201
7481
  compiledBatches.length > 0
6202
- ? await this.governor.suggestedParallelism(
7482
+ ? await this.resourceGovernor.suggestedToolParallelism(
6203
7483
  compiledBatches[0]!.batchOperation,
6204
7484
  this.governor.policy.pacing
6205
7485
  .workerToolBatchDefaultParallelism,
@@ -6218,9 +7498,6 @@ export class PlayContextImpl {
6218
7498
  this.persistenceLatch,
6219
7499
  );
6220
7500
  }
6221
- await this.assertRuntimeToolReceiptOwnership(
6222
- batch.memberRequests,
6223
- );
6224
7501
  const receiptKeys = batch.memberRequests.map(
6225
7502
  (request) => request.cacheKey,
6226
7503
  );
@@ -6254,9 +7531,29 @@ export class PlayContextImpl {
6254
7531
  }),
6255
7532
  ),
6256
7533
  }),
7534
+ receiptLeaseExpiresAt: batch.memberRequests.reduce<
7535
+ string | null
7536
+ >((earliest, request) => {
7537
+ const expiresAt = request.receiptLeaseExpiresAt ?? null;
7538
+ if (!expiresAt) return earliest;
7539
+ if (!earliest) return expiresAt;
7540
+ return Date.parse(expiresAt) < Date.parse(earliest)
7541
+ ? expiresAt
7542
+ : earliest;
7543
+ }, null),
6257
7544
  timeoutMs: resolveRuntimeTimeoutMsForClaimedOwners(
6258
7545
  batch.memberRequests,
6259
7546
  ),
7547
+ beforeProviderCall: () =>
7548
+ this.assertRuntimeToolReceiptOwnership(
7549
+ batch.memberRequests,
7550
+ ),
7551
+ heartbeatReceipt: () =>
7552
+ this.assertRuntimeToolReceiptOwnership(
7553
+ batch.memberRequests,
7554
+ ),
7555
+ parkProviderCall: () =>
7556
+ this.parkRuntimeToolReceiptsQueued(batch.memberRequests),
6260
7557
  },
6261
7558
  );
6262
7559
  },
@@ -6299,77 +7596,144 @@ export class PlayContextImpl {
6299
7596
  },
6300
7597
  });
6301
7598
  } else {
6302
- const batchSize = await this.governor.suggestedParallelism(
6303
- toolId,
6304
- this.governor.policy.pacing.suggestedMaxParallelism,
6305
- );
6306
- for (
6307
- let start = 0;
6308
- start < pendingRequests.length;
6309
- start += batchSize
6310
- ) {
6311
- const chunk = pendingRequests.slice(start, start + batchSize);
6312
- await Promise.allSettled(
6313
- chunk.map(async (request) => {
6314
- // Circuit breaker: do not dispatch this provider call once a
6315
- // persistence failure has occurred in this run.
6316
- if (this.persistenceLatch.tripped) {
6317
- this.persistenceLatch.preventedCallCount += 1;
6318
- await rejectWithLiveFollowers(
6319
- request,
6320
- new RuntimePersistenceCircuitOpenError(
6321
- this.persistenceLatch,
6322
- ),
6323
- );
6324
- return;
6325
- }
6326
- try {
6327
- await this.assertRuntimeToolReceiptOwnership([request]);
6328
- const execution = await this.callToolExecutionAPI(
6329
- toolId,
6330
- request.input,
6331
- {
6332
- ...(request.receiptKey
6333
- ? {
6334
- durableCallReceiptKey: request.receiptKey,
6335
- executionAuthScopeDigest:
6336
- request.executionAuthScopeDigest,
6337
- providerIdempotencyKey:
6338
- this.providerIdempotencyKeyForToolCall({
6339
- cacheKey: request.receiptKey,
6340
- force: request.force === true,
6341
- leaseId: request.receiptLeaseId,
6342
- }),
6343
- }
6344
- : {}),
6345
- timeoutMs: resolveRuntimeTimeoutMsForClaimedOwners([
6346
- request,
6347
- ]),
6348
- },
6349
- );
6350
- const result = await this.resolveToolCall(
6351
- toolId,
6352
- request,
6353
- execution?.result ?? null,
6354
- execution?.metadata ?? null,
6355
- execution?.jobId,
6356
- execution?.meta,
6357
- );
6358
- liveStepResults.set(request.callId, result);
6359
- resolveLiveFollowers(request, result);
6360
- } catch (error) {
6361
- await rejectWithLiveFollowers(request, error);
6362
- }
6363
- }),
7599
+ const initialRequestsPerSecond =
7600
+ await this.resourceGovernor.suggestedToolParallelism(
7601
+ toolId,
7602
+ this.governor.policy.pacing.suggestedMaxParallelism,
6364
7603
  );
6365
-
6366
- recordToolStep(chunk);
6367
- this.#options.onBatchComplete?.(this.checkpoint);
6368
- }
7604
+ const dispatcher = new AdaptiveToolDispatcher<
7605
+ ToolCallRequest,
7606
+ unknown
7607
+ >({
7608
+ initialRequestsPerSecond,
7609
+ maxRequestsPerSecond:
7610
+ this.governor.policy.pacing.suggestedMaxParallelism,
7611
+ maxConcurrency: this.governor.policy.concurrency.toolCalls,
7612
+ defaultLatencyMs: 1_000,
7613
+ safetyFactor: 1.5,
7614
+ });
7615
+ const completionBuffer: Array<{
7616
+ request: ToolCallRequest;
7617
+ result: unknown | null;
7618
+ metadata?: ToolResultMetadataInput | null;
7619
+ jobId?: string;
7620
+ meta?: Record<string, unknown>;
7621
+ resolve: (value: unknown) => void;
7622
+ reject: (error: unknown) => void;
7623
+ }> = [];
7624
+ const completionFailedCallIds = new Set<string>();
7625
+ let completionFlushScheduled = false;
7626
+ const flushCompletionBuffer = async (): Promise<void> => {
7627
+ const entries = completionBuffer.splice(0);
7628
+ if (entries.length === 0) return;
7629
+ try {
7630
+ const resolvedResults = await this.resolveToolCallBatchResults(
7631
+ toolId,
7632
+ entries.map((entry) => ({
7633
+ request: entry.request,
7634
+ result: entry.result,
7635
+ metadata: entry.metadata,
7636
+ jobId: entry.jobId,
7637
+ meta: entry.meta,
7638
+ })),
7639
+ );
7640
+ for (let index = 0; index < entries.length; index += 1) {
7641
+ const entry = entries[index]!;
7642
+ const result = resolvedResults[index];
7643
+ liveStepResults.set(entry.request.callId, result);
7644
+ resolveLiveFollowers(entry.request, result);
7645
+ entry.resolve(result);
7646
+ }
7647
+ } catch (error) {
7648
+ for (const entry of entries) {
7649
+ completionFailedCallIds.add(entry.request.callId);
7650
+ await this.rejectToolCall(toolId, entry.request, error, {
7651
+ persistReceiptFailure: false,
7652
+ });
7653
+ entry.reject(error);
7654
+ }
7655
+ }
7656
+ };
7657
+ const enqueueCompletion = (
7658
+ request: ToolCallRequest,
7659
+ execution: ParsedToolExecuteResponse,
7660
+ ): Promise<unknown> =>
7661
+ new Promise((resolve, reject) => {
7662
+ completionBuffer.push({
7663
+ request,
7664
+ result: execution.result ?? null,
7665
+ metadata: execution.metadata ?? null,
7666
+ jobId: execution.jobId,
7667
+ meta: execution.meta,
7668
+ resolve,
7669
+ reject,
7670
+ });
7671
+ if (completionFlushScheduled) return;
7672
+ completionFlushScheduled = true;
7673
+ setTimeout(() => {
7674
+ completionFlushScheduled = false;
7675
+ void flushCompletionBuffer();
7676
+ }, 0);
7677
+ });
7678
+ const dispatchResults = await dispatcher.dispatch(
7679
+ pendingRequests,
7680
+ async (request) => {
7681
+ // Circuit breaker: do not dispatch this provider call once a
7682
+ // persistence failure has occurred in this run.
7683
+ if (this.persistenceLatch.tripped) {
7684
+ this.persistenceLatch.preventedCallCount += 1;
7685
+ throw new RuntimePersistenceCircuitOpenError(
7686
+ this.persistenceLatch,
7687
+ );
7688
+ }
7689
+ const execution = await this.callToolExecutionAPI(
7690
+ toolId,
7691
+ request.input,
7692
+ {
7693
+ beforeProviderCall: () =>
7694
+ this.assertRuntimeToolReceiptOwnership([request]),
7695
+ parkProviderCall: () =>
7696
+ this.parkRuntimeToolReceiptsQueued([request]),
7697
+ ...(request.receiptKey
7698
+ ? {
7699
+ durableCallReceiptKey: request.receiptKey,
7700
+ executionAuthScopeDigest:
7701
+ request.executionAuthScopeDigest,
7702
+ providerIdempotencyKey:
7703
+ this.providerIdempotencyKeyForToolCall({
7704
+ cacheKey: request.receiptKey,
7705
+ force: request.force === true,
7706
+ leaseId: request.receiptLeaseId,
7707
+ }),
7708
+ receiptLeaseExpiresAt: request.receiptLeaseExpiresAt,
7709
+ heartbeatReceipt: () =>
7710
+ this.assertRuntimeToolReceiptOwnership([request]),
7711
+ }
7712
+ : {}),
7713
+ timeoutMs: resolveRuntimeTimeoutMsForClaimedOwners([
7714
+ request,
7715
+ ]),
7716
+ },
7717
+ );
7718
+ return await enqueueCompletion(request, execution);
7719
+ },
7720
+ );
7721
+ const failedEntries = dispatchResults.filter(
7722
+ (entry) =>
7723
+ entry.error !== undefined &&
7724
+ !completionFailedCallIds.has(entry.job.callId),
7725
+ );
7726
+ await Promise.allSettled(
7727
+ failedEntries.map((entry) =>
7728
+ rejectWithLiveFollowers(entry.job, entry.error),
7729
+ ),
7730
+ );
7731
+ recordToolStep(pendingRequests);
7732
+ this.#options.onBatchComplete?.(this.checkpoint);
6369
7733
  }
6370
7734
  }
6371
- if (durableExistingRunningHandlers.length > 0) {
6372
- await Promise.allSettled(durableExistingRunningHandlers);
7735
+ if (durableExistingRunningHandler) {
7736
+ await durableExistingRunningHandler;
6373
7737
  }
6374
7738
  }),
6375
7739
  );
@@ -6498,7 +7862,11 @@ export class PlayContextImpl {
6498
7862
  // The Governor's tool slot is the single seam for tool-call budget + the
6499
7863
  // global tool-concurrency backstop + per-(org, provider) pacing. It blocks
6500
7864
  // until all three are satisfied and is held (across retries) until release.
6501
- const toolSlot = await this.governor.acquireToolSlot(toolId);
7865
+ const toolSlot = await this.resourceGovernor.acquireTool({
7866
+ orgId: this.#options.orgId ?? null,
7867
+ providerResourceKey: `tool:${toolId}`,
7868
+ toolId,
7869
+ });
6502
7870
  try {
6503
7871
  return await withActiveSpan(
6504
7872
  'plays.tool.execute',
@@ -6538,7 +7906,15 @@ export class PlayContextImpl {
6538
7906
  const deeplineRequestId = providerIdempotencyKey
6539
7907
  ? `ctx-tool-${stableDigest(providerIdempotencyKey).slice(0, 32)}`
6540
7908
  : null;
6541
- const abortController = timeoutMs ? new AbortController() : null;
7909
+ // Receipt ownership is a liveness contract, not a one-time check.
7910
+ // Keep it alive for the whole provider HTTP request. The cadence
7911
+ // comes from the store-issued expiry because a remote runner may
7912
+ // not share the coordinator's TTL environment.
7913
+ const hasReceiptHeartbeat = Boolean(options?.heartbeatReceipt);
7914
+ const abortController =
7915
+ timeoutMs || hasReceiptHeartbeat ? new AbortController() : null;
7916
+ const receiptHeartbeat = options?.heartbeatReceipt;
7917
+ let heartbeatFailure: unknown = null;
6542
7918
  const timeoutHandle = timeoutMs
6543
7919
  ? setTimeout(() => {
6544
7920
  abortController?.abort(
@@ -6549,75 +7925,114 @@ export class PlayContextImpl {
6549
7925
  }, timeoutMs)
6550
7926
  : null;
6551
7927
  try {
6552
- response = await fetch(url, {
6553
- method: 'POST',
6554
- signal: abortController?.signal,
6555
- headers: {
6556
- 'Content-Type': 'application/json',
6557
- Authorization: `Bearer ${this.#options.executorToken}`,
6558
- [EXECUTE_RESPONSE_CONTRACT_HEADER]:
6559
- V2_EXECUTE_RESPONSE_CONTRACT,
6560
- [EXECUTE_RESPONSE_INTENT_HEADER]: 'dataset',
6561
- [EXECUTE_TOOL_METADATA_HEADER]: 'true',
6562
- ...(deeplineRequestId
6563
- ? {
6564
- 'x-deepline-request-id': deeplineRequestId,
6565
- 'x-deepline-idempotency-key': providerIdempotencyKey!,
6566
- }
6567
- : {}),
6568
- ...(this.#options.vercelProtectionBypassToken?.trim()
6569
- ? {
6570
- 'x-vercel-protection-bypass':
6571
- this.#options.vercelProtectionBypassToken.trim(),
6572
- }
6573
- : {}),
6574
- },
6575
- body: JSON.stringify({
6576
- payload: input,
6577
- metadata: {
6578
- parent_run_id: this.#options.runId,
6579
- ...(durableCallReceiptKey
6580
- ? {
6581
- durable_call_receipt_key: durableCallReceiptKey,
6582
- ...(executionAuthScopeDigest
6583
- ? {
6584
- execution_auth_scope_digest:
6585
- executionAuthScopeDigest,
6586
- }
6587
- : {}),
6588
- }
6589
- : {}),
6590
- ...(options?.customerDbDataset
7928
+ const providerCallStartedAt = Date.now();
7929
+ await options?.beforeProviderCall?.();
7930
+ const heartbeatIntervalMs = hasReceiptHeartbeat
7931
+ ? runtimeLeaseHeartbeatIntervalFromExpiry({
7932
+ leaseExpiresAt: options?.receiptLeaseExpiresAt,
7933
+ fallbackTtlMs: PLAY_RUNTIME_WORK_RECEIPT_LEASE_TTL_MS,
7934
+ })
7935
+ : null;
7936
+ const receiptHeartbeatSupervisor =
7937
+ heartbeatIntervalMs !== null && receiptHeartbeat
7938
+ ? createRuntimeReceiptHeartbeatSupervisor({
7939
+ intervalMs: heartbeatIntervalMs,
7940
+ heartbeat: async () => {
7941
+ await receiptHeartbeat();
7942
+ return 'active';
7943
+ },
7944
+ isLeaseLost: (error) =>
7945
+ error instanceof RuntimeReceiptLeaseLostError,
7946
+ onLeaseLost: (error) => {
7947
+ heartbeatFailure ??= error;
7948
+ abortController?.abort(error);
7949
+ },
7950
+ onTransientFailure: (error) => {
7951
+ this.log(
7952
+ `Tool ${toolId} receipt heartbeat transport failed; retrying: ${error instanceof Error ? error.message : String(error)}`,
7953
+ );
7954
+ },
7955
+ })
7956
+ : null;
7957
+ receiptHeartbeatSupervisor?.start();
7958
+ try {
7959
+ response = await fetch(url, {
7960
+ method: 'POST',
7961
+ signal: abortController?.signal,
7962
+ headers: {
7963
+ 'Content-Type': 'application/json',
7964
+ Authorization: `Bearer ${this.#options.executorToken}`,
7965
+ [EXECUTE_RESPONSE_CONTRACT_HEADER]:
7966
+ V2_EXECUTE_RESPONSE_CONTRACT,
7967
+ [EXECUTE_RESPONSE_INTENT_HEADER]: 'dataset',
7968
+ [EXECUTE_TOOL_METADATA_HEADER]: 'true',
7969
+ ...(deeplineRequestId
6591
7970
  ? {
6592
- query_result_dataset: {
6593
- limit: options.customerDbDataset.limit,
6594
- offset: options.customerDbDataset.offset,
6595
- page_size: options.customerDbDataset.pageSize,
6596
- total_rows: options.customerDbDataset.totalRows,
6597
- },
6598
- ...(isCustomerDbDatasetTool(toolId)
6599
- ? {
6600
- customer_db_dataset: {
6601
- limit: options.customerDbDataset.limit,
6602
- offset: options.customerDbDataset.offset,
6603
- page_size: options.customerDbDataset.pageSize,
6604
- total_rows:
6605
- options.customerDbDataset.totalRows,
6606
- },
6607
- }
6608
- : {}),
7971
+ 'x-deepline-request-id': deeplineRequestId,
7972
+ 'x-deepline-idempotency-key': providerIdempotencyKey!,
6609
7973
  }
6610
7974
  : {}),
6611
- ...(providerIdempotencyKey
6612
- ? { provider_idempotency_key: providerIdempotencyKey }
6613
- : {}),
7975
+ ...(await this.vercelProtectionHeaders()),
6614
7976
  },
6615
- ...(this.#options.integrationMode
6616
- ? { integration_mode: this.#options.integrationMode }
6617
- : {}),
6618
- }),
6619
- });
7977
+ body: JSON.stringify({
7978
+ payload: input,
7979
+ metadata: {
7980
+ parent_run_id: this.#options.runId,
7981
+ ...(durableCallReceiptKey
7982
+ ? {
7983
+ durable_call_receipt_key: durableCallReceiptKey,
7984
+ ...(executionAuthScopeDigest
7985
+ ? {
7986
+ execution_auth_scope_digest:
7987
+ executionAuthScopeDigest,
7988
+ }
7989
+ : {}),
7990
+ }
7991
+ : {}),
7992
+ ...(options?.customerDbDataset
7993
+ ? {
7994
+ query_result_dataset: {
7995
+ limit: options.customerDbDataset.limit,
7996
+ offset: options.customerDbDataset.offset,
7997
+ page_size: options.customerDbDataset.pageSize,
7998
+ total_rows: options.customerDbDataset.totalRows,
7999
+ },
8000
+ ...(isCustomerDbDatasetTool(toolId)
8001
+ ? {
8002
+ customer_db_dataset: {
8003
+ limit: options.customerDbDataset.limit,
8004
+ offset: options.customerDbDataset.offset,
8005
+ page_size:
8006
+ options.customerDbDataset.pageSize,
8007
+ total_rows:
8008
+ options.customerDbDataset.totalRows,
8009
+ },
8010
+ }
8011
+ : {}),
8012
+ }
8013
+ : {}),
8014
+ ...(providerIdempotencyKey
8015
+ ? { provider_idempotency_key: providerIdempotencyKey }
8016
+ : {}),
8017
+ },
8018
+ ...(this.#options.integrationMode
8019
+ ? { integration_mode: this.#options.integrationMode }
8020
+ : {}),
8021
+ }),
8022
+ });
8023
+ this.resourceGovernor.observe({
8024
+ providerResourceKey: `tool:${toolId}`,
8025
+ providerLatencyMs: Date.now() - providerCallStartedAt,
8026
+ providerSuccess: response.ok,
8027
+ provider429: response.status === 429,
8028
+ });
8029
+ } finally {
8030
+ receiptHeartbeatSupervisor?.stop();
8031
+ }
6620
8032
  } catch (error) {
8033
+ if (heartbeatFailure) {
8034
+ throw heartbeatFailure;
8035
+ }
6621
8036
  if (abortController?.signal.aborted) {
6622
8037
  throw abortController.signal.reason instanceof Error
6623
8038
  ? abortController.signal.reason
@@ -6639,6 +8054,7 @@ export class PlayContextImpl {
6639
8054
  this.log(
6640
8055
  `Tool ${toolId} transport failed calling ${url} on attempt ${transportAttempt}/${TOOL_EXECUTE_TRANSPORT_MAX_ATTEMPTS}; retrying after ${retryAfterMs}ms: ${message}`,
6641
8056
  );
8057
+ await options?.parkProviderCall?.();
6642
8058
  await this.sleepWithCheckpointHeartbeat(retryAfterMs);
6643
8059
  continue;
6644
8060
  }
@@ -6702,6 +8118,7 @@ export class PlayContextImpl {
6702
8118
  this.log(
6703
8119
  `Tool ${toolId} returned ${response.status}; retrying after ${failure.retryDelayMs}ms`,
6704
8120
  );
8121
+ await options?.parkProviderCall?.();
6705
8122
  await this.sleepWithCheckpointHeartbeat(failure.retryDelayMs);
6706
8123
  continue;
6707
8124
  }
@@ -6730,8 +8147,8 @@ export class PlayContextImpl {
6730
8147
 
6731
8148
  /**
6732
8149
  * Resolve the provider backing a tool (from queue-hint metadata) and feed a
6733
- * server-observed Retry-After back into the Governor's shared pacer. No hints
6734
- * no known provider nothing to penalize.
8150
+ * server-observed Retry-After back into the Governor's shared pacer. Tools
8151
+ * without hints use the Governor's default `tool:${toolId}` bucket.
6735
8152
  */
6736
8153
  private async reportToolBackpressure(
6737
8154
  toolId: string,
@@ -6740,11 +8157,11 @@ export class PlayContextImpl {
6740
8157
  const hints: readonly PlayQueueHint[] = this.#options.getToolQueueHints
6741
8158
  ? await this.#options.getToolQueueHints(toolId)
6742
8159
  : [];
6743
- const provider = hints[0]?.provider;
6744
- if (!provider) {
6745
- return;
6746
- }
6747
- this.governor.reportProviderBackpressure({ provider, retryAfterMs });
8160
+ const provider = hints[0]?.provider?.trim() || `tool:${toolId}`;
8161
+ this.resourceGovernor.reportProviderBackpressure({
8162
+ provider,
8163
+ retryAfterMs,
8164
+ });
6748
8165
  }
6749
8166
 
6750
8167
  private async sleepWithCheckpointHeartbeat(ms: number): Promise<void> {