deepline 0.1.182 → 0.1.183

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 (66) hide show
  1. package/dist/bundling-sources/apps/play-runner-workers/src/child-play-await.ts +203 -6
  2. package/dist/bundling-sources/apps/play-runner-workers/src/child-play-submit.ts +8 -1
  3. package/dist/bundling-sources/apps/play-runner-workers/src/coordinator-entry.ts +633 -107
  4. package/dist/bundling-sources/apps/play-runner-workers/src/dedup-do.ts +3 -2
  5. package/dist/bundling-sources/apps/play-runner-workers/src/entry.ts +1747 -1890
  6. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/harness-receipt-store.ts +116 -0
  7. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/map-chunk-plan.ts +542 -78
  8. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/receipts.ts +292 -11
  9. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/run-work-dispatcher.ts +519 -0
  10. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/tool-dispatch.ts +2381 -0
  11. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/tool-receipts.ts +18 -5
  12. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/work-budget.ts +130 -0
  13. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/worker-platform-budget.ts +39 -0
  14. package/dist/bundling-sources/apps/play-runner-workers/src/workflow-retry-state.ts +2 -0
  15. package/dist/bundling-sources/sdk/src/client.ts +41 -0
  16. package/dist/bundling-sources/sdk/src/http.ts +23 -8
  17. package/dist/bundling-sources/sdk/src/plays/harness-stub.ts +56 -3
  18. package/dist/bundling-sources/sdk/src/release.ts +2 -2
  19. package/dist/bundling-sources/sdk/src/types.ts +2 -0
  20. package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +132 -10
  21. package/dist/bundling-sources/shared_libs/play-runtime/auth-scope-resolver.ts +92 -0
  22. package/dist/bundling-sources/shared_libs/play-runtime/batch-runtime.ts +4 -4
  23. package/dist/bundling-sources/shared_libs/play-runtime/batching-types.ts +8 -0
  24. package/dist/bundling-sources/shared_libs/play-runtime/child-run-id.ts +101 -0
  25. package/dist/bundling-sources/shared_libs/play-runtime/context.ts +1605 -281
  26. package/dist/bundling-sources/shared_libs/play-runtime/ctx-types.ts +105 -6
  27. package/dist/bundling-sources/shared_libs/play-runtime/dedup-backend.ts +0 -0
  28. package/dist/bundling-sources/shared_libs/play-runtime/default-batch-strategies.ts +1 -0
  29. package/dist/bundling-sources/shared_libs/play-runtime/durability-store.ts +4 -0
  30. package/dist/bundling-sources/shared_libs/play-runtime/durable-call-cache.ts +116 -16
  31. package/dist/bundling-sources/shared_libs/play-runtime/durable-receipt-execution.ts +186 -20
  32. package/dist/bundling-sources/shared_libs/play-runtime/dynamic-worker-version.ts +2 -0
  33. package/dist/bundling-sources/shared_libs/play-runtime/execution-ledger-store.ts +133 -0
  34. package/dist/bundling-sources/shared_libs/play-runtime/governor/app-runtime-rate-state-backend.ts +245 -0
  35. package/dist/bundling-sources/shared_libs/play-runtime/governor/governor.ts +5 -5
  36. package/dist/bundling-sources/shared_libs/play-runtime/governor/policy.ts +14 -0
  37. package/dist/bundling-sources/shared_libs/play-runtime/lease-policy.ts +52 -0
  38. package/dist/bundling-sources/shared_libs/play-runtime/protocol.ts +41 -0
  39. package/dist/bundling-sources/shared_libs/play-runtime/providers.ts +3 -2
  40. package/dist/bundling-sources/shared_libs/play-runtime/receipt-sql.ts +124 -0
  41. package/dist/bundling-sources/shared_libs/play-runtime/receipt-status.ts +6 -2
  42. package/dist/bundling-sources/shared_libs/play-runtime/row-isolation.ts +48 -2
  43. package/dist/bundling-sources/shared_libs/play-runtime/run-ledger.ts +158 -15
  44. package/dist/bundling-sources/shared_libs/play-runtime/run-terminal-source.ts +45 -0
  45. package/dist/bundling-sources/shared_libs/play-runtime/runtime-actions.ts +9 -0
  46. package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +2037 -262
  47. package/dist/bundling-sources/shared_libs/play-runtime/runtime-contract.ts +42 -0
  48. package/dist/bundling-sources/shared_libs/play-runtime/runtime-sheet-attempt-state-machine.ts +183 -0
  49. package/dist/bundling-sources/shared_libs/play-runtime/runtime-sheet-errors.ts +88 -0
  50. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backend.ts +8 -1
  51. package/dist/bundling-sources/shared_libs/play-runtime/sheet-attempt-sql.ts +103 -0
  52. package/dist/bundling-sources/shared_libs/play-runtime/suspension.ts +19 -1
  53. package/dist/bundling-sources/shared_libs/play-runtime/test-runtime-seams.ts +343 -0
  54. package/dist/bundling-sources/shared_libs/play-runtime/tool-execute-retry-policy.ts +43 -0
  55. package/dist/bundling-sources/shared_libs/play-runtime/tool-http-errors.ts +11 -0
  56. package/dist/bundling-sources/shared_libs/play-runtime/work-receipt-state-machine.ts +437 -0
  57. package/dist/bundling-sources/shared_libs/play-runtime/work-receipts.ts +83 -6
  58. package/dist/bundling-sources/shared_libs/security/safe-outbound-fetch.ts +24 -17
  59. package/dist/cli/index.js +76 -17
  60. package/dist/cli/index.mjs +76 -17
  61. package/dist/index.d.mts +2 -0
  62. package/dist/index.d.ts +2 -0
  63. package/dist/index.js +63 -9
  64. package/dist/index.mjs +63 -9
  65. package/dist/plays/bundle-play-file.mjs +2 -2
  66. package/package.json +1 -1
@@ -23,12 +23,24 @@ import {
23
23
  } from './batch-runtime';
24
24
  import type { PlayQueueHint } from './governor/rate-state-backend';
25
25
  import type { MapRowOutcome } from './durability-store';
26
+ import type { WorkReceiptFailureKind } from './work-receipts';
26
27
  import {
27
28
  completedMapRowOutcome,
28
29
  failedMapRowOutcome,
29
30
  mapRowOutcomeRuntimeFields,
30
31
  resolveMapRowOutcomeKey,
31
32
  } from './map-row-outcome';
33
+ import { RuntimeSheetRowsBlockedError } from './runtime-sheet-errors';
34
+ import { buildChildRunId } from './child-run-id';
35
+ import type { PlayCallGovernanceSnapshot } from './scheduler-backend';
36
+ import {
37
+ RUNTIME_SCHEDULER_SCHEMA_OVERRIDE_HEADER,
38
+ SYNTHETIC_RUN_HEADER,
39
+ } from './coordinator-headers';
40
+ export {
41
+ RuntimeSheetRowsBlockedError,
42
+ type RuntimeSheetBlockedRowDetail,
43
+ } from './runtime-sheet-errors';
32
44
  import {
33
45
  createDefaultGovernanceSnapshot,
34
46
  createPlayExecutionGovernor,
@@ -64,30 +76,38 @@ import {
64
76
  TOOL_EXECUTE_TRANSPORT_MAX_ATTEMPTS,
65
77
  classifyToolExecuteHttpFailure,
66
78
  createToolExecuteHttpFailureAttemptTracker,
79
+ parseToolExecuteAuthScopeChangedError,
80
+ ToolExecuteAuthScopeChangedError,
67
81
  } from './tool-execute-retry-policy';
68
82
  import {
69
83
  buildDurableCtxCallCacheKey,
84
+ buildDurableRunPlaySemanticKey,
85
+ buildDurableToolAggregateProviderIdempotencyKey,
86
+ buildDurableToolAggregateReceiptKey,
70
87
  buildDurableToolCallAuthScopeDigest,
71
88
  buildDurableToolCallCacheKey,
89
+ buildDurableToolProviderIdempotencyKey,
90
+ buildDurableToolReceiptPrefix,
72
91
  } from './durable-call-cache';
73
92
  import {
74
- QUERY_RESULT_DATASET_PAGE_SIZE,
75
- isCustomerDbDatasetTool,
76
- isQueryResultDatasetReadRequest,
77
- isQueryResultDatasetTool,
78
- } from './query-result-dataset';
79
- import {
93
+ RuntimeReceiptLeaseLostError,
80
94
  RuntimeReceiptWaitTimeoutError,
81
95
  executeWithDurableRuntimeReceipt,
82
96
  resolveRuntimeToolReceiptWaitMaxAttempts,
97
+ runtimeReceiptFailureKindForError,
83
98
  runtimeReceiptOutput as durableRuntimeReceiptOutput,
84
99
  waitForCompletedRuntimeReceipt,
85
100
  type DurableReceiptExecutionStore,
86
101
  } from './durable-receipt-execution';
102
+ import {
103
+ QUERY_RESULT_DATASET_PAGE_SIZE,
104
+ isCustomerDbDatasetTool,
105
+ isQueryResultDatasetReadRequest,
106
+ isQueryResultDatasetTool,
107
+ } from './query-result-dataset';
87
108
  import { isRowIsolationExemptError } from './row-isolation';
88
109
  import {
89
110
  createRuntimePersistenceLatch,
90
- PROVIDER_DISPATCH_WAVE_SIZE,
91
111
  RuntimePersistenceCircuitOpenError,
92
112
  tripRuntimePersistenceLatch,
93
113
  type RuntimePersistenceLatch,
@@ -119,7 +139,9 @@ import { DISALLOWED_RUN_JAVASCRIPT_TOOL_MESSAGE } from './runtime-constraints';
119
139
  import {
120
140
  PlayExecutionSuspendedError,
121
141
  PlayRowExecutionSuspendedError,
142
+ isPlayExecutionSuspendedError,
122
143
  isPlayRowExecutionSuspendedError,
144
+ type PlayExecutionSuspension,
123
145
  } from './suspension';
124
146
  import {
125
147
  createSecretRedactionContext,
@@ -171,6 +193,7 @@ import {
171
193
  type StepProgramDatasetColumnInput,
172
194
  type StepProgramDatasetOptions,
173
195
  } from './step-program-dataset-builder';
196
+ import { readRuntimeSheetDatasetRows } from './runtime-api';
174
197
 
175
198
  /**
176
199
  * SECURITY: AsyncLocalStorage is per async execution, not a cross-run cache.
@@ -207,9 +230,118 @@ const TOOL_RETRY_HEARTBEAT_INTERVAL_MS = 30_000;
207
230
  const DEEPLINEAGENT_TOOL_RUNTIME_TIMEOUT_MS = 15 * 60 * 1000;
208
231
  const FETCH_TRANSPORT_MAX_ATTEMPTS = 3;
209
232
  const FETCH_TRANSPORT_RETRY_DELAY_MS = 100;
233
+ const NODE_RUNTIME_MAP_VISIBILITY_MAX_ATTEMPTS = 100;
234
+ const NODE_RUNTIME_MAP_VISIBILITY_RETRY_MS = 25;
210
235
  type SafeFetchModule = typeof import('@shared_libs/security/safe-fetch');
211
236
  let safeFetchModule: Promise<SafeFetchModule> | null = null;
212
237
 
238
+ export async function waitForNodeRuntimeMapRowsVisible(input: {
239
+ mapName: string;
240
+ tableNamespace: string;
241
+ runId?: string | null;
242
+ expectedRows: number;
243
+ updatedRows: number;
244
+ readVisibleRowCount: () => Promise<number>;
245
+ sleep?: (ms: number) => Promise<void>;
246
+ log?: (line: string) => void;
247
+ }): Promise<number> {
248
+ if (input.expectedRows <= 0) return 0;
249
+
250
+ const sleep =
251
+ input.sleep ??
252
+ ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
253
+ let lastVisibleRows = -1;
254
+ for (
255
+ let attempt = 1;
256
+ attempt <= NODE_RUNTIME_MAP_VISIBILITY_MAX_ATTEMPTS;
257
+ attempt += 1
258
+ ) {
259
+ lastVisibleRows = await input.readVisibleRowCount();
260
+ if (lastVisibleRows >= input.expectedRows) {
261
+ if (attempt > 1) {
262
+ input.log?.(
263
+ `Runtime sheet visibility barrier satisfied ctx.dataset("${input.mapName}") ` +
264
+ `after ${attempt} read(s): visible ${lastVisibleRows}/${input.expectedRows}; ` +
265
+ `wrote ${input.updatedRows}/${input.expectedRows}; run ${input.runId ?? 'unknown'}.`,
266
+ );
267
+ }
268
+ return lastVisibleRows;
269
+ }
270
+
271
+ if (attempt === 1) {
272
+ input.log?.(
273
+ `Runtime sheet visibility barrier waiting ctx.dataset("${input.mapName}"): ` +
274
+ `visible ${lastVisibleRows}/${input.expectedRows}; ` +
275
+ `wrote ${input.updatedRows}/${input.expectedRows}; run ${input.runId ?? 'unknown'}.`,
276
+ );
277
+ }
278
+ if (attempt < NODE_RUNTIME_MAP_VISIBILITY_MAX_ATTEMPTS) {
279
+ await sleep(NODE_RUNTIME_MAP_VISIBILITY_RETRY_MS);
280
+ }
281
+ }
282
+
283
+ throw new Error(
284
+ `Runtime sheet visibility mismatch for ctx.dataset("${input.mapName}"): ` +
285
+ `expected ${input.expectedRows} terminal visible row(s), saw ${lastVisibleRows}; ` +
286
+ `write reported ${input.updatedRows}; run ${input.runId ?? 'unknown'}.`,
287
+ );
288
+ }
289
+
290
+ export async function reconcileNodeRuntimeMapResultsWithPersistedSheet(input: {
291
+ mapName: string;
292
+ tableNamespace: string;
293
+ runId?: string | null;
294
+ expectedRows: number;
295
+ currentRows: Record<string, unknown>[];
296
+ failedRowCount: number;
297
+ readPersistedRows: (input: {
298
+ limit: number;
299
+ offset: number;
300
+ rowMode: 'all';
301
+ }) => Promise<Record<string, unknown>[]>;
302
+ log?: (line: string) => void;
303
+ }): Promise<Record<string, unknown>[]> {
304
+ if (
305
+ input.failedRowCount > 0 ||
306
+ input.expectedRows <= 0 ||
307
+ input.currentRows.length >= input.expectedRows
308
+ ) {
309
+ return input.currentRows;
310
+ }
311
+
312
+ const persistedRows: Record<string, unknown>[] = [];
313
+ let offset = 0;
314
+ while (persistedRows.length < input.expectedRows) {
315
+ const page = await input.readPersistedRows({
316
+ limit: Math.min(10_000, input.expectedRows - persistedRows.length),
317
+ offset,
318
+ rowMode: 'all',
319
+ });
320
+ if (page.length === 0) break;
321
+ persistedRows.push(...page);
322
+ offset += page.length;
323
+ }
324
+
325
+ if (persistedRows.length < input.expectedRows) {
326
+ throw new Error(
327
+ `Runtime sheet finalization mismatch for ctx.dataset("${input.mapName}"): ` +
328
+ `expected ${input.expectedRows} terminal persisted row(s), saw ${persistedRows.length}; ` +
329
+ `in-memory Node map results reported ${input.currentRows.length}; run ${input.runId ?? 'unknown'}.`,
330
+ );
331
+ }
332
+
333
+ if (persistedRows.length > input.currentRows.length) {
334
+ input.log?.(
335
+ `Runtime sheet finalization reconciled ctx.dataset("${input.mapName}") ` +
336
+ `from ${input.currentRows.length}/${input.expectedRows} in-memory Node row(s) ` +
337
+ `to ${persistedRows.length} terminal persisted row(s).`,
338
+ );
339
+ return persistedRows;
340
+ }
341
+
342
+ return input.currentRows;
343
+ }
344
+
213
345
  export function resolveToolRuntimeTimeoutMs(
214
346
  toolId: string,
215
347
  requestedTimeoutMs?: number,
@@ -304,6 +436,9 @@ function finitePositiveInteger(value: unknown): number | null {
304
436
 
305
437
  type ToolExecutionApiOptions = {
306
438
  timeoutMs?: number;
439
+ durableCallReceiptKey?: string | null;
440
+ executionAuthScopeDigest?: string | null;
441
+ providerIdempotencyKey?: string | null;
307
442
  customerDbDataset?: {
308
443
  limit: number;
309
444
  offset: number;
@@ -347,6 +482,16 @@ function persistableMapRowBytes(row: PersistableMapRow): number {
347
482
  return JSON.stringify(row).length;
348
483
  }
349
484
 
485
+ function failedMapRowLogLabel(row: PersistableMapRow): string {
486
+ const rowId = row.data?.row_id;
487
+ if (typeof rowId === 'string' && rowId.trim()) return rowId.trim();
488
+ if (row.key?.trim()) return row.key.trim();
489
+ if (typeof row.inputIndex === 'number' && Number.isFinite(row.inputIndex)) {
490
+ return `input:${Math.floor(row.inputIndex)}`;
491
+ }
492
+ return 'unknown';
493
+ }
494
+
350
495
  type FieldMapRunResult = {
351
496
  completedRows: PersistableMapRow[];
352
497
  failedRows: PersistableMapRow[];
@@ -508,9 +653,7 @@ function durableCtxKey(input: {
508
653
  staleAfterSeconds?: number | null;
509
654
  }): string {
510
655
  if (input.operation === 'tool') {
511
- throw new Error(
512
- 'Durable ctx key is only for non-tool call boundaries; tools use buildDurableToolCallCacheKey.',
513
- );
656
+ throw new Error('Tool calls use tool receipt keys.');
514
657
  }
515
658
  return buildDurableCtxCallCacheKey({
516
659
  orgId: input.orgId,
@@ -756,6 +899,13 @@ export class PlayContextImpl {
756
899
  eventKey: string;
757
900
  timeoutMs: number;
758
901
  }> = [];
902
+ private pendingChildPlayBoundaries: Array<{
903
+ boundaryId: string;
904
+ childRunId: string;
905
+ childPlayName: string;
906
+ }> = [];
907
+ private activeScheduledChildPlayCalls = 0;
908
+ private scheduledChildPlayQuiescenceWaiters: Array<() => void> = [];
759
909
  private processedRowCount = 0;
760
910
  private sleepBoundaryIndex = 0;
761
911
  private fetchCallIndex = 0;
@@ -891,13 +1041,18 @@ export class PlayContextImpl {
891
1041
  options.workflowId ||
892
1042
  'anonymous-play';
893
1043
  const rootRunId = options.runId ?? options.workflowId ?? 'anonymous-run';
1044
+ if (options.requireSharedRateState && !options.rateState) {
1045
+ throw new Error(
1046
+ 'Shared rate-state backend is required for this Node runtime substrate.',
1047
+ );
1048
+ }
894
1049
  this.governor = createPlayExecutionGovernor({
895
1050
  adapter: 'cjs_node20',
896
1051
  scope: {
897
1052
  orgId: options.orgId ?? 'org',
898
1053
  rootRunId: options.runId ?? options.workflowId ?? 'run',
899
1054
  },
900
- rateState: new InMemoryRateStateBackend(),
1055
+ rateState: options.rateState ?? new InMemoryRateStateBackend(),
901
1056
  resolvePacing: createPacingResolver(options.getToolQueueHints),
902
1057
  // A root run keeps depth 0 (its first child play is depth 1) but still
903
1058
  // seeds its own play id into the ancestry/currentPlayId so the cycle guard
@@ -923,6 +1078,263 @@ export class PlayContextImpl {
923
1078
  return `${this.governance.currentRunId}:${localId}`;
924
1079
  }
925
1080
 
1081
+ private shouldLaunchChildPlaysThroughScheduler(): boolean {
1082
+ return (
1083
+ this.#options.requireSharedRateState === true &&
1084
+ this.#options.durableBoundaries === true
1085
+ );
1086
+ }
1087
+
1088
+ private childPlayBoundaryId(childRunId: string): string {
1089
+ return this.durableBoundaryId(`runPlay:${childRunId}`);
1090
+ }
1091
+
1092
+ private recordPendingChildPlayBoundary(input: {
1093
+ boundaryId: string;
1094
+ childRunId: string;
1095
+ childPlayName: string;
1096
+ }): void {
1097
+ this.pendingChildPlayBoundaries.push(input);
1098
+ }
1099
+
1100
+ private beginScheduledChildPlayCall(): { release: () => void } {
1101
+ this.activeScheduledChildPlayCalls += 1;
1102
+ let released = false;
1103
+ return {
1104
+ release: () => {
1105
+ if (released) return;
1106
+ released = true;
1107
+ this.activeScheduledChildPlayCalls = Math.max(
1108
+ 0,
1109
+ this.activeScheduledChildPlayCalls - 1,
1110
+ );
1111
+ if (this.activeScheduledChildPlayCalls !== 0) return;
1112
+ for (const resolve of this.scheduledChildPlayQuiescenceWaiters.splice(
1113
+ 0,
1114
+ )) {
1115
+ resolve();
1116
+ }
1117
+ },
1118
+ };
1119
+ }
1120
+
1121
+ private async waitForScheduledChildPlayQuiescence(): Promise<void> {
1122
+ if (this.activeScheduledChildPlayCalls === 0) return;
1123
+ await new Promise<void>((resolve) => {
1124
+ this.scheduledChildPlayQuiescenceWaiters.push(resolve);
1125
+ });
1126
+ }
1127
+
1128
+ private consumePendingChildPlaySuspension(): PlayExecutionSuspension | null {
1129
+ if (this.pendingChildPlayBoundaries.length === 0) return null;
1130
+ const boundaries = [
1131
+ ...new Map(
1132
+ this.pendingChildPlayBoundaries.map((boundary) => [
1133
+ boundary.boundaryId,
1134
+ boundary,
1135
+ ]),
1136
+ ).values(),
1137
+ ];
1138
+ this.pendingChildPlayBoundaries = [];
1139
+ if (boundaries.length === 1) {
1140
+ const boundary = boundaries[0]!;
1141
+ return {
1142
+ kind: 'child_play',
1143
+ boundaryId: boundary.boundaryId,
1144
+ childRunId: boundary.childRunId,
1145
+ childPlayName: boundary.childPlayName,
1146
+ };
1147
+ }
1148
+ return {
1149
+ kind: 'child_play_batch',
1150
+ boundaries,
1151
+ };
1152
+ }
1153
+
1154
+ private childPlayBoundaryOutput<TOutput>(input: {
1155
+ boundaryId: string;
1156
+ childRunId: string;
1157
+ childPlayName: string;
1158
+ }): { found: true; output: TOutput } | { found: false } {
1159
+ const existing = this.checkpoint.resolvedBoundaries?.[input.boundaryId];
1160
+ if (!existing || existing.kind !== 'child_play') return { found: false };
1161
+ if (existing.childRunId !== input.childRunId) return { found: false };
1162
+ if (existing.status === 'completed') {
1163
+ return { found: true, output: existing.output as TOutput };
1164
+ }
1165
+ const message =
1166
+ typeof existing.error === 'string' && existing.error.trim()
1167
+ ? existing.error.trim()
1168
+ : `Child play ${input.childPlayName} (${input.childRunId}) ${existing.status}.`;
1169
+ throw new Error(message);
1170
+ }
1171
+
1172
+ private recordPlayCallStep(input: {
1173
+ playId: string;
1174
+ description?: string | null;
1175
+ nestedSteps?: PlayStep[];
1176
+ }): void {
1177
+ const step = {
1178
+ type: 'play_call' as const,
1179
+ playId: input.playId,
1180
+ nestedSteps: input.nestedSteps ?? [],
1181
+ description: normalizeStepDescription(input.description ?? undefined),
1182
+ };
1183
+ if (this.activeDatasetStep) {
1184
+ this.activeDatasetStep.substeps.push(step);
1185
+ } else {
1186
+ this.steps.push(step);
1187
+ }
1188
+ }
1189
+
1190
+ private runtimeApiHeaders(): Record<string, string> {
1191
+ if (!this.#options.executorToken?.trim()) {
1192
+ throw new Error(
1193
+ 'ctx.runPlay scheduled child launch requires an executor token.',
1194
+ );
1195
+ }
1196
+ return {
1197
+ Authorization: `Bearer ${this.#options.executorToken.trim()}`,
1198
+ 'Content-Type': 'application/json',
1199
+ ...(this.#options.runtimeSchedulerSchema?.trim()
1200
+ ? {
1201
+ [RUNTIME_SCHEDULER_SCHEMA_OVERRIDE_HEADER]:
1202
+ this.#options.runtimeSchedulerSchema.trim(),
1203
+ [SYNTHETIC_RUN_HEADER]: '1',
1204
+ }
1205
+ : {}),
1206
+ ...(this.#options.vercelProtectionBypassToken?.trim()
1207
+ ? {
1208
+ 'x-vercel-protection-bypass':
1209
+ this.#options.vercelProtectionBypassToken.trim(),
1210
+ }
1211
+ : {}),
1212
+ };
1213
+ }
1214
+
1215
+ private runtimeApiBaseUrl(): string {
1216
+ const baseUrl = this.#options.baseUrl?.trim();
1217
+ if (!baseUrl) {
1218
+ throw new Error('ctx.runPlay scheduled child launch requires baseUrl.');
1219
+ }
1220
+ return baseUrl.replace(/\/$/, '');
1221
+ }
1222
+
1223
+ private async launchScheduledChildPlay(input: {
1224
+ key: string;
1225
+ childRunId: string;
1226
+ childPlayName: string;
1227
+ childGovernance: GovernanceSnapshot;
1228
+ childInput: Record<string, unknown>;
1229
+ description?: string | null;
1230
+ }): Promise<void> {
1231
+ const parentPlayName =
1232
+ this.governance.currentPlayId ||
1233
+ this.#options.playName ||
1234
+ this.#options.playId ||
1235
+ 'anonymous-play';
1236
+ const governance: PlayCallGovernanceSnapshot = {
1237
+ rootRunId: this.governance.rootRunId,
1238
+ parentRunId: this.governance.currentRunId,
1239
+ parentPlayName,
1240
+ key: input.key,
1241
+ ancestryPlayIds: [...this.governance.ancestryPlayIds],
1242
+ callDepth: input.childGovernance.callDepth,
1243
+ playCallCount: input.childGovernance.playCallCount,
1244
+ toolCallCount: input.childGovernance.toolCallCount,
1245
+ retryCount: input.childGovernance.retryCount,
1246
+ descendantCount: input.childGovernance.descendantCount,
1247
+ waterfallStepExecutions: input.childGovernance.waterfallStepExecutions,
1248
+ };
1249
+ const response = await fetch(
1250
+ `${this.runtimeApiBaseUrl()}/api/v2/plays/run`,
1251
+ {
1252
+ method: 'POST',
1253
+ headers: this.runtimeApiHeaders(),
1254
+ body: JSON.stringify({
1255
+ name: input.childPlayName,
1256
+ workflowId: input.childRunId,
1257
+ input: input.childInput,
1258
+ profile: 'hatchet',
1259
+ waitForCompletionMs: 0,
1260
+ internalRunPlay: {
1261
+ ...governance,
1262
+ description: input.description ?? null,
1263
+ },
1264
+ }),
1265
+ },
1266
+ );
1267
+ if (!response.ok) {
1268
+ const body = (await response.json().catch(() => null)) as {
1269
+ error?: unknown;
1270
+ } | null;
1271
+ const message =
1272
+ typeof body?.error === 'string' && body.error.trim()
1273
+ ? body.error.trim()
1274
+ : response.statusText;
1275
+ throw new Error(
1276
+ `ctx.runPlay(${input.key}) failed to launch child play "${input.childPlayName}": ${message} (status ${response.status}).`,
1277
+ );
1278
+ }
1279
+ }
1280
+
1281
+ private async readScheduledChildTerminal<TOutput>(input: {
1282
+ childRunId: string;
1283
+ childPlayName: string;
1284
+ }): Promise<
1285
+ | { status: 'completed'; output: TOutput }
1286
+ | { status: 'failed' | 'cancelled'; error: string }
1287
+ | null
1288
+ > {
1289
+ const response = await fetch(
1290
+ `${this.runtimeApiBaseUrl()}/api/v2/internal/play-runtime`,
1291
+ {
1292
+ method: 'POST',
1293
+ headers: this.runtimeApiHeaders(),
1294
+ body: JSON.stringify({
1295
+ action: 'read_child_run_terminal_snapshot',
1296
+ parentRunId: this.governance.currentRunId,
1297
+ childRunId: input.childRunId,
1298
+ childPlayName: input.childPlayName,
1299
+ }),
1300
+ },
1301
+ );
1302
+ const body = (await response.json().catch(() => null)) as {
1303
+ state?: {
1304
+ data?: {
1305
+ status?: unknown;
1306
+ result?: unknown;
1307
+ error?: unknown;
1308
+ };
1309
+ };
1310
+ error?: unknown;
1311
+ } | null;
1312
+ if (!response.ok) {
1313
+ const message =
1314
+ typeof body?.error === 'string' && body.error.trim()
1315
+ ? body.error.trim()
1316
+ : response.statusText;
1317
+ throw new Error(
1318
+ `ctx.runPlay failed to read child terminal snapshot for "${input.childPlayName}": ${message} (status ${response.status}).`,
1319
+ );
1320
+ }
1321
+ const data = body?.state?.data;
1322
+ const status = String(data?.status ?? '').toLowerCase();
1323
+ if (status === 'completed') {
1324
+ return { status, output: data?.result as TOutput };
1325
+ }
1326
+ if (status === 'failed' || status === 'cancelled') {
1327
+ return {
1328
+ status,
1329
+ error:
1330
+ typeof data?.error === 'string' && data.error.trim()
1331
+ ? data.error.trim()
1332
+ : `Child play ${input.childPlayName} (${input.childRunId}) ${status}.`,
1333
+ };
1334
+ }
1335
+ return null;
1336
+ }
1337
+
926
1338
  private emitScopedRowUpdate(
927
1339
  key: string | null,
928
1340
  tableNamespace: string | null,
@@ -990,7 +1402,7 @@ export class PlayContextImpl {
990
1402
  this.#options.runId
991
1403
  ) {
992
1404
  const response = await fetch(
993
- `${this.#options.baseUrl.replace(/\/$/, '')}/api/v2/plays/internal/runtime`,
1405
+ `${this.#options.baseUrl.replace(/\/$/, '')}/api/v2/internal/play-runtime`,
994
1406
  {
995
1407
  method: 'POST',
996
1408
  headers: {
@@ -1102,6 +1514,7 @@ export class PlayContextImpl {
1102
1514
  runId: string,
1103
1515
  reclaimRunning = false,
1104
1516
  forceRefresh = false,
1517
+ forceFailedRefresh = false,
1105
1518
  ): Promise<RuntimeStepReceipt | null> {
1106
1519
  if (!this.#options.claimRuntimeStepReceipt) {
1107
1520
  return null;
@@ -1109,8 +1522,11 @@ export class PlayContextImpl {
1109
1522
  const claimed = await this.#options.claimRuntimeStepReceipt({
1110
1523
  key,
1111
1524
  runId,
1525
+ runAttempt: this.currentRunAttempt,
1526
+ leaseAware: true,
1112
1527
  ...(reclaimRunning ? { reclaimRunning: true } : {}),
1113
1528
  ...(forceRefresh ? { forceRefresh: true } : {}),
1529
+ ...(forceFailedRefresh ? { forceFailedRefresh: true } : {}),
1114
1530
  });
1115
1531
  if (!claimed || typeof claimed.key !== 'string' || !claimed.key.trim()) {
1116
1532
  return null;
@@ -1126,6 +1542,7 @@ export class PlayContextImpl {
1126
1542
  key: string,
1127
1543
  runId: string,
1128
1544
  output: unknown | null,
1545
+ leaseId?: string | null,
1129
1546
  ): Promise<RuntimeStepReceipt | null> {
1130
1547
  assertNoSecretTaint(output, 'ctx.step receipt output');
1131
1548
  if (!this.#options.completeRuntimeStepReceipt) {
@@ -1134,6 +1551,8 @@ export class PlayContextImpl {
1134
1551
  const completed = await this.#options.completeRuntimeStepReceipt({
1135
1552
  key,
1136
1553
  runId,
1554
+ runAttempt: this.currentRunAttempt,
1555
+ ...(leaseId ? { leaseId } : {}),
1137
1556
  output,
1138
1557
  });
1139
1558
  if (
@@ -1150,10 +1569,167 @@ export class PlayContextImpl {
1150
1569
  };
1151
1570
  }
1152
1571
 
1572
+ private async releaseRuntimeStepReceipt(
1573
+ key: string,
1574
+ runId: string,
1575
+ leaseId?: string | null,
1576
+ ): Promise<RuntimeStepReceipt | null> {
1577
+ if (!this.#options.releaseRuntimeStepReceipt) {
1578
+ return null;
1579
+ }
1580
+ const released = await this.#options.releaseRuntimeStepReceipt({
1581
+ key,
1582
+ runId,
1583
+ runAttempt: this.currentRunAttempt,
1584
+ ...(leaseId ? { leaseId } : {}),
1585
+ });
1586
+ if (!released || typeof released.key !== 'string' || !released.key.trim()) {
1587
+ return null;
1588
+ }
1589
+ return {
1590
+ ...released,
1591
+ key: released.key.trim(),
1592
+ runId: released.runId ?? null,
1593
+ };
1594
+ }
1595
+
1596
+ private async heartbeatRuntimeStepReceipt(
1597
+ key: string,
1598
+ runId: string,
1599
+ leaseId: string,
1600
+ ): Promise<RuntimeStepReceipt | null> {
1601
+ if (!this.#options.heartbeatRuntimeStepReceipts) {
1602
+ return null;
1603
+ }
1604
+ const [receipt] = await this.#options.heartbeatRuntimeStepReceipts({
1605
+ runId,
1606
+ runAttempt: this.currentRunAttempt,
1607
+ leaseId,
1608
+ keys: [key],
1609
+ });
1610
+ if (!receipt || typeof receipt.key !== 'string' || !receipt.key.trim()) {
1611
+ return null;
1612
+ }
1613
+ return {
1614
+ ...receipt,
1615
+ key: receipt.key.trim(),
1616
+ runId: receipt.runId ?? null,
1617
+ };
1618
+ }
1619
+
1620
+ private async assertRuntimeToolReceiptOwnership(
1621
+ requests: ToolCallRequest[],
1622
+ ): Promise<void> {
1623
+ const targets = requests.flatMap((request) => {
1624
+ const receiptKey = request.receiptKey?.trim() || null;
1625
+ if (!receiptKey) return [];
1626
+ const leaseId = request.receiptLeaseId?.trim() || null;
1627
+ if (!leaseId) return [];
1628
+ return [{ receiptKey, leaseId }];
1629
+ });
1630
+ if (targets.length === 0) return;
1631
+
1632
+ const byLeaseId = new Map<string, string[]>();
1633
+ for (const target of targets) {
1634
+ const keys = byLeaseId.get(target.leaseId) ?? [];
1635
+ keys.push(target.receiptKey);
1636
+ byLeaseId.set(target.leaseId, keys);
1637
+ }
1638
+
1639
+ if (this.#options.heartbeatRuntimeStepReceipts) {
1640
+ for (const [leaseId, keys] of byLeaseId) {
1641
+ const receipts = await this.#options.heartbeatRuntimeStepReceipts({
1642
+ runId: this.currentRunId,
1643
+ runAttempt: this.currentRunAttempt,
1644
+ leaseId,
1645
+ keys,
1646
+ });
1647
+ this.assertRuntimeToolReceiptHeartbeatResult(keys, leaseId, receipts);
1648
+ }
1649
+ return;
1650
+ }
1651
+
1652
+ if (
1653
+ !this.#options.getRuntimeStepReceipt &&
1654
+ !this.#options.getRuntimeStepReceipts
1655
+ ) {
1656
+ throw new RuntimeReceiptLeaseLostError({
1657
+ receiptKey: targets[0]?.receiptKey ?? 'unknown',
1658
+ runId: this.currentRunId,
1659
+ leaseId: targets[0]?.leaseId ?? 'unknown',
1660
+ });
1661
+ }
1662
+
1663
+ const latest = await this.getRuntimeStepReceipts(
1664
+ targets.map((target) => target.receiptKey),
1665
+ );
1666
+ for (const target of targets) {
1667
+ const receipt = latest.get(target.receiptKey);
1668
+ if (!this.runtimeToolReceiptStillOwned(receipt, target.leaseId)) {
1669
+ throw new RuntimeReceiptLeaseLostError({
1670
+ receiptKey: target.receiptKey,
1671
+ runId: this.currentRunId,
1672
+ leaseId: target.leaseId,
1673
+ });
1674
+ }
1675
+ }
1676
+ }
1677
+
1678
+ private assertRuntimeToolReceiptHeartbeatResult(
1679
+ keys: string[],
1680
+ leaseId: string,
1681
+ receipts: Array<RuntimeStepReceipt | null>,
1682
+ ): void {
1683
+ for (let index = 0; index < keys.length; index += 1) {
1684
+ const receipt = this.normalizeRuntimeStepReceipt(
1685
+ keys[index] ?? '',
1686
+ receipts[index],
1687
+ );
1688
+ if (!this.runtimeToolReceiptStillOwned(receipt, leaseId)) {
1689
+ throw new RuntimeReceiptLeaseLostError({
1690
+ receiptKey: keys[index] ?? 'unknown',
1691
+ runId: this.currentRunId,
1692
+ leaseId,
1693
+ });
1694
+ }
1695
+ }
1696
+ }
1697
+
1698
+ private runtimeToolReceiptStillOwned(
1699
+ receipt: RuntimeStepReceipt | null | undefined,
1700
+ leaseId: string,
1701
+ ): boolean {
1702
+ if (!receipt || receipt.status !== 'running') return false;
1703
+ if (receipt.leaseId !== leaseId) return false;
1704
+ const ownerRunId = receipt.leaseOwnerRunId ?? receipt.runId ?? null;
1705
+ return !ownerRunId || ownerRunId === this.currentRunId;
1706
+ }
1707
+
1708
+ private runtimeToolReceiptStillOwnedByCurrentAttempt(
1709
+ receipt: RuntimeStepReceipt | null | undefined,
1710
+ leaseId: string | null | undefined,
1711
+ ): boolean {
1712
+ if (!receipt || receipt.status !== 'running') return false;
1713
+ if (leaseId?.trim()) {
1714
+ if (receipt.leaseId !== leaseId.trim()) return false;
1715
+ } else if (receipt.leaseId != null) {
1716
+ return false;
1717
+ }
1718
+ const ownerRunId = receipt.leaseOwnerRunId ?? receipt.runId ?? null;
1719
+ if (ownerRunId !== this.currentRunId) return false;
1720
+ const ownerAttempt =
1721
+ typeof receipt.leaseOwnerAttempt === 'number'
1722
+ ? receipt.leaseOwnerAttempt
1723
+ : 0;
1724
+ return ownerAttempt === this.currentRunAttempt;
1725
+ }
1726
+
1153
1727
  private async failRuntimeStepReceipt(
1154
1728
  key: string,
1155
1729
  runId: string,
1156
1730
  error: string,
1731
+ leaseId?: string | null,
1732
+ failureKind?: WorkReceiptFailureKind | null,
1157
1733
  ): Promise<RuntimeStepReceipt | null> {
1158
1734
  if (!this.#options.failRuntimeStepReceipt) {
1159
1735
  return null;
@@ -1161,6 +1737,9 @@ export class PlayContextImpl {
1161
1737
  const failed = await this.#options.failRuntimeStepReceipt({
1162
1738
  key,
1163
1739
  runId,
1740
+ runAttempt: this.currentRunAttempt,
1741
+ ...(leaseId ? { leaseId } : {}),
1742
+ ...(failureKind ? { failureKind } : {}),
1164
1743
  error,
1165
1744
  });
1166
1745
  if (!failed || typeof failed.key !== 'string' || !failed.key.trim()) {
@@ -1220,6 +1799,7 @@ export class PlayContextImpl {
1220
1799
  runId: string,
1221
1800
  reclaimRunning = false,
1222
1801
  forceRefresh = false,
1802
+ forceFailedRefresh = false,
1223
1803
  ): Promise<Map<string, RuntimeStepReceipt>> {
1224
1804
  const uniqueKeys = [
1225
1805
  ...new Set(keys.map((key) => key.trim()).filter(Boolean)),
@@ -1229,8 +1809,11 @@ export class PlayContextImpl {
1229
1809
  ? await this.#options.claimRuntimeStepReceipts({
1230
1810
  keys: uniqueKeys,
1231
1811
  runId,
1812
+ runAttempt: this.currentRunAttempt,
1813
+ leaseAware: true,
1232
1814
  ...(reclaimRunning ? { reclaimRunning: true } : {}),
1233
1815
  ...(forceRefresh ? { forceRefresh: true } : {}),
1816
+ ...(forceFailedRefresh ? { forceFailedRefresh: true } : {}),
1234
1817
  })
1235
1818
  : await Promise.all(
1236
1819
  uniqueKeys.map((key) =>
@@ -1239,6 +1822,7 @@ export class PlayContextImpl {
1239
1822
  runId,
1240
1823
  reclaimRunning,
1241
1824
  forceRefresh,
1825
+ forceFailedRefresh,
1242
1826
  ),
1243
1827
  ),
1244
1828
  );
@@ -1254,26 +1838,44 @@ export class PlayContextImpl {
1254
1838
  }
1255
1839
 
1256
1840
  private async completeRuntimeStepReceipts(
1257
- receipts: Array<{ key: string; runId: string; output: unknown | null }>,
1841
+ receipts: Array<{
1842
+ key: string;
1843
+ runId: string;
1844
+ runAttempt?: number | null;
1845
+ output: unknown | null;
1846
+ leaseId?: string | null;
1847
+ }>,
1258
1848
  ): Promise<Map<string, RuntimeStepReceipt>> {
1259
1849
  const normalizedInputs = receipts.filter((receipt) => receipt.key.trim());
1260
1850
  if (normalizedInputs.length === 0) return new Map();
1261
1851
  for (const receipt of normalizedInputs) {
1262
1852
  assertNoSecretTaint(receipt.output, 'ctx.tool receipt output');
1263
1853
  }
1264
- const completed = this.#options.completeRuntimeStepReceipts
1265
- ? await this.#options.completeRuntimeStepReceipts({
1266
- receipts: normalizedInputs,
1267
- })
1268
- : await Promise.all(
1269
- normalizedInputs.map((receipt) =>
1270
- this.completeRuntimeStepReceipt(
1271
- receipt.key,
1272
- receipt.runId,
1273
- receipt.output,
1854
+ let completed: Array<RuntimeStepReceipt | null>;
1855
+ try {
1856
+ completed = this.#options.completeRuntimeStepReceipts
1857
+ ? await this.#options.completeRuntimeStepReceipts({
1858
+ receipts: normalizedInputs.map((receipt) => ({
1859
+ ...receipt,
1860
+ runAttempt: receipt.runAttempt ?? this.currentRunAttempt,
1861
+ })),
1862
+ })
1863
+ : await Promise.all(
1864
+ normalizedInputs.map((receipt) =>
1865
+ this.completeRuntimeStepReceipt(
1866
+ receipt.key,
1867
+ receipt.runId,
1868
+ receipt.output,
1869
+ receipt.leaseId,
1870
+ ),
1274
1871
  ),
1275
- ),
1276
- );
1872
+ );
1873
+ } catch (error) {
1874
+ await this.recoverRuntimeStepReceiptCompletionsAfterBulkError(
1875
+ normalizedInputs,
1876
+ );
1877
+ throw error;
1878
+ }
1277
1879
  const byKey = new Map<string, RuntimeStepReceipt>();
1278
1880
  for (let index = 0; index < completed.length; index += 1) {
1279
1881
  const normalized = this.normalizeRuntimeStepReceipt(
@@ -1282,17 +1884,123 @@ export class PlayContextImpl {
1282
1884
  );
1283
1885
  if (normalized) byKey.set(normalized.key, normalized);
1284
1886
  }
1887
+ await this.reconcileMissingRuntimeStepReceiptCompletions(
1888
+ normalizedInputs,
1889
+ byKey,
1890
+ );
1285
1891
  return byKey;
1286
1892
  }
1287
1893
 
1894
+ private async reconcileMissingRuntimeStepReceiptCompletions(
1895
+ receipts: Array<{
1896
+ key: string;
1897
+ runId: string;
1898
+ runAttempt?: number | null;
1899
+ output: unknown | null;
1900
+ leaseId?: string | null;
1901
+ }>,
1902
+ completedByKey: Map<string, RuntimeStepReceipt>,
1903
+ ): Promise<void> {
1904
+ if (
1905
+ receipts.length === completedByKey.size ||
1906
+ (!this.#options.getRuntimeStepReceipt &&
1907
+ !this.#options.getRuntimeStepReceipts)
1908
+ ) {
1909
+ return;
1910
+ }
1911
+ const missingKeys = receipts
1912
+ .map((receipt) => receipt.key.trim())
1913
+ .filter((key) => key && !completedByKey.has(key));
1914
+ if (missingKeys.length === 0) return;
1915
+ const latest = await this.getRuntimeStepReceipts(missingKeys);
1916
+ for (const key of missingKeys) {
1917
+ const recovered = latest.get(key);
1918
+ if (
1919
+ recovered?.status === 'completed' ||
1920
+ recovered?.status === 'skipped'
1921
+ ) {
1922
+ completedByKey.set(key, recovered);
1923
+ }
1924
+ }
1925
+ const recoveredCount = missingKeys.filter((key) =>
1926
+ completedByKey.has(key),
1927
+ ).length;
1928
+ if (recoveredCount > 0) {
1929
+ this.log(
1930
+ `Runtime tool receipt completion response reconciled ${recoveredCount}/${missingKeys.length} missing receipt(s) from durable store read-back.`,
1931
+ );
1932
+ }
1933
+ }
1934
+
1935
+ private async recoverRuntimeStepReceiptCompletionsAfterBulkError(
1936
+ receipts: Array<{
1937
+ key: string;
1938
+ runId: string;
1939
+ runAttempt?: number | null;
1940
+ output: unknown | null;
1941
+ leaseId?: string | null;
1942
+ }>,
1943
+ ): Promise<void> {
1944
+ if (
1945
+ receipts.length === 0 ||
1946
+ (!this.#options.getRuntimeStepReceipt &&
1947
+ !this.#options.getRuntimeStepReceipts)
1948
+ ) {
1949
+ return;
1950
+ }
1951
+ let latest: Map<string, RuntimeStepReceipt>;
1952
+ try {
1953
+ latest = await this.getRuntimeStepReceipts(
1954
+ receipts.map((receipt) => receipt.key),
1955
+ );
1956
+ } catch {
1957
+ return;
1958
+ }
1959
+ await Promise.allSettled(
1960
+ receipts.map(async (receipt) => {
1961
+ const recovered = latest.get(receipt.key);
1962
+ if (
1963
+ recovered?.status === 'completed' ||
1964
+ recovered?.status === 'skipped'
1965
+ ) {
1966
+ return;
1967
+ }
1968
+ if (
1969
+ !this.runtimeToolReceiptStillOwnedByCurrentAttempt(
1970
+ recovered,
1971
+ receipt.leaseId,
1972
+ )
1973
+ ) {
1974
+ return;
1975
+ }
1976
+ await this.completeRuntimeStepReceipt(
1977
+ receipt.key,
1978
+ receipt.runId,
1979
+ receipt.output,
1980
+ receipt.leaseId,
1981
+ );
1982
+ }),
1983
+ );
1984
+ }
1985
+
1288
1986
  private async failRuntimeStepReceipts(
1289
- receipts: Array<{ key: string; runId: string; error: string }>,
1987
+ receipts: Array<{
1988
+ key: string;
1989
+ runId: string;
1990
+ runAttempt?: number | null;
1991
+ error: string;
1992
+ leaseId?: string | null;
1993
+ failureKind?: WorkReceiptFailureKind | null;
1994
+ }>,
1290
1995
  ): Promise<Map<string, RuntimeStepReceipt>> {
1291
1996
  const normalizedInputs = receipts.filter((receipt) => receipt.key.trim());
1292
1997
  if (normalizedInputs.length === 0) return new Map();
1293
1998
  const failed = this.#options.failRuntimeStepReceipts
1294
1999
  ? await this.#options.failRuntimeStepReceipts({
1295
- receipts: normalizedInputs,
2000
+ receipts: normalizedInputs.map((receipt) => ({
2001
+ ...receipt,
2002
+ runAttempt: receipt.runAttempt ?? this.currentRunAttempt,
2003
+ })),
1296
2004
  })
1297
2005
  : await Promise.all(
1298
2006
  normalizedInputs.map((receipt) =>
@@ -1300,6 +2008,8 @@ export class PlayContextImpl {
1300
2008
  receipt.key,
1301
2009
  receipt.runId,
1302
2010
  receipt.error,
2011
+ receipt.leaseId,
2012
+ receipt.failureKind,
1303
2013
  ),
1304
2014
  ),
1305
2015
  );
@@ -1327,45 +2037,98 @@ export class PlayContextImpl {
1327
2037
  );
1328
2038
  }
1329
2039
 
1330
- private async durableToolCallCacheKey(input: {
2040
+ private async durableToolCallCacheKeyForScope(input: {
1331
2041
  toolId: string;
1332
2042
  requestInput: Record<string, unknown>;
2043
+ executionAuthScopeDigest?: string | null;
1333
2044
  staleAfterSeconds?: number | null;
2045
+ playLocalScope?: string | null;
1334
2046
  }): Promise<string> {
1335
2047
  const providerActionVersion =
1336
2048
  (await this.#options.getToolActionCacheVersion?.(input.toolId))?.trim() ??
1337
2049
  '';
2050
+ const executionAuthScopeDigest =
2051
+ input.executionAuthScopeDigest?.trim() ||
2052
+ (await this.resolveToolAuthScopeDigest(input.toolId))?.trim() ||
2053
+ null;
1338
2054
  return buildDurableToolCallCacheKey({
1339
2055
  orgId: this.#options.orgId,
1340
- playId: this.governance.currentPlayId,
2056
+ playLocalScope: input.playLocalScope,
1341
2057
  toolId: input.toolId,
1342
2058
  requestInput: input.requestInput,
1343
2059
  authScopeDigest: buildDurableToolCallAuthScopeDigest({
1344
2060
  orgId: this.#options.orgId,
1345
- userEmail: this.#options.userEmail,
1346
2061
  toolId: input.toolId,
2062
+ executionAuthScopeDigest,
1347
2063
  }),
1348
2064
  providerActionVersion,
1349
2065
  staleAfterSeconds: input.staleAfterSeconds,
1350
2066
  });
1351
2067
  }
1352
2068
 
2069
+ private async durableToolCallCacheKey(input: {
2070
+ toolId: string;
2071
+ requestInput: Record<string, unknown>;
2072
+ executionAuthScopeDigest?: string | null;
2073
+ staleAfterSeconds?: number | null;
2074
+ }): Promise<string> {
2075
+ return await this.durableToolCallCacheKeyForScope({
2076
+ ...input,
2077
+ playLocalScope: this.governance.currentPlayId,
2078
+ });
2079
+ }
2080
+
2081
+ private async resolveToolAuthScopeDigest(
2082
+ toolId: string,
2083
+ ): Promise<string | null> {
2084
+ const configured = await this.#options.getToolAuthScopeDigest?.(toolId);
2085
+ if (typeof configured === 'string' && configured.trim()) {
2086
+ return configured.trim();
2087
+ }
2088
+ return null;
2089
+ }
2090
+
2091
+ private invalidateToolAuthScopeDigest(toolId: string): void {
2092
+ this.#options.invalidateToolAuthScopeDigest?.(toolId);
2093
+ }
2094
+
1353
2095
  private durableReceiptExecutionStore(): DurableReceiptExecutionStore {
1354
2096
  return {
1355
2097
  enabled: Boolean(this.#options.claimRuntimeStepReceipt),
1356
2098
  get: (receiptKey) => this.getRuntimeStepReceipt(receiptKey),
1357
2099
  getMany: (receiptKeys) => this.getRuntimeStepReceipts(receiptKeys),
1358
- claim: (receiptKey, runId, reclaimRunning, forceRefresh) =>
2100
+ claim: (
2101
+ receiptKey,
2102
+ runId,
2103
+ reclaimRunning,
2104
+ forceRefresh,
2105
+ forceFailedRefresh,
2106
+ ) =>
1359
2107
  this.claimRuntimeStepReceipt(
1360
2108
  receiptKey,
1361
2109
  runId,
1362
2110
  reclaimRunning,
1363
2111
  forceRefresh,
2112
+ forceFailedRefresh,
2113
+ ),
2114
+ complete: (receiptKey, runId, output, leaseId) =>
2115
+ this.completeRuntimeStepReceipt(receiptKey, runId, output, leaseId),
2116
+ release: (receiptKey, runId, leaseId) =>
2117
+ this.releaseRuntimeStepReceipt(receiptKey, runId, leaseId),
2118
+ ...(this.#options.heartbeatRuntimeStepReceipts
2119
+ ? {
2120
+ heartbeat: (receiptKey, runId, leaseId) =>
2121
+ this.heartbeatRuntimeStepReceipt(receiptKey, runId, leaseId),
2122
+ }
2123
+ : {}),
2124
+ fail: (receiptKey, runId, error, leaseId, failureKind) =>
2125
+ this.failRuntimeStepReceipt(
2126
+ receiptKey,
2127
+ runId,
2128
+ error,
2129
+ leaseId,
2130
+ failureKind,
1364
2131
  ),
1365
- complete: (receiptKey, runId, output) =>
1366
- this.completeRuntimeStepReceipt(receiptKey, runId, output),
1367
- fail: (receiptKey, runId, error) =>
1368
- this.failRuntimeStepReceipt(receiptKey, runId, error),
1369
2132
  canPersistFailure: Boolean(this.#options.failRuntimeStepReceipt),
1370
2133
  canPersistCompletion: Boolean(this.#options.completeRuntimeStepReceipt),
1371
2134
  };
@@ -1407,7 +2170,8 @@ export class PlayContextImpl {
1407
2170
  source: DurableReceiptRecoverySource,
1408
2171
  ) => T;
1409
2172
  onClaimedResult?: (output: T, receiptKey: string) => T;
1410
- execute: () => Promise<T>;
2173
+ shouldPersistFailure?: (error: unknown) => boolean;
2174
+ execute: (context: { leaseId: string | null }) => Promise<T>;
1411
2175
  },
1412
2176
  ): Promise<T> {
1413
2177
  const receiptKey =
@@ -1436,6 +2200,7 @@ export class PlayContextImpl {
1436
2200
  markSkipped: opts.markSkipped,
1437
2201
  onRecovered: opts.onRecovered,
1438
2202
  onClaimedResult: opts.onClaimedResult,
2203
+ shouldPersistFailure: opts.shouldPersistFailure,
1439
2204
  formatError: (error) => this.formatRuntimeError(error),
1440
2205
  log: (message) => this.log(message),
1441
2206
  execute: opts.execute,
@@ -1446,6 +2211,13 @@ export class PlayContextImpl {
1446
2211
  return this.#options.runId ?? this.governance.currentRunId ?? 'unknown';
1447
2212
  }
1448
2213
 
2214
+ private get currentRunAttempt(): number {
2215
+ const attempt = this.#options.runAttempt;
2216
+ return typeof attempt === 'number' && Number.isFinite(attempt)
2217
+ ? Math.max(0, Math.floor(attempt))
2218
+ : 0;
2219
+ }
2220
+
1449
2221
  private emitScopedFieldMetaUpdate(input: {
1450
2222
  rowId: number;
1451
2223
  key: string | null;
@@ -1659,12 +2431,17 @@ export class PlayContextImpl {
1659
2431
 
1660
2432
  private effectiveToolCallCachePolicy(options?: ToolCallOptions): {
1661
2433
  force: boolean;
2434
+ forceFailedRefresh: boolean;
1662
2435
  staleAfterSeconds?: number | null;
1663
2436
  } {
1664
2437
  return {
1665
2438
  force:
1666
2439
  options?.force === true ||
1667
2440
  this.#options.cachePolicy?.forceToolRefresh === true,
2441
+ forceFailedRefresh:
2442
+ options?.force === true ||
2443
+ this.#options.cachePolicy?.forceToolRefresh === true ||
2444
+ this.#options.cachePolicy?.forceFailedToolRefresh === true,
1668
2445
  staleAfterSeconds: options?.staleAfterSeconds ?? null,
1669
2446
  };
1670
2447
  }
@@ -1685,6 +2462,19 @@ export class PlayContextImpl {
1685
2462
  return this.checkpoint.completedToolBatches[toolId]?.[rowCacheKey];
1686
2463
  }
1687
2464
 
2465
+ private getCachedToolResultCandidate(
2466
+ toolId: string,
2467
+ rowCacheKeys: readonly string[],
2468
+ ): { cacheKey: string; result: ToolBatchResult } | null {
2469
+ for (const rowCacheKey of rowCacheKeys) {
2470
+ const cached = this.getCachedToolResult(toolId, rowCacheKey);
2471
+ if (cached?.done) {
2472
+ return { cacheKey: rowCacheKey, result: cached };
2473
+ }
2474
+ }
2475
+ return null;
2476
+ }
2477
+
1688
2478
  private cacheToolResult(
1689
2479
  toolId: string,
1690
2480
  rowCacheKey: string,
@@ -1914,6 +2704,7 @@ export class PlayContextImpl {
1914
2704
  {
1915
2705
  key: receiptKey,
1916
2706
  runId: this.currentRunId,
2707
+ leaseId: request.receiptLeaseId ?? null,
1917
2708
  output: serializeToolExecuteResult(wrapped),
1918
2709
  },
1919
2710
  ])
@@ -1969,12 +2760,13 @@ export class PlayContextImpl {
1969
2760
  {
1970
2761
  key: receiptKey,
1971
2762
  runId: this.currentRunId,
2763
+ leaseId: entry.request.receiptLeaseId ?? null,
1972
2764
  output: serializeToolExecuteResult(entry.wrapped),
1973
2765
  },
1974
2766
  ]
1975
2767
  : [];
1976
2768
  });
1977
- let completedByKey: Map<string, RuntimeStepReceipt>;
2769
+ let completedByKey = new Map<string, RuntimeStepReceipt>();
1978
2770
  if (receiptInputs.length > 0) {
1979
2771
  try {
1980
2772
  completedByKey = await this.completeRuntimeStepReceipts(receiptInputs);
@@ -1982,10 +2774,15 @@ export class PlayContextImpl {
1982
2774
  // Completion failed AFTER successful (billed) tool calls: trip the
1983
2775
  // breaker so remaining rows stop dispatching new provider calls.
1984
2776
  tripRuntimePersistenceLatch(this.persistenceLatch, receiptError);
2777
+ await Promise.all(
2778
+ wrappedEntries.map((entry) =>
2779
+ this.rejectToolCall(toolId, entry.request, receiptError, {
2780
+ persistReceiptFailure: false,
2781
+ }),
2782
+ ),
2783
+ );
1985
2784
  throw receiptError;
1986
2785
  }
1987
- } else {
1988
- completedByKey = new Map<string, RuntimeStepReceipt>();
1989
2786
  }
1990
2787
  const results: unknown[] = [];
1991
2788
  for (const entry of wrappedEntries) {
@@ -2009,9 +2806,23 @@ export class PlayContextImpl {
2009
2806
  completed: RuntimeStepReceipt | null | undefined,
2010
2807
  ): Promise<unknown> {
2011
2808
  const cacheKey = request.cacheKey;
2809
+ const receiptKey = request.receiptKey?.trim() || null;
2810
+ const canPersistReceiptCompletion = Boolean(
2811
+ this.#options.completeRuntimeStepReceipt ||
2812
+ this.#options.completeRuntimeStepReceipts,
2813
+ );
2814
+ if (
2815
+ receiptKey &&
2816
+ canPersistReceiptCompletion &&
2817
+ completed?.status !== 'completed' &&
2818
+ completed?.status !== 'skipped'
2819
+ ) {
2820
+ throw new Error(
2821
+ `Durable tool call ${receiptKey} lost receipt ownership before completion.`,
2822
+ );
2823
+ }
2012
2824
  const finalWrapped =
2013
- (completed?.status === 'completed' || completed?.status === 'skipped') &&
2014
- completed.runId !== this.currentRunId
2825
+ completed?.status === 'completed' || completed?.status === 'skipped'
2015
2826
  ? await this.wrapToolExecutionResult({
2016
2827
  toolId,
2017
2828
  status:
@@ -2030,8 +2841,8 @@ export class PlayContextImpl {
2030
2841
  : {
2031
2842
  kind: 'cache',
2032
2843
  cacheKey,
2033
- receiptKey: request.receiptKey ?? '',
2034
- attachedToReceiptKey: request.receiptKey,
2844
+ receiptKey: receiptKey ?? '',
2845
+ attachedToReceiptKey: receiptKey,
2035
2846
  },
2036
2847
  ),
2037
2848
  })
@@ -2076,7 +2887,9 @@ export class PlayContextImpl {
2076
2887
  {
2077
2888
  key: receiptKey,
2078
2889
  runId: this.currentRunId,
2890
+ leaseId: request.receiptLeaseId ?? null,
2079
2891
  error: message,
2892
+ failureKind: runtimeReceiptFailureKindForError(error),
2080
2893
  },
2081
2894
  ]);
2082
2895
  } catch (receiptError) {
@@ -2360,6 +3173,7 @@ export class PlayContextImpl {
2360
3173
  this.toOutputRow(item as Record<string, unknown>),
2361
3174
  );
2362
3175
  itemOriginalIndexes = materializedItems.map((_item, index) => index);
3176
+ const mapStartSeedRowsByKey = new Map<string, Record<string, unknown>>();
2363
3177
  if (this.#options.onMapStart) {
2364
3178
  const shouldPassRowKey = explicitKeyResolver != null;
2365
3179
  // toSerializableCsvAliasedRow (not a plain spread): projected CSV
@@ -2381,6 +3195,7 @@ export class PlayContextImpl {
2381
3195
  playName: this.#options.playName,
2382
3196
  playId: this.#options.playId,
2383
3197
  runId: this.#options.runId,
3198
+ executorToken: this.#options.executorToken,
2384
3199
  staticPipeline: this.#options.staticPipeline,
2385
3200
  forceRefresh: this.#options.cachePolicy?.forceToolRefresh === true,
2386
3201
  },
@@ -2388,13 +3203,14 @@ export class PlayContextImpl {
2388
3203
  resolvedTableNamespace = normalizeTableNamespace(
2389
3204
  mapStartResult.tableNamespace,
2390
3205
  );
3206
+ if ((mapStartResult.blockedRows?.length ?? 0) > 0) {
3207
+ throw new RuntimeSheetRowsBlockedError({
3208
+ tableNamespace: resolvedTableNamespace,
3209
+ blockedRows: mapStartResult.blockedRows ?? [],
3210
+ });
3211
+ }
2391
3212
  const persistedRowIdentity = (row: Record<string, unknown>, index = 0) =>
2392
3213
  resolveMapRowOutcomeKey(row) ?? rowIdentity(row, index);
2393
- const pendingKeys = new Set(
2394
- mapStartResult.pendingRows.map((row, index) =>
2395
- persistedRowIdentity(row, index),
2396
- ),
2397
- );
2398
3214
  const pendingRowsByKey = new Map<string, Record<string, unknown>>();
2399
3215
  for (
2400
3216
  let index = 0;
@@ -2405,15 +3221,20 @@ export class PlayContextImpl {
2405
3221
  const rowKey = persistedRowIdentity(row, index);
2406
3222
  if (rowKey) pendingRowsByKey.set(rowKey, row);
2407
3223
  }
2408
- const pendingItems = materializedItems
3224
+ for (const row of mapStartResult.completedRows ?? []) {
3225
+ const rowKey = persistedRowIdentity(row);
3226
+ if (!rowKey) continue;
3227
+ mapStartSeedRowsByKey.set(rowKey, row);
3228
+ }
3229
+ const seededItems = materializedItems
2409
3230
  .map((item, index) => ({
2410
3231
  row: this.toOutputRow(item as Record<string, unknown>),
2411
3232
  index,
2412
3233
  }))
2413
- .filter(({ row, index }) => pendingKeys.has(rowIdentity(row, index)))
2414
3234
  .map(({ row, index }) => {
2415
3235
  const rowKey = rowIdentity(row, index);
2416
- const persisted = pendingRowsByKey.get(rowKey);
3236
+ const persisted =
3237
+ pendingRowsByKey.get(rowKey) ?? mapStartSeedRowsByKey.get(rowKey);
2417
3238
  // Persisted rows lose the non-enumerable CSV alias projections on
2418
3239
  // the sheet round-trip. Merge the persisted data/meta over the
2419
3240
  // in-memory row so key functions and column resolvers keep seeing
@@ -2423,8 +3244,8 @@ export class PlayContextImpl {
2423
3244
  index,
2424
3245
  };
2425
3246
  });
2426
- itemsToProcess = pendingItems.map((item) => item.row);
2427
- itemOriginalIndexes = pendingItems.map((item) => item.index);
3247
+ itemsToProcess = seededItems.map((item) => item.row);
3248
+ itemOriginalIndexes = seededItems.map((item) => item.index);
2428
3249
  }
2429
3250
 
2430
3251
  const mapScope = this.createMapExecutionScope({
@@ -2495,6 +3316,7 @@ export class PlayContextImpl {
2495
3316
  playName: this.#options.playName,
2496
3317
  playId: this.#options.playId,
2497
3318
  runId: this.#options.runId,
3319
+ executorToken: this.#options.executorToken,
2498
3320
  tableNamespace: resolvedTableNamespace,
2499
3321
  rows: chunk,
2500
3322
  outputFields: datasetColumnNames.filter((field) =>
@@ -2580,7 +3402,7 @@ export class PlayContextImpl {
2580
3402
  const directCompletedResults = mapResult.completedRows.map((row) =>
2581
3403
  this.toPublicOutputRow(row.data),
2582
3404
  );
2583
- const results =
3405
+ let results =
2584
3406
  mapResult.failedRows.length === 0 &&
2585
3407
  directCompletedResults.length === rawItems.length
2586
3408
  ? directCompletedResults
@@ -2633,6 +3455,49 @@ export class PlayContextImpl {
2633
3455
  }
2634
3456
  this.activeMapCellMeta = null;
2635
3457
 
3458
+ if (this.#options.onMapRowsCompleted) {
3459
+ results = await reconcileNodeRuntimeMapResultsWithPersistedSheet({
3460
+ mapName: normalizedMapNamespace,
3461
+ tableNamespace: resolvedTableNamespace,
3462
+ runId: this.#options.runId,
3463
+ expectedRows: totalInputCount,
3464
+ currentRows: results,
3465
+ failedRowCount: mapResult.failedRows.length,
3466
+ log: (line) => this.log(line),
3467
+ readPersistedRows: async (readInput) => {
3468
+ if (
3469
+ !this.#options.baseUrl ||
3470
+ !this.#options.executorToken ||
3471
+ !this.#options.playName ||
3472
+ !this.#options.runId
3473
+ ) {
3474
+ throw new Error(
3475
+ `Runtime sheet finalization mismatch for ctx.dataset("${normalizedMapNamespace}"): ` +
3476
+ 'cannot verify terminal persisted rows because the Node runtime context is missing ' +
3477
+ 'baseUrl, executorToken, playName, or runId.',
3478
+ );
3479
+ }
3480
+ const result = await readRuntimeSheetDatasetRows(
3481
+ {
3482
+ baseUrl: this.#options.baseUrl,
3483
+ executorToken: this.#options.executorToken,
3484
+ playName: this.#options.playName,
3485
+ userEmail: this.#options.userEmail,
3486
+ runId: this.#options.runId,
3487
+ },
3488
+ {
3489
+ tableNamespace: resolvedTableNamespace,
3490
+ runId: this.#options.runId,
3491
+ rowMode: readInput.rowMode,
3492
+ limit: readInput.limit,
3493
+ offset: readInput.offset,
3494
+ },
3495
+ );
3496
+ return result.rows;
3497
+ },
3498
+ });
3499
+ }
3500
+
2636
3501
  return createDeferredPlayDataset({
2637
3502
  datasetKind: 'map',
2638
3503
  datasetId: createRuntimeDatasetId(
@@ -2909,7 +3774,7 @@ export class PlayContextImpl {
2909
3774
  this.lastDatasetStep = datasetStep;
2910
3775
  this.activeDatasetStep = null;
2911
3776
  this.log(
2912
- `Map completed: ${results.length + completedRows} results (${results.length} executed, ${completedRows} duplicate keys skipped)`,
3777
+ `Map completed: ${results.length + completedRows} results (${results.length} succeeded, 0 failed, ${completedRows} duplicate keys skipped)`,
2913
3778
  );
2914
3779
  return {
2915
3780
  completedRows: results.map((row, index) =>
@@ -3183,6 +4048,17 @@ export class PlayContextImpl {
3183
4048
  }
3184
4049
  return result.value;
3185
4050
  });
4051
+ const accountedRowIndexes = new Set<number>();
4052
+ for (const row of completedRowsToPersist) {
4053
+ if (typeof row.inputIndex === 'number') {
4054
+ accountedRowIndexes.add(row.inputIndex);
4055
+ }
4056
+ }
4057
+ for (const row of failedRowsToPersist) {
4058
+ if (typeof row.inputIndex === 'number') {
4059
+ accountedRowIndexes.add(row.inputIndex);
4060
+ }
4061
+ }
3186
4062
  if (this.pendingRowEventBoundaries.length > 0) {
3187
4063
  const completedRowKeys = new Set<string>();
3188
4064
  for (let index = 0; index < settledResults.length; index += 1) {
@@ -3195,6 +4071,7 @@ export class PlayContextImpl {
3195
4071
  if (key) {
3196
4072
  completedRowKeys.add(key);
3197
4073
  }
4074
+ accountedRowIndexes.add(executionRowIndex(index));
3198
4075
  continue;
3199
4076
  }
3200
4077
  const row = result as Record<string, unknown>;
@@ -3238,6 +4115,19 @@ export class PlayContextImpl {
3238
4115
  boundaries: uniqueBoundaries,
3239
4116
  });
3240
4117
  }
4118
+ if (accountedRowIndexes.size !== items.length) {
4119
+ const expectedRowIndexes = items.map((_item, index) =>
4120
+ executionRowIndex(index),
4121
+ );
4122
+ const missingRowIndexes = expectedRowIndexes.filter(
4123
+ (index) => !accountedRowIndexes.has(index),
4124
+ );
4125
+ throw new Error(
4126
+ `Runtime map execution accounting mismatch for ctx.dataset("${normalizedTableNamespace}"): ` +
4127
+ `expected ${items.length} row(s), accounted for ${accountedRowIndexes.size}; ` +
4128
+ `missing input index(es): ${missingRowIndexes.slice(0, 10).join(', ') || 'unknown'}.`,
4129
+ );
4130
+ }
3241
4131
  const results = settledResults.filter(
3242
4132
  (result): result is Record<string, unknown> =>
3243
4133
  result !== WAITING_ROW && result !== FAILED_ROW,
@@ -3249,8 +4139,18 @@ export class PlayContextImpl {
3249
4139
  emitEventType: 'map.completed',
3250
4140
  });
3251
4141
  }
4142
+ for (const failedRow of failedRowsToPersist.slice(0, 3)) {
4143
+ this.log(
4144
+ `row ${failedMapRowLogLabel(failedRow)} failed: ${failedRow.error ?? 'unknown error'}`,
4145
+ );
4146
+ }
4147
+ if (failedRowsToPersist.length > 3) {
4148
+ this.log(
4149
+ `${failedRowsToPersist.length - 3} additional row failure(s) omitted from map log`,
4150
+ );
4151
+ }
3252
4152
  this.log(
3253
- `Map completed: ${results.length + completedRows} results (${results.length} executed, ${completedRows} duplicate keys skipped)`,
4153
+ `Map completed: ${results.length + completedRows} results (${results.length} succeeded, ${failedRowsToPersist.length} failed, ${completedRows} duplicate keys skipped)`,
3254
4154
  );
3255
4155
  return {
3256
4156
  completedRows: [...completedRowsToPersist].sort(
@@ -3560,6 +4460,13 @@ export class PlayContextImpl {
3560
4460
  if (!hasPendingWork) {
3561
4461
  const status = await raceSettled();
3562
4462
  if (status === 'done' && this.toolCallQueue.length === 0) {
4463
+ if (!this.activeDatasetStep) {
4464
+ const childPlaySuspension =
4465
+ this.consumePendingChildPlaySuspension();
4466
+ if (childPlaySuspension) {
4467
+ throw new PlayExecutionSuspendedError(childPlaySuspension);
4468
+ }
4469
+ }
3563
4470
  break;
3564
4471
  }
3565
4472
 
@@ -3656,18 +4563,23 @@ export class PlayContextImpl {
3656
4563
  toolId,
3657
4564
  requestInput: input,
3658
4565
  });
4566
+ let executionAuthScopeDigest =
4567
+ (await this.resolveToolAuthScopeDigest(toolId))?.trim() ?? null;
3659
4568
  const cacheableToolResult = !isQueryResultDatasetReadRequest(toolId, input);
3660
- const durableCacheKey = await this.durableToolCallCacheKey({
4569
+ let durableCacheKey = await this.durableToolCallCacheKey({
3661
4570
  toolId,
3662
4571
  requestInput: input,
4572
+ executionAuthScopeDigest,
3663
4573
  staleAfterSeconds: toolCachePolicy.staleAfterSeconds,
3664
4574
  });
3665
- const durableReceiptKey = cacheableToolResult ? durableCacheKey : null;
4575
+ const checkpointCacheKeys = [durableCacheKey];
3666
4576
  const eventWaitHandler =
3667
4577
  (await this.#options.getIntegrationEventWaitHandler?.(toolId)) ?? null;
3668
4578
  const store = rowContext.getStore();
3669
4579
 
3670
- const executeTool = async (): Promise<unknown> => {
4580
+ const executeTool = async (context?: {
4581
+ leaseId?: string | null;
4582
+ }): Promise<unknown> => {
3671
4583
  if (eventWaitHandler) {
3672
4584
  return this.executeIntegrationEventWaitTool(
3673
4585
  toolId,
@@ -3694,17 +4606,17 @@ export class PlayContextImpl {
3694
4606
  ? null
3695
4607
  : toolCachePolicy.force
3696
4608
  ? null
3697
- : this.getCachedToolResult(toolId, directCacheKey);
3698
- if (cached?.done) {
3699
- this.log(`Tool cache hit: ${toolId} exact payload`);
4609
+ : this.getCachedToolResultCandidate(toolId, checkpointCacheKeys);
4610
+ if (cached) {
4611
+ this.log(`Calling tool: ${toolId} recovered from checkpoint`);
3700
4612
  return await this.wrapToolExecutionResult({
3701
4613
  toolId,
3702
- status: cached.result == null ? 'no_result' : 'completed',
3703
- result: cached.result,
4614
+ status: cached.result.result == null ? 'no_result' : 'completed',
4615
+ result: cached.result.result,
3704
4616
  requestInput: input,
3705
4617
  execution: toolExecutionMetadataForOutcome({
3706
4618
  kind: 'checkpoint',
3707
- cacheKey: directCacheKey,
4619
+ cacheKey: cached.cacheKey,
3708
4620
  }),
3709
4621
  });
3710
4622
  }
@@ -3714,6 +4626,17 @@ export class PlayContextImpl {
3714
4626
  : `Calling tool: ${toolId}`,
3715
4627
  );
3716
4628
  const execution = await this.callToolExecutionAPI(toolId, input, {
4629
+ ...(cacheableToolResult
4630
+ ? {
4631
+ durableCallReceiptKey: directCacheKey,
4632
+ executionAuthScopeDigest,
4633
+ providerIdempotencyKey: this.providerIdempotencyKeyForToolCall({
4634
+ cacheKey: directCacheKey,
4635
+ force: toolCachePolicy.force,
4636
+ leaseId: context?.leaseId ?? null,
4637
+ }),
4638
+ }
4639
+ : {}),
3717
4640
  timeoutMs: resolveToolRuntimeTimeoutMs(toolId, options?.timeoutMs),
3718
4641
  });
3719
4642
  const wrapped = await this.wrapToolExecutionResult({
@@ -3763,17 +4686,17 @@ export class PlayContextImpl {
3763
4686
  ? null
3764
4687
  : toolCachePolicy.force
3765
4688
  ? null
3766
- : this.getCachedToolResult(toolId, toolResultCacheKey);
3767
- if (cached?.done) {
3768
- this.log(` Row ${rowId} ${toolId}: cache hit exact payload`);
4689
+ : this.getCachedToolResultCandidate(toolId, checkpointCacheKeys);
4690
+ if (cached) {
4691
+ this.log(` Row ${rowId} ${toolId}: recovered from checkpoint`);
3769
4692
  return await this.wrapToolExecutionResult({
3770
4693
  toolId,
3771
- status: cached.result == null ? 'no_result' : 'completed',
3772
- result: cached.result,
4694
+ status: cached.result.result == null ? 'no_result' : 'completed',
4695
+ result: cached.result.result,
3773
4696
  requestInput: input,
3774
4697
  execution: toolExecutionMetadataForOutcome({
3775
4698
  kind: 'checkpoint',
3776
- cacheKey: toolResultCacheKey,
4699
+ cacheKey: cached.cacheKey,
3777
4700
  }),
3778
4701
  });
3779
4702
  }
@@ -3806,8 +4729,10 @@ export class PlayContextImpl {
3806
4729
  callId,
3807
4730
  cacheKey: toolResultCacheKey,
3808
4731
  cacheable: cacheableToolResult,
3809
- receiptKey: durableReceiptKey,
4732
+ receiptKey: cacheableToolResult ? durableCacheKey : null,
4733
+ executionAuthScopeDigest,
3810
4734
  force: toolCachePolicy.force,
4735
+ forceFailedRefresh: toolCachePolicy.forceFailedRefresh,
3811
4736
  rowId,
3812
4737
  fieldName,
3813
4738
  toolId,
@@ -3827,41 +4752,81 @@ export class PlayContextImpl {
3827
4752
  return await executeTool();
3828
4753
  }
3829
4754
 
3830
- return this.executeWithRuntimeReceipt(
3831
- 'tool',
3832
- normalizedKey,
3833
- this.currentRunId,
3834
- {
3835
- receiptKey: durableReceiptKey,
3836
- semanticKey: toolRequestIdentity,
3837
- force: toolCachePolicy.force,
3838
- staleAfterSeconds: toolCachePolicy.staleAfterSeconds,
3839
- markSkipped: () => {
3840
- this.log(
3841
- `Tool cache hit: ${toolId} exact payload (${normalizedKey}:${toolRequestIdentity})`,
3842
- );
3843
- },
3844
- onClaimedResult: (output, receiptKey) =>
3845
- markToolExecuteResultExecutionOutcome(output, {
3846
- kind: 'live',
3847
- receiptKey,
3848
- }),
3849
- onRecovered: (output, _receipt, source) =>
3850
- markToolExecuteResultExecutionOutcome(
3851
- output,
3852
- toolExecutionOutcomeForDurableReceipt({
3853
- source,
3854
- receiptKey: durableCacheKey,
3855
- }),
3856
- ),
3857
- runningReceiptWaitMaxAttempts: resolveRuntimeToolReceiptWaitMaxAttempts(
3858
- typeof options?.receiptWaitMs === 'number'
3859
- ? { max_wait_ms: options.receiptWaitMs }
3860
- : input,
3861
- ),
3862
- execute: executeTool,
3863
- },
3864
- );
4755
+ for (
4756
+ let authScopeAttempt = 0;
4757
+ authScopeAttempt < 2;
4758
+ authScopeAttempt += 1
4759
+ ) {
4760
+ try {
4761
+ return await this.executeWithRuntimeReceipt(
4762
+ 'tool',
4763
+ normalizedKey,
4764
+ this.currentRunId,
4765
+ {
4766
+ receiptKey: durableCacheKey,
4767
+ semanticKey: toolRequestIdentity,
4768
+ force: toolCachePolicy.force,
4769
+ staleAfterSeconds: toolCachePolicy.staleAfterSeconds,
4770
+ markSkipped: () => {
4771
+ this.log(
4772
+ `ctx.tools.execute(${toolId}): no-op due completed receipt ${toolRequestIdentity} (label: ${normalizedKey})`,
4773
+ );
4774
+ },
4775
+ onClaimedResult: (output, receiptKey) =>
4776
+ markToolExecuteResultExecutionOutcome(output, {
4777
+ kind: 'live',
4778
+ receiptKey,
4779
+ }),
4780
+ onRecovered: (output, _receipt, source) =>
4781
+ markToolExecuteResultExecutionOutcome(
4782
+ output,
4783
+ toolExecutionOutcomeForDurableReceipt({
4784
+ source,
4785
+ receiptKey: durableCacheKey,
4786
+ }),
4787
+ ),
4788
+ shouldPersistFailure: (error) =>
4789
+ !(error instanceof ToolExecuteAuthScopeChangedError),
4790
+ execute: ({ leaseId }) => executeTool({ leaseId }),
4791
+ runningReceiptWaitMaxAttempts:
4792
+ resolveRuntimeToolReceiptWaitMaxAttempts(
4793
+ typeof options?.receiptWaitMs === 'number'
4794
+ ? { max_wait_ms: options.receiptWaitMs }
4795
+ : input,
4796
+ ),
4797
+ },
4798
+ );
4799
+ } catch (error) {
4800
+ if (
4801
+ !(error instanceof ToolExecuteAuthScopeChangedError) ||
4802
+ authScopeAttempt > 0
4803
+ ) {
4804
+ throw error;
4805
+ }
4806
+ // Make the bounded auth-scope re-claim observable in run logs instead
4807
+ // of a silent continuation: the credential identity changed after the
4808
+ // receipt key was prepared, so we evict the cached digest, re-resolve,
4809
+ // and re-claim a fresh receipt under the new scope before retrying once.
4810
+ this.log(
4811
+ `ctx.tools.execute(${toolId}): auth_scope_changed_reclaim ` +
4812
+ `(label: ${normalizedKey}); credential identity changed mid-run, ` +
4813
+ `re-resolving auth scope and re-claiming a fresh receipt under the ` +
4814
+ `new scope (attempt ${authScopeAttempt + 1}/2).`,
4815
+ );
4816
+ this.invalidateToolAuthScopeDigest(toolId);
4817
+ executionAuthScopeDigest =
4818
+ (await this.resolveToolAuthScopeDigest(toolId))?.trim() ?? null;
4819
+ durableCacheKey = await this.durableToolCallCacheKey({
4820
+ toolId,
4821
+ requestInput: input,
4822
+ executionAuthScopeDigest,
4823
+ staleAfterSeconds: toolCachePolicy.staleAfterSeconds,
4824
+ });
4825
+ checkpointCacheKeys[0] = durableCacheKey;
4826
+ }
4827
+ }
4828
+
4829
+ throw new ToolExecuteAuthScopeChangedError();
3865
4830
  }
3866
4831
 
3867
4832
  private async executeIntegrationEventWaitTool(
@@ -3987,163 +4952,349 @@ export class PlayContextImpl {
3987
4952
  if (!resolvedName.trim()) {
3988
4953
  throw new Error('ctx.runPlay(...) requires a resolvable play name.');
3989
4954
  }
4955
+ const scheduledChildPlayCall = this.shouldLaunchChildPlaysThroughScheduler()
4956
+ ? this.beginScheduledChildPlayCall()
4957
+ : null;
3990
4958
 
3991
- if (!this.#options.resolvePlay) {
3992
- throw new Error(
3993
- 'ctx.runPlay(...) is unavailable because no play resolver was configured.',
3994
- );
3995
- }
3996
- const resolvePlay = this.#options.resolvePlay;
3997
- const resolvedPlay = await resolvePlay(resolvedName);
3998
- if (!resolvedPlay) {
3999
- throw new Error(
4000
- `Unable to resolve play "${resolvedName}" for ctx.runPlay(...).`,
4001
- );
4002
- }
4003
- const childRevisionFingerprint =
4004
- resolvedPlayRevisionFingerprint(resolvedPlay);
4005
-
4006
- const store = rowContext.getStore();
4007
- const executePlayCall = async (): Promise<TOutput> => {
4008
- // Reserve depth + per-parent + descendant/play-call budget on the parent
4009
- // and fork the lineage-global snapshot for the child. forkChild owns the
4010
- // cycle guard, depth cap, per-parent cap, and play/descendant charges, so
4011
- // a recovered receipt (which skips this body) never consumes budget. The
4012
- // child run id stays deterministic — it matches the prior post-increment
4013
- // playCallCount — so receipt keys and replay are stable.
4014
- const childRunId = `${this.governance.rootRunId}:${resolvedName}:${this.governor.snapshot().playCallCount + 1}`;
4015
- const childGovernance = this.governor.forkChild({
4959
+ try {
4960
+ if (!this.#options.resolvePlay) {
4961
+ throw new Error(
4962
+ 'ctx.runPlay(...) is unavailable because no play resolver was configured.',
4963
+ );
4964
+ }
4965
+ const resolvePlay = this.#options.resolvePlay;
4966
+ const resolvedPlay = await resolvePlay(resolvedName);
4967
+ if (!resolvedPlay) {
4968
+ throw new Error(
4969
+ `Unable to resolve play "${resolvedName}" for ctx.runPlay(...).`,
4970
+ );
4971
+ }
4972
+ const childRevisionFingerprint =
4973
+ resolvedPlayRevisionFingerprint(resolvedPlay);
4974
+ const runPlayRowScope = rowContext.getStore();
4975
+ const runPlaySemanticKey = buildDurableRunPlaySemanticKey({
4016
4976
  childPlayName: resolvedName,
4017
- childRunId,
4977
+ childRevisionFingerprint,
4978
+ input,
4979
+ rowScope: runPlayRowScope
4980
+ ? {
4981
+ fieldName: runPlayRowScope.fieldName,
4982
+ rowId: runPlayRowScope.rowId,
4983
+ rowKey: runPlayRowScope.rowKey ?? null,
4984
+ tableNamespace: runPlayRowScope.tableNamespace ?? null,
4985
+ }
4986
+ : null,
4018
4987
  });
4019
- const childPlaySlot = await this.governor.acquireChildPlaySlot();
4020
- const rowStore = rowContext.getStore();
4021
- const producer = {
4022
- kind: 'play' as const,
4023
- id: normalizedKey,
4024
- playId: resolvedName,
4025
- displayName: displayNameFromProducerId(resolvedName),
4026
- runId: childRunId,
4027
- };
4028
4988
 
4029
- try {
4030
- if (rowStore) {
4031
- this.emitScopedFieldMetaUpdate({
4032
- rowId: rowStore.rowId,
4033
- key: rowStore.rowKey ?? null,
4034
- tableNamespace: rowStore.tableNamespace ?? null,
4035
- fieldName: rowStore.fieldName,
4036
- status: 'running',
4037
- rowStatus: 'running',
4038
- stage: resolvedName,
4039
- provider: 'deepline_native',
4040
- error: null,
4041
- producer,
4042
- dataPatch: {},
4043
- });
4044
- }
4045
- const childContext = createPlayContext({
4046
- ...this.#options,
4047
- playId: resolvedName,
4048
- playName: resolvedName,
4049
- runId: childGovernance.currentRunId,
4050
- staticPipeline: resolvedPlay.staticPipeline ?? null,
4051
- checkpoint: this.checkpoint,
4052
- governance: childGovernance,
4053
- getRuntimeStepReceipt: undefined,
4054
- getRuntimeStepReceipts: undefined,
4055
- claimRuntimeStepReceipt: undefined,
4056
- claimRuntimeStepReceipts: undefined,
4057
- completeRuntimeStepReceipt: undefined,
4058
- completeRuntimeStepReceipts: undefined,
4059
- failRuntimeStepReceipt: undefined,
4060
- failRuntimeStepReceipts: undefined,
4061
- skipRuntimeStepReceipt: undefined,
4062
- });
4063
- const childExecution = this.executeResolvedPlay(
4064
- resolvedPlay,
4065
- childContext,
4989
+ const executePlayCall = async (): Promise<TOutput> => {
4990
+ // Reserve depth + per-parent + descendant/play-call budget on the parent
4991
+ // and fork the lineage-global snapshot for the child. forkChild owns the
4992
+ // cycle guard, depth cap, per-parent cap, and play/descendant charges, so
4993
+ // a recovered receipt (which skips this body) never consumes budget.
4994
+ //
4995
+ // The child run id is derived through the shared canonical builder so the
4996
+ // in-process runtime and the workers_edge coordinator produce the same
4997
+ // FORMAT (`play/<slug>/run/child-<digest>`) via the same code. The digest
4998
+ // is deterministic across replay because every input (parent lineage,
4999
+ // call key, input) is stable. Per-substrate difference: in-process has no
5000
+ // coordinator idempotency key and no compiled child graphHash, so it uses
5001
+ // the semantic fallback with graphHash omitted; workers_edge feeds the
5002
+ // durable child idempotency key. See child-run-id.ts.
5003
+ const childRunId = buildChildRunId({
5004
+ childPlayName: resolvedName,
5005
+ parentRunId: this.currentRunId,
5006
+ parentPlayName: this.#options.playName,
5007
+ key: normalizedKey,
5008
+ runPlaySemanticKey,
4066
5009
  input,
4067
- );
4068
- await childContext.drainQueuedWork([childExecution]);
4069
- const result = await childExecution;
4070
- if (rowStore) {
4071
- this.emitScopedFieldMetaUpdate({
4072
- rowId: rowStore.rowId,
4073
- key: rowStore.rowKey ?? null,
4074
- tableNamespace: rowStore.tableNamespace ?? null,
4075
- fieldName: rowStore.fieldName,
4076
- status: 'completed',
4077
- rowStatus: 'running',
4078
- stage: resolvedName,
4079
- provider: 'deepline_native',
4080
- error: null,
4081
- producer,
4082
- dataPatch: {},
4083
- });
4084
- }
4085
- const step = {
4086
- type: 'play_call' as const,
5010
+ graphHash: null,
5011
+ });
5012
+ const childGovernance = this.governor.forkChild({
5013
+ childPlayName: resolvedName,
5014
+ childRunId,
5015
+ });
5016
+ const childPlaySlot = await this.governor.acquireChildSubmitSlot();
5017
+ const rowStore = rowContext.getStore();
5018
+ const producer = {
5019
+ kind: 'play' as const,
5020
+ id: normalizedKey,
4087
5021
  playId: resolvedName,
4088
- nestedSteps: childContext.getSteps(),
4089
- description: normalizeStepDescription(options?.description),
5022
+ displayName: displayNameFromProducerId(resolvedName),
5023
+ runId: childRunId,
4090
5024
  };
4091
- if (this.activeDatasetStep) {
4092
- this.activeDatasetStep.substeps.push(step);
4093
- } else {
4094
- this.steps.push(step);
4095
- }
4096
- return result as TOutput;
4097
- } catch (error) {
4098
- if (rowStore) {
4099
- this.emitScopedFieldMetaUpdate({
4100
- rowId: rowStore.rowId,
4101
- key: rowStore.rowKey ?? null,
4102
- tableNamespace: rowStore.tableNamespace ?? null,
4103
- fieldName: rowStore.fieldName,
4104
- status: 'failed',
4105
- rowStatus: 'running',
4106
- stage: resolvedName,
4107
- provider: 'deepline_native',
4108
- error: this.formatRuntimeError(error),
4109
- producer,
4110
- dataPatch: {},
5025
+
5026
+ try {
5027
+ if (rowStore) {
5028
+ this.emitScopedFieldMetaUpdate({
5029
+ rowId: rowStore.rowId,
5030
+ key: rowStore.rowKey ?? null,
5031
+ tableNamespace: rowStore.tableNamespace ?? null,
5032
+ fieldName: rowStore.fieldName,
5033
+ status: 'running',
5034
+ rowStatus: 'running',
5035
+ stage: resolvedName,
5036
+ provider: 'deepline_native',
5037
+ error: null,
5038
+ producer,
5039
+ dataPatch: {},
5040
+ });
5041
+ }
5042
+ if (this.shouldLaunchChildPlaysThroughScheduler()) {
5043
+ const boundaryId = this.childPlayBoundaryId(
5044
+ childGovernance.currentRunId,
5045
+ );
5046
+ const checkpointResult = this.childPlayBoundaryOutput<TOutput>({
5047
+ boundaryId,
5048
+ childRunId: childGovernance.currentRunId,
5049
+ childPlayName: resolvedName,
5050
+ });
5051
+ if (checkpointResult.found) {
5052
+ if (rowStore) {
5053
+ this.emitScopedFieldMetaUpdate({
5054
+ rowId: rowStore.rowId,
5055
+ key: rowStore.rowKey ?? null,
5056
+ tableNamespace: rowStore.tableNamespace ?? null,
5057
+ fieldName: rowStore.fieldName,
5058
+ status: 'completed',
5059
+ rowStatus: 'running',
5060
+ stage: resolvedName,
5061
+ provider: 'deepline_native',
5062
+ error: null,
5063
+ producer,
5064
+ dataPatch: {},
5065
+ });
5066
+ }
5067
+ this.recordPlayCallStep({
5068
+ playId: resolvedName,
5069
+ description: options?.description,
5070
+ });
5071
+ return checkpointResult.output;
5072
+ }
5073
+
5074
+ await this.launchScheduledChildPlay({
5075
+ key: normalizedKey,
5076
+ childRunId: childGovernance.currentRunId,
5077
+ childPlayName: resolvedName,
5078
+ childGovernance,
5079
+ childInput: input,
5080
+ description: options?.description ?? null,
5081
+ });
5082
+ childPlaySlot.release();
5083
+ const terminal = await this.readScheduledChildTerminal<TOutput>({
5084
+ childRunId: childGovernance.currentRunId,
5085
+ childPlayName: resolvedName,
5086
+ });
5087
+ if (terminal?.status === 'completed') {
5088
+ this.checkpoint.resolvedBoundaries = {
5089
+ ...(this.checkpoint.resolvedBoundaries ?? {}),
5090
+ [boundaryId]: {
5091
+ kind: 'child_play',
5092
+ childRunId: childGovernance.currentRunId,
5093
+ childPlayName: resolvedName,
5094
+ status: 'completed',
5095
+ output: terminal.output,
5096
+ completedAt: Date.now(),
5097
+ },
5098
+ };
5099
+ if (rowStore) {
5100
+ this.emitScopedFieldMetaUpdate({
5101
+ rowId: rowStore.rowId,
5102
+ key: rowStore.rowKey ?? null,
5103
+ tableNamespace: rowStore.tableNamespace ?? null,
5104
+ fieldName: rowStore.fieldName,
5105
+ status: 'completed',
5106
+ rowStatus: 'running',
5107
+ stage: resolvedName,
5108
+ provider: 'deepline_native',
5109
+ error: null,
5110
+ producer,
5111
+ dataPatch: {},
5112
+ });
5113
+ }
5114
+ this.recordPlayCallStep({
5115
+ playId: resolvedName,
5116
+ description: options?.description,
5117
+ });
5118
+ return terminal.output;
5119
+ }
5120
+ if (terminal) {
5121
+ throw new Error(terminal.error);
5122
+ }
5123
+
5124
+ const suspension = {
5125
+ kind: 'child_play',
5126
+ boundaryId,
5127
+ childRunId: childGovernance.currentRunId,
5128
+ childPlayName: resolvedName,
5129
+ } as const;
5130
+ this.recordPendingChildPlayBoundary(suspension);
5131
+ scheduledChildPlayCall?.release();
5132
+ await this.waitForScheduledChildPlayQuiescence();
5133
+ throw new PlayExecutionSuspendedError(
5134
+ this.consumePendingChildPlaySuspension() ?? suspension,
5135
+ );
5136
+ }
5137
+ const childContext = createPlayContext({
5138
+ ...this.#options,
5139
+ playId: resolvedName,
5140
+ playName: resolvedName,
5141
+ runId: childGovernance.currentRunId,
5142
+ executorToken: await this.mintChildExecutorToken({
5143
+ parentRunId: this.currentRunId,
5144
+ parentPlayName: this.#options.playName,
5145
+ childRunId: childGovernance.currentRunId,
5146
+ childPlayName: resolvedName,
5147
+ }),
5148
+ staticPipeline: resolvedPlay.staticPipeline ?? null,
5149
+ checkpoint: this.checkpoint,
5150
+ governance: childGovernance,
5151
+ getRuntimeStepReceipt: undefined,
5152
+ getRuntimeStepReceipts: undefined,
5153
+ claimRuntimeStepReceipt: undefined,
5154
+ claimRuntimeStepReceipts: undefined,
5155
+ completeRuntimeStepReceipt: undefined,
5156
+ completeRuntimeStepReceipts: undefined,
5157
+ failRuntimeStepReceipt: undefined,
5158
+ failRuntimeStepReceipts: undefined,
5159
+ heartbeatRuntimeStepReceipts: undefined,
5160
+ skipRuntimeStepReceipt: undefined,
4111
5161
  });
5162
+ const childExecution = this.executeResolvedPlay(
5163
+ resolvedPlay,
5164
+ childContext,
5165
+ input,
5166
+ );
5167
+ await childContext.drainQueuedWork([childExecution]);
5168
+ const result = await childExecution;
5169
+ if (rowStore) {
5170
+ this.emitScopedFieldMetaUpdate({
5171
+ rowId: rowStore.rowId,
5172
+ key: rowStore.rowKey ?? null,
5173
+ tableNamespace: rowStore.tableNamespace ?? null,
5174
+ fieldName: rowStore.fieldName,
5175
+ status: 'completed',
5176
+ rowStatus: 'running',
5177
+ stage: resolvedName,
5178
+ provider: 'deepline_native',
5179
+ error: null,
5180
+ producer,
5181
+ dataPatch: {},
5182
+ });
5183
+ }
5184
+ const step = {
5185
+ type: 'play_call' as const,
5186
+ playId: resolvedName,
5187
+ nestedSteps: childContext.getSteps(),
5188
+ description: normalizeStepDescription(options?.description),
5189
+ };
5190
+ if (this.activeDatasetStep) {
5191
+ this.activeDatasetStep.substeps.push(step);
5192
+ } else {
5193
+ this.steps.push(step);
5194
+ }
5195
+ return result as TOutput;
5196
+ } catch (error) {
5197
+ if (isPlayExecutionSuspendedError(error)) {
5198
+ throw error;
5199
+ }
5200
+ if (rowStore) {
5201
+ this.emitScopedFieldMetaUpdate({
5202
+ rowId: rowStore.rowId,
5203
+ key: rowStore.rowKey ?? null,
5204
+ tableNamespace: rowStore.tableNamespace ?? null,
5205
+ fieldName: rowStore.fieldName,
5206
+ status: 'failed',
5207
+ rowStatus: 'running',
5208
+ stage: resolvedName,
5209
+ provider: 'deepline_native',
5210
+ error: this.formatRuntimeError(error),
5211
+ producer,
5212
+ dataPatch: {},
5213
+ });
5214
+ }
5215
+ throw error;
5216
+ } finally {
5217
+ childPlaySlot.release();
4112
5218
  }
4113
- throw error;
4114
- } finally {
4115
- childPlaySlot.release();
4116
- }
4117
- };
5219
+ };
4118
5220
 
4119
- if (store) {
4120
- return await executePlayCall();
5221
+ return await this.executeWithRuntimeReceipt<TOutput>(
5222
+ 'runPlay',
5223
+ normalizedKey,
5224
+ this.currentRunId,
5225
+ {
5226
+ semanticKey: runPlaySemanticKey,
5227
+ staleAfterSeconds: options?.staleAfterSeconds,
5228
+ markSkipped: () => {
5229
+ this.log(
5230
+ `ctx.runPlay(${normalizedKey}): no-op due completed receipt`,
5231
+ );
5232
+ },
5233
+ repairRunningReceiptForSameRunAfterWaitTimeout: true,
5234
+ runningReceiptWaitMaxAttempts:
5235
+ resolveRuntimeToolReceiptWaitMaxAttempts(input),
5236
+ execute: executePlayCall,
5237
+ },
5238
+ );
5239
+ } finally {
5240
+ scheduledChildPlayCall?.release();
4121
5241
  }
5242
+ }
4122
5243
 
4123
- return this.executeWithRuntimeReceipt<TOutput>(
4124
- 'runPlay',
4125
- normalizedKey,
4126
- this.currentRunId,
5244
+ private async mintChildExecutorToken(input: {
5245
+ parentRunId: string;
5246
+ parentPlayName?: string | null;
5247
+ childRunId: string;
5248
+ childPlayName: string;
5249
+ }): Promise<string | undefined> {
5250
+ if (!this.#options.executorToken || !this.#options.baseUrl) {
5251
+ return this.#options.executorToken;
5252
+ }
5253
+ const parentPlayName = input.parentPlayName?.trim();
5254
+ if (!parentPlayName) {
5255
+ return this.#options.executorToken;
5256
+ }
5257
+ const response = await fetch(
5258
+ `${this.#options.baseUrl.replace(/\/$/, '')}/api/v2/internal/play-runtime/child-executor-token`,
4127
5259
  {
4128
- semanticKey: stableDigest(
4129
- stableStringify({
4130
- childPlayName: resolvedName,
4131
- childRevisionFingerprint,
4132
- input,
4133
- }),
4134
- ),
4135
- staleAfterSeconds: options?.staleAfterSeconds,
4136
- markSkipped: () => {
4137
- this.log(
4138
- `ctx.runPlay(${normalizedKey}): no-op due completed receipt`,
4139
- );
5260
+ method: 'POST',
5261
+ headers: {
5262
+ Authorization: `Bearer ${this.#options.executorToken}`,
5263
+ 'Content-Type': 'application/json',
5264
+ ...(this.#options.vercelProtectionBypassToken?.trim()
5265
+ ? {
5266
+ 'x-vercel-protection-bypass':
5267
+ this.#options.vercelProtectionBypassToken.trim(),
5268
+ }
5269
+ : {}),
4140
5270
  },
4141
- repairRunningReceiptForSameRunAfterWaitTimeout: true,
4142
- runningReceiptWaitMaxAttempts:
4143
- resolveRuntimeToolReceiptWaitMaxAttempts(input),
4144
- execute: executePlayCall,
5271
+ body: JSON.stringify({
5272
+ parentRunId: input.parentRunId,
5273
+ parentPlayName,
5274
+ childRunId: input.childRunId,
5275
+ childPlayName: input.childPlayName,
5276
+ }),
4145
5277
  },
4146
5278
  );
5279
+ const body = (await response.json().catch(() => null)) as {
5280
+ executorToken?: unknown;
5281
+ error?: unknown;
5282
+ } | null;
5283
+ if (!response.ok) {
5284
+ const message =
5285
+ typeof body?.error === 'string' && body.error.trim()
5286
+ ? body.error.trim()
5287
+ : response.statusText;
5288
+ throw new Error(
5289
+ `ctx.runPlay failed to mint child executor token for "${input.childPlayName}": ${message} (status ${response.status}).`,
5290
+ );
5291
+ }
5292
+ if (typeof body?.executorToken !== 'string' || !body.executorToken.trim()) {
5293
+ throw new Error(
5294
+ `ctx.runPlay failed to mint child executor token for "${input.childPlayName}": response did not include executorToken.`,
5295
+ );
5296
+ }
5297
+ return body.executorToken.trim();
4147
5298
  }
4148
5299
 
4149
5300
  /**
@@ -4630,7 +5781,11 @@ export class PlayContextImpl {
4630
5781
  const followers = liveFollowersByOwnerCallId.get(owner.callId) ?? [];
4631
5782
  liveFollowersByOwnerCallId.delete(owner.callId);
4632
5783
  for (const request of [owner, ...followers]) {
4633
- await this.rejectToolCall(toolId, request, error);
5784
+ await this.rejectToolCall(toolId, request, error, {
5785
+ persistReceiptFailure: !(
5786
+ error instanceof ToolExecuteAuthScopeChangedError
5787
+ ),
5788
+ });
4634
5789
  }
4635
5790
  };
4636
5791
  const requestsWithLiveFollowers = (
@@ -4685,7 +5840,11 @@ export class PlayContextImpl {
4685
5840
  ) {
4686
5841
  const requestsByReceiptKey = new Map<
4687
5842
  string,
4688
- { requests: ToolCallRequest[]; forceRefresh: boolean }
5843
+ {
5844
+ requests: ToolCallRequest[];
5845
+ forceRefresh: boolean;
5846
+ forceFailedRefresh: boolean;
5847
+ }
4689
5848
  >();
4690
5849
  const localOnlyRequests: ToolCallRequest[] = [];
4691
5850
  for (const request of pendingRequests) {
@@ -4697,17 +5856,37 @@ export class PlayContextImpl {
4697
5856
  const group = requestsByReceiptKey.get(receiptKey) ?? {
4698
5857
  requests: [],
4699
5858
  forceRefresh: false,
5859
+ forceFailedRefresh: false,
4700
5860
  };
4701
5861
  group.requests.push(request);
4702
5862
  group.forceRefresh ||= request.force === true;
5863
+ group.forceFailedRefresh ||= request.forceFailedRefresh === true;
4703
5864
  requestsByReceiptKey.set(receiptKey, group);
4704
5865
  }
4705
5866
  const normalReceiptKeys = [...requestsByReceiptKey]
4706
- .filter(([, group]) => !group.forceRefresh)
5867
+ .filter(
5868
+ ([, group]) => !group.forceRefresh && !group.forceFailedRefresh,
5869
+ )
4707
5870
  .map(([receiptKey]) => receiptKey);
4708
5871
  const forcedReceiptKeys = [...requestsByReceiptKey]
4709
5872
  .filter(([, group]) => group.forceRefresh)
4710
5873
  .map(([receiptKey]) => receiptKey);
5874
+ const failedRefreshReceiptKeys = [...requestsByReceiptKey]
5875
+ .filter(
5876
+ ([, group]) => !group.forceRefresh && group.forceFailedRefresh,
5877
+ )
5878
+ .map(([receiptKey]) => receiptKey);
5879
+ const forcedExisting = forcedReceiptKeys.length
5880
+ ? await this.getRuntimeStepReceipts(forcedReceiptKeys)
5881
+ : new Map<string, RuntimeStepReceipt>();
5882
+ const forcedRunningReceiptKeys = new Set(
5883
+ [...forcedExisting]
5884
+ .filter(([, receipt]) => receipt.status === 'running')
5885
+ .map(([receiptKey]) => receiptKey),
5886
+ );
5887
+ const claimableForcedReceiptKeys = forcedReceiptKeys.filter(
5888
+ (receiptKey) => !forcedRunningReceiptKeys.has(receiptKey),
5889
+ );
4711
5890
  const claims =
4712
5891
  normalReceiptKeys.length > 0
4713
5892
  ? await this.claimRuntimeStepReceipts(
@@ -4716,17 +5895,30 @@ export class PlayContextImpl {
4716
5895
  )
4717
5896
  : new Map<string, RuntimeStepReceipt>();
4718
5897
  const forcedClaims =
4719
- forcedReceiptKeys.length > 0
5898
+ claimableForcedReceiptKeys.length > 0
4720
5899
  ? await this.claimRuntimeStepReceipts(
4721
- forcedReceiptKeys,
5900
+ claimableForcedReceiptKeys,
4722
5901
  this.currentRunId,
5902
+ false,
4723
5903
  true,
5904
+ )
5905
+ : new Map<string, RuntimeStepReceipt>();
5906
+ const failedRefreshClaims =
5907
+ failedRefreshReceiptKeys.length > 0
5908
+ ? await this.claimRuntimeStepReceipts(
5909
+ failedRefreshReceiptKeys,
5910
+ this.currentRunId,
5911
+ false,
5912
+ false,
4724
5913
  true,
4725
5914
  )
4726
5915
  : new Map<string, RuntimeStepReceipt>();
4727
5916
  for (const [receiptKey, claim] of forcedClaims) {
4728
5917
  claims.set(receiptKey, claim);
4729
5918
  }
5919
+ for (const [receiptKey, claim] of failedRefreshClaims) {
5920
+ claims.set(receiptKey, claim);
5921
+ }
4730
5922
  const claimedRequests: ToolCallRequest[] = [...localOnlyRequests];
4731
5923
  const durableRecoveredRequests: ToolCallRequest[] = [];
4732
5924
  const resolveRequestsFromReceipt = async (
@@ -4738,9 +5930,7 @@ export class PlayContextImpl {
4738
5930
  if (!request) return;
4739
5931
  const receiptKey = request.receiptKey?.trim() || null;
4740
5932
  if (!receiptKey) {
4741
- throw new Error(
4742
- `Durable tool receipt ${source} recovery requires a receipt key.`,
4743
- );
5933
+ throw new Error(`${source} tool recovery needs receipt key.`);
4744
5934
  }
4745
5935
  const wrapped = await this.wrapToolExecutionResult({
4746
5936
  toolId,
@@ -4794,6 +5984,7 @@ export class PlayContextImpl {
4794
5984
  const reclaimExistingRunningReceiptAfterWait = async (
4795
5985
  receiptKey: string,
4796
5986
  requestsForKey: ToolCallRequest[],
5987
+ forceAfterWait = false,
4797
5988
  ): Promise<void> => {
4798
5989
  const waitMaxAttempts =
4799
5990
  requestsForKey.length > 0
@@ -4808,15 +5999,18 @@ export class PlayContextImpl {
4808
5999
  )
4809
6000
  : undefined;
4810
6001
  try {
4811
- await resolveRequestsFromReceipt(
4812
- requestsForKey,
4813
- await this.waitForCompletedRuntimeToolReceipt(
4814
- receiptKey,
4815
- waitMaxAttempts,
4816
- ),
4817
- 'in_flight',
6002
+ const completed = await this.waitForCompletedRuntimeToolReceipt(
6003
+ receiptKey,
6004
+ waitMaxAttempts,
4818
6005
  );
4819
- return;
6006
+ if (!forceAfterWait) {
6007
+ await resolveRequestsFromReceipt(
6008
+ requestsForKey,
6009
+ completed,
6010
+ 'in_flight',
6011
+ );
6012
+ return;
6013
+ }
4820
6014
  } catch (error) {
4821
6015
  if (!(error instanceof RuntimeReceiptWaitTimeoutError)) {
4822
6016
  for (const request of requestsForKey) {
@@ -4832,6 +6026,7 @@ export class PlayContextImpl {
4832
6026
  [receiptKey],
4833
6027
  this.currentRunId,
4834
6028
  true,
6029
+ forceAfterWait,
4835
6030
  )
4836
6031
  ).get(receiptKey);
4837
6032
  if (
@@ -4862,14 +6057,31 @@ export class PlayContextImpl {
4862
6057
  ) {
4863
6058
  const [owner, ...waiters] = requestsForKey;
4864
6059
  if (!owner) return;
6060
+ if (!reclaimed.leaseId) {
6061
+ throw new RuntimeReceiptLeaseLostError({
6062
+ receiptKey,
6063
+ runId: this.currentRunId,
6064
+ leaseId: 'missing',
6065
+ });
6066
+ }
6067
+ owner.receiptLeaseId = reclaimed.leaseId ?? null;
4865
6068
  if (waiters.length > 0) {
4866
6069
  liveFollowersByOwnerCallId.set(owner.callId, waiters);
4867
6070
  }
4868
6071
  try {
6072
+ await this.assertRuntimeToolReceiptOwnership([owner]);
4869
6073
  const execution = await this.callToolExecutionAPI(
4870
6074
  toolId,
4871
6075
  owner.input,
4872
6076
  {
6077
+ durableCallReceiptKey: receiptKey,
6078
+ executionAuthScopeDigest: owner.executionAuthScopeDigest,
6079
+ providerIdempotencyKey:
6080
+ this.providerIdempotencyKeyForToolCall({
6081
+ cacheKey: owner.cacheKey,
6082
+ force: owner.force === true,
6083
+ leaseId: owner.receiptLeaseId,
6084
+ }),
4873
6085
  timeoutMs: resolveRuntimeTimeoutMsForClaimedOwners([owner]),
4874
6086
  },
4875
6087
  );
@@ -4904,8 +6116,13 @@ export class PlayContextImpl {
4904
6116
  const requestsForKey = group.requests;
4905
6117
  const claim = claims.get(receiptKey);
4906
6118
  if (!claim) {
4907
- const [owner, ...waiters] = requestsForKey;
4908
- claimOwnerWithLiveFollowers(owner, waiters);
6119
+ durableExistingRunningHandlers.push(
6120
+ reclaimExistingRunningReceiptAfterWait(
6121
+ receiptKey,
6122
+ requestsForKey,
6123
+ group.forceRefresh,
6124
+ ),
6125
+ );
4909
6126
  continue;
4910
6127
  }
4911
6128
  if (claim?.status === 'completed' || claim?.status === 'skipped') {
@@ -4928,6 +6145,16 @@ export class PlayContextImpl {
4928
6145
  claim.claimState !== 'existing'
4929
6146
  ) {
4930
6147
  const [owner, ...waiters] = requestsForKey;
6148
+ if (!claim.leaseId) {
6149
+ throw new RuntimeReceiptLeaseLostError({
6150
+ receiptKey,
6151
+ runId: this.currentRunId,
6152
+ leaseId: 'missing',
6153
+ });
6154
+ }
6155
+ if (owner) {
6156
+ owner.receiptLeaseId = claim.leaseId ?? null;
6157
+ }
4931
6158
  claimOwnerWithLiveFollowers(owner, waiters);
4932
6159
  continue;
4933
6160
  }
@@ -4939,6 +6166,7 @@ export class PlayContextImpl {
4939
6166
  reclaimExistingRunningReceiptAfterWait(
4940
6167
  receiptKey,
4941
6168
  requestsForKey,
6169
+ group.forceRefresh,
4942
6170
  ),
4943
6171
  );
4944
6172
  continue;
@@ -4947,6 +6175,7 @@ export class PlayContextImpl {
4947
6175
  reclaimExistingRunningReceiptAfterWait(
4948
6176
  receiptKey,
4949
6177
  requestsForKey,
6178
+ group.forceRefresh,
4950
6179
  ),
4951
6180
  );
4952
6181
  }
@@ -4971,18 +6200,13 @@ export class PlayContextImpl {
4971
6200
  compiledBatches.length > 0
4972
6201
  ? await this.governor.suggestedParallelism(
4973
6202
  compiledBatches[0]!.batchOperation,
4974
- 4,
6203
+ this.governor.policy.pacing
6204
+ .workerToolBatchDefaultParallelism,
4975
6205
  )
4976
- : 4;
6206
+ : this.governor.policy.pacing.workerToolBatchDefaultParallelism;
4977
6207
  await executeChunkedRequests({
4978
6208
  requests: compiledBatches,
4979
6209
  batchSize,
4980
- // Bounded dispatch wave (postgres_fast parity with workers_edge):
4981
- // cap the provider calls (counted by batch member size) a single
4982
- // chunk dispatches before the latch is read again, so a
4983
- // persistence failure prevents the rest of a large batch group.
4984
- maxChunkWeight: PROVIDER_DISPATCH_WAVE_SIZE,
4985
- weightOf: (batch) => batch.memberRequests.length,
4986
6210
  execute: async (batch) => {
4987
6211
  // Circuit breaker: skip dispatching this batch's provider call
4988
6212
  // once a persistence failure has occurred in this run.
@@ -4993,10 +6217,42 @@ export class PlayContextImpl {
4993
6217
  this.persistenceLatch,
4994
6218
  );
4995
6219
  }
6220
+ await this.assertRuntimeToolReceiptOwnership(
6221
+ batch.memberRequests,
6222
+ );
6223
+ const receiptKeys = batch.memberRequests.map(
6224
+ (request) => request.cacheKey,
6225
+ );
6226
+ const aggregateReceiptKey = buildDurableToolAggregateReceiptKey(
6227
+ {
6228
+ receiptKeys,
6229
+ prefix: 'batch',
6230
+ aggregateReceiptPrefix: buildDurableToolReceiptPrefix({
6231
+ orgId: this.#options.orgId,
6232
+ toolId: batch.batchOperation,
6233
+ }),
6234
+ },
6235
+ );
4996
6236
  return await this.callToolAPI(
4997
6237
  batch.batchOperation,
4998
6238
  batch.batchPayload,
4999
6239
  {
6240
+ durableCallReceiptKey: aggregateReceiptKey,
6241
+ executionAuthScopeDigest:
6242
+ batch.memberRequests[0]?.executionAuthScopeDigest ?? null,
6243
+ providerIdempotencyKey:
6244
+ buildDurableToolAggregateProviderIdempotencyKey({
6245
+ aggregateReceiptKey,
6246
+ receiptKeys,
6247
+ providerIdempotencyKeys: batch.memberRequests.map(
6248
+ (request) =>
6249
+ this.providerIdempotencyKeyForToolCall({
6250
+ cacheKey: request.cacheKey,
6251
+ force: request.force === true,
6252
+ leaseId: request.receiptLeaseId,
6253
+ }),
6254
+ ),
6255
+ }),
5000
6256
  timeoutMs: resolveRuntimeTimeoutMsForClaimedOwners(
5001
6257
  batch.memberRequests,
5002
6258
  ),
@@ -5042,13 +6298,9 @@ export class PlayContextImpl {
5042
6298
  },
5043
6299
  });
5044
6300
  } else {
5045
- // Bounded dispatch wave (postgres_fast parity with workers_edge):
5046
- // an unbatched tool group can carry a whole chunk's rows, so cap the
5047
- // provider calls dispatched before the latch is read again to the
5048
- // wave size. Respect a lower provider-pacing suggestion when present.
5049
- const batchSize = Math.min(
5050
- await this.governor.suggestedParallelism(toolId, 50),
5051
- PROVIDER_DISPATCH_WAVE_SIZE,
6301
+ const batchSize = await this.governor.suggestedParallelism(
6302
+ toolId,
6303
+ this.governor.policy.pacing.suggestedMaxParallelism,
5052
6304
  );
5053
6305
  for (
5054
6306
  let start = 0;
@@ -5071,10 +6323,24 @@ export class PlayContextImpl {
5071
6323
  return;
5072
6324
  }
5073
6325
  try {
6326
+ await this.assertRuntimeToolReceiptOwnership([request]);
5074
6327
  const execution = await this.callToolExecutionAPI(
5075
6328
  toolId,
5076
6329
  request.input,
5077
6330
  {
6331
+ ...(request.receiptKey
6332
+ ? {
6333
+ durableCallReceiptKey: request.receiptKey,
6334
+ executionAuthScopeDigest:
6335
+ request.executionAuthScopeDigest,
6336
+ providerIdempotencyKey:
6337
+ this.providerIdempotencyKeyForToolCall({
6338
+ cacheKey: request.receiptKey,
6339
+ force: request.force === true,
6340
+ leaseId: request.receiptLeaseId,
6341
+ }),
6342
+ }
6343
+ : {}),
5078
6344
  timeoutMs: resolveRuntimeTimeoutMsForClaimedOwners([
5079
6345
  request,
5080
6346
  ]),
@@ -5198,6 +6464,23 @@ export class PlayContextImpl {
5198
6464
  : execution.result;
5199
6465
  }
5200
6466
 
6467
+ private providerIdempotencyKeyForToolCall(input: {
6468
+ cacheKey: string;
6469
+ force?: boolean;
6470
+ leaseId?: string | null;
6471
+ }): string {
6472
+ const fallbackAttemptId =
6473
+ input.force === true && !input.leaseId
6474
+ ? `${this.currentRunId}:${crypto.randomUUID()}`
6475
+ : null;
6476
+ return buildDurableToolProviderIdempotencyKey({
6477
+ receiptKey: input.cacheKey,
6478
+ force: input.force,
6479
+ receiptLeaseId: input.leaseId,
6480
+ fallbackAttemptId,
6481
+ });
6482
+ }
6483
+
5201
6484
  private async callToolExecutionAPI(
5202
6485
  toolId: string,
5203
6486
  input: Record<string, unknown>,
@@ -5240,6 +6523,20 @@ export class PlayContextImpl {
5240
6523
 
5241
6524
  while (true) {
5242
6525
  let response: Response;
6526
+ const durableCallReceiptKey =
6527
+ options?.durableCallReceiptKey?.trim() || null;
6528
+ const executionAuthScopeDigest =
6529
+ durableCallReceiptKey && options?.executionAuthScopeDigest
6530
+ ? options.executionAuthScopeDigest.trim() || null
6531
+ : durableCallReceiptKey
6532
+ ? ((await this.resolveToolAuthScopeDigest(toolId))?.trim() ??
6533
+ null)
6534
+ : null;
6535
+ const providerIdempotencyKey =
6536
+ options?.providerIdempotencyKey?.trim() || durableCallReceiptKey;
6537
+ const deeplineRequestId = providerIdempotencyKey
6538
+ ? `ctx-tool-${stableDigest(providerIdempotencyKey).slice(0, 32)}`
6539
+ : null;
5243
6540
  const abortController = timeoutMs ? new AbortController() : null;
5244
6541
  const timeoutHandle = timeoutMs
5245
6542
  ? setTimeout(() => {
@@ -5261,6 +6558,12 @@ export class PlayContextImpl {
5261
6558
  V2_EXECUTE_RESPONSE_CONTRACT,
5262
6559
  [EXECUTE_RESPONSE_INTENT_HEADER]: 'dataset',
5263
6560
  [EXECUTE_TOOL_METADATA_HEADER]: 'true',
6561
+ ...(deeplineRequestId
6562
+ ? {
6563
+ 'x-deepline-request-id': deeplineRequestId,
6564
+ 'x-deepline-idempotency-key': providerIdempotencyKey!,
6565
+ }
6566
+ : {}),
5264
6567
  ...(this.#options.vercelProtectionBypassToken?.trim()
5265
6568
  ? {
5266
6569
  'x-vercel-protection-bypass':
@@ -5272,6 +6575,17 @@ export class PlayContextImpl {
5272
6575
  payload: input,
5273
6576
  metadata: {
5274
6577
  parent_run_id: this.#options.runId,
6578
+ ...(durableCallReceiptKey
6579
+ ? {
6580
+ durable_call_receipt_key: durableCallReceiptKey,
6581
+ ...(executionAuthScopeDigest
6582
+ ? {
6583
+ execution_auth_scope_digest:
6584
+ executionAuthScopeDigest,
6585
+ }
6586
+ : {}),
6587
+ }
6588
+ : {}),
5275
6589
  ...(options?.customerDbDataset
5276
6590
  ? {
5277
6591
  query_result_dataset: {
@@ -5293,6 +6607,9 @@ export class PlayContextImpl {
5293
6607
  : {}),
5294
6608
  }
5295
6609
  : {}),
6610
+ ...(providerIdempotencyKey
6611
+ ? { provider_idempotency_key: providerIdempotencyKey }
6612
+ : {}),
5296
6613
  },
5297
6614
  ...(this.#options.integrationMode
5298
6615
  ? { integration_mode: this.#options.integrationMode }
@@ -5337,6 +6654,13 @@ export class PlayContextImpl {
5337
6654
 
5338
6655
  if (!response.ok) {
5339
6656
  const text = await response.text();
6657
+ const authScopeChanged = parseToolExecuteAuthScopeChangedError({
6658
+ status: response.status,
6659
+ bodyText: text,
6660
+ });
6661
+ if (authScopeChanged) {
6662
+ throw authScopeChanged;
6663
+ }
5340
6664
  const httpFailureAttempt = httpFailureAttempts.next({
5341
6665
  toolId,
5342
6666
  status: response.status,