deepline 0.1.181 → 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 (70) 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 +1932 -1838
  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/output-datasets.ts +124 -12
  9. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/receipts.ts +292 -11
  10. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/run-work-dispatcher.ts +519 -0
  11. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/tool-dispatch.ts +2381 -0
  12. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/tool-receipts.ts +18 -5
  13. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/work-budget.ts +130 -0
  14. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/worker-platform-budget.ts +39 -0
  15. package/dist/bundling-sources/apps/play-runner-workers/src/workflow-retry-state.ts +2 -0
  16. package/dist/bundling-sources/sdk/src/client.ts +48 -5
  17. package/dist/bundling-sources/sdk/src/http.ts +23 -8
  18. package/dist/bundling-sources/sdk/src/play.ts +200 -26
  19. package/dist/bundling-sources/sdk/src/plays/harness-stub.ts +56 -3
  20. package/dist/bundling-sources/sdk/src/release.ts +2 -2
  21. package/dist/bundling-sources/sdk/src/types.ts +2 -0
  22. package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +132 -10
  23. package/dist/bundling-sources/shared_libs/play-runtime/auth-scope-resolver.ts +92 -0
  24. package/dist/bundling-sources/shared_libs/play-runtime/batch-runtime.ts +4 -4
  25. package/dist/bundling-sources/shared_libs/play-runtime/batching-types.ts +8 -0
  26. package/dist/bundling-sources/shared_libs/play-runtime/child-run-id.ts +101 -0
  27. package/dist/bundling-sources/shared_libs/play-runtime/context.ts +1857 -314
  28. package/dist/bundling-sources/shared_libs/play-runtime/ctx-types.ts +106 -6
  29. package/dist/bundling-sources/shared_libs/play-runtime/dedup-backend.ts +0 -0
  30. package/dist/bundling-sources/shared_libs/play-runtime/default-batch-strategies.ts +1 -0
  31. package/dist/bundling-sources/shared_libs/play-runtime/durability-store.ts +4 -0
  32. package/dist/bundling-sources/shared_libs/play-runtime/durable-call-cache.ts +116 -16
  33. package/dist/bundling-sources/shared_libs/play-runtime/durable-receipt-execution.ts +186 -20
  34. package/dist/bundling-sources/shared_libs/play-runtime/dynamic-worker-version.ts +2 -0
  35. package/dist/bundling-sources/shared_libs/play-runtime/execution-ledger-store.ts +133 -0
  36. package/dist/bundling-sources/shared_libs/play-runtime/governor/app-runtime-rate-state-backend.ts +245 -0
  37. package/dist/bundling-sources/shared_libs/play-runtime/governor/governor.ts +5 -5
  38. package/dist/bundling-sources/shared_libs/play-runtime/governor/policy.ts +14 -0
  39. package/dist/bundling-sources/shared_libs/play-runtime/lease-policy.ts +52 -0
  40. package/dist/bundling-sources/shared_libs/play-runtime/protocol.ts +41 -0
  41. package/dist/bundling-sources/shared_libs/play-runtime/providers.ts +3 -2
  42. package/dist/bundling-sources/shared_libs/play-runtime/query-result-dataset.ts +120 -0
  43. package/dist/bundling-sources/shared_libs/play-runtime/receipt-sql.ts +124 -0
  44. package/dist/bundling-sources/shared_libs/play-runtime/receipt-status.ts +6 -2
  45. package/dist/bundling-sources/shared_libs/play-runtime/row-isolation.ts +48 -2
  46. package/dist/bundling-sources/shared_libs/play-runtime/run-ledger.ts +158 -15
  47. package/dist/bundling-sources/shared_libs/play-runtime/run-terminal-source.ts +45 -0
  48. package/dist/bundling-sources/shared_libs/play-runtime/runtime-actions.ts +9 -0
  49. package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +2037 -262
  50. package/dist/bundling-sources/shared_libs/play-runtime/runtime-contract.ts +42 -0
  51. package/dist/bundling-sources/shared_libs/play-runtime/runtime-sheet-attempt-state-machine.ts +183 -0
  52. package/dist/bundling-sources/shared_libs/play-runtime/runtime-sheet-errors.ts +88 -0
  53. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backend.ts +8 -1
  54. package/dist/bundling-sources/shared_libs/play-runtime/sheet-attempt-sql.ts +103 -0
  55. package/dist/bundling-sources/shared_libs/play-runtime/suspension.ts +19 -1
  56. package/dist/bundling-sources/shared_libs/play-runtime/test-runtime-seams.ts +343 -0
  57. package/dist/bundling-sources/shared_libs/play-runtime/tool-execute-retry-policy.ts +43 -0
  58. package/dist/bundling-sources/shared_libs/play-runtime/tool-http-errors.ts +11 -0
  59. package/dist/bundling-sources/shared_libs/play-runtime/tool-result.ts +139 -17
  60. package/dist/bundling-sources/shared_libs/play-runtime/work-receipt-state-machine.ts +437 -0
  61. package/dist/bundling-sources/shared_libs/play-runtime/work-receipts.ts +83 -6
  62. package/dist/bundling-sources/shared_libs/security/safe-outbound-fetch.ts +24 -17
  63. package/dist/cli/index.js +85 -23
  64. package/dist/cli/index.mjs +85 -23
  65. package/dist/index.d.mts +4 -1
  66. package/dist/index.d.ts +4 -1
  67. package/dist/index.js +274 -35
  68. package/dist/index.mjs +274 -35
  69. package/dist/plays/bundle-play-file.mjs +2 -2
  70. package/package.json +1 -1
@@ -0,0 +1,2381 @@
1
+ import {
2
+ compileRequestsWithStrategy,
3
+ executeChunkedRequests,
4
+ type ChunkExecutionResult,
5
+ } from '../../../../shared_libs/play-runtime/batch-runtime';
6
+ import type { AnyBatchOperationStrategy } from '../../../../shared_libs/play-runtime/batching-types';
7
+ import {
8
+ buildDurableToolCallAuthScopeDigest,
9
+ buildDurableToolCallCacheKey,
10
+ buildDurableToolAggregateProviderIdempotencyKey,
11
+ buildDurableToolAggregateReceiptKey,
12
+ buildDurableToolProviderIdempotencyKey,
13
+ buildDurableToolReceiptPrefix,
14
+ } from '../../../../shared_libs/play-runtime/durable-call-cache';
15
+ import {
16
+ RuntimeReceiptWaitTimeoutError,
17
+ resolveRuntimeToolReceiptWaitMaxAttempts,
18
+ waitForCompletedRuntimeReceipt,
19
+ } from '../../../../shared_libs/play-runtime/durable-receipt-execution';
20
+ import type { PlayExecutionGovernor } from '../../../../shared_libs/play-runtime/governor/governor';
21
+ import { getToolHttpErrorReceiptFailureKind } from '../../../../shared_libs/play-runtime/tool-http-errors';
22
+ import { ToolExecuteAuthScopeChangedError } from '../../../../shared_libs/play-runtime/tool-execute-retry-policy';
23
+ import { getPlayRuntimeBatchStrategy } from '../../../../shared_libs/play-runtime/play-runtime-batching-registry';
24
+ import {
25
+ RuntimePersistenceCircuitOpenError,
26
+ shouldShortenCompleteReceiptLadder,
27
+ tripRuntimePersistenceLatch,
28
+ type RuntimePersistenceLatch,
29
+ } from '../../../../shared_libs/play-runtime/persistence-latch';
30
+ import type { ReceiptSalvageBuffer } from '../../../../shared_libs/play-runtime/receipt-salvage';
31
+ import type { ResolvedPacingPolicy } from '../../../../shared_libs/play-runtime/pacing';
32
+ import type { RuntimeStepReceipt } from '../../../../shared_libs/play-runtime/ctx-types';
33
+ import {
34
+ isPlayExecutionSuspendedError,
35
+ isPlayRowExecutionSuspendedError,
36
+ } from '../../../../shared_libs/play-runtime/suspension';
37
+ import {
38
+ QUERY_RESULT_DATASET_PAGE_SIZE,
39
+ isQueryResultDatasetReadRequest,
40
+ type QueryResultDatasetRequest,
41
+ } from '../../../../shared_libs/play-runtime/query-result-dataset';
42
+ import {
43
+ createToolExecuteResult,
44
+ isToolExecuteResult,
45
+ type ToolExecuteResult,
46
+ type ToolResultMetadataInput,
47
+ } from '../../../../shared_libs/play-runtime/tool-result';
48
+ import { toolExecutionMetadataForOutcome } from '../../../../shared_libs/play-runtime/tool-execution-outcome';
49
+ import {
50
+ buildScopedWorkReceiptKey,
51
+ type WorkReceiptFailureKind,
52
+ } from '../../../../shared_libs/play-runtime/work-receipts';
53
+ import {
54
+ canReclaimFailedWorkerToolReceipt,
55
+ canReclaimTimedOutWorkerToolReceipt,
56
+ markWorkerToolReceiptResultCached,
57
+ markWorkerToolReceiptResultExecution,
58
+ planWorkerToolReceiptGroups,
59
+ resolveWorkerToolReceiptGroupWaitMaxAttempts,
60
+ resolveWorkerToolRuntimeTimeoutMs,
61
+ } from './tool-receipts';
62
+ import type {
63
+ WorkerRuntimeReceipt,
64
+ WorkerRuntimeReceiptClaim,
65
+ WorkerRuntimeReceiptStore,
66
+ } from './receipts';
67
+ import {
68
+ executeWithWorkerReceiptHeartbeats,
69
+ RuntimeLeaseLostError,
70
+ } from './receipts';
71
+ import { WORKER_PLATFORM_SUBREQUESTS_PER_UNBATCHED_TOOL_CALL } from './worker-platform-budget';
72
+ import type { WorkerWorkBudgetMeter } from './work-budget';
73
+
74
+ export type WorkerPacingResolver = (
75
+ toolId: string,
76
+ ) => Promise<
77
+ (ResolvedPacingPolicy & { retrySafeTransientHttp: boolean }) | null
78
+ >;
79
+
80
+ export type WorkerToolActionCacheVersionResolver = (
81
+ toolId: string,
82
+ ) => Promise<string>;
83
+
84
+ export type WorkerToolAuthScopeDigestResolver = ((
85
+ toolId: string,
86
+ ) => Promise<string>) & {
87
+ invalidate?: (toolId?: string | null) => void;
88
+ };
89
+
90
+ export type WorkerToolDispatchCallbacks = {
91
+ onToolCalled?: (toolId: string, at?: number) => void;
92
+ onToolFailed?: (toolId: string, at?: number) => void;
93
+ };
94
+
95
+ export type WorkerToolDirectOptions = {
96
+ customerDbDataset?: QueryResultDatasetRequest;
97
+ attachCustomerDbDataset?: boolean;
98
+ };
99
+
100
+ export type WorkerToolDispatchRunRequest = {
101
+ runId: string;
102
+ runAttempt?: number | null;
103
+ orgId: string;
104
+ playName: string;
105
+ };
106
+
107
+ export type WorkflowStepLike = object;
108
+
109
+ export type WorkerToolDispatchExecuteTool = (input: {
110
+ id: string;
111
+ toolId: string;
112
+ input: Record<string, unknown>;
113
+ workflowStep?: WorkflowStepLike;
114
+ callbacks?: WorkerToolDispatchCallbacks;
115
+ onProviderBackpressure?: (retryAfterMs: number) => void;
116
+ onRetryAttempt?: () => void;
117
+ transientHttpRetrySafe?: boolean;
118
+ durableCallReceiptKey?: string | null;
119
+ executionAuthScopeDigest?: string | null;
120
+ providerIdempotencyKey?: string | null;
121
+ runtimeTimeoutMs?: number;
122
+ directOptions?: WorkerToolDirectOptions;
123
+ }) => Promise<ToolExecuteResult>;
124
+
125
+ export type WorkerToolDispatchPerfRecorder = (input: {
126
+ phase: string;
127
+ ms?: number;
128
+ extra?: Record<string, unknown>;
129
+ }) => void;
130
+
131
+ export type ToolDispatchStore = {
132
+ receiptStore?: WorkerRuntimeReceiptStore;
133
+ serializeDurableValue: <T>(value: T) => T;
134
+ deserializeDurableValue: <T>(value: T) => T;
135
+ allowLocalRetryReceipts?: boolean;
136
+ runtimeReceiptHeartbeatIntervalMs?: number;
137
+ runtimeReceiptLeaseTtlMs?: number;
138
+ };
139
+
140
+ export type ToolDispatchGovernor = {
141
+ governor: Pick<
142
+ PlayExecutionGovernor,
143
+ | 'policy'
144
+ | 'acquireToolSlot'
145
+ | 'suggestedParallelism'
146
+ | 'chargeBudget'
147
+ | 'reportProviderBackpressure'
148
+ >;
149
+ resolvePacing: WorkerPacingResolver;
150
+ budgetMeter?: WorkerWorkBudgetMeter;
151
+ };
152
+
153
+ export type ToolExecutor = {
154
+ resolveToolActionCacheVersion: WorkerToolActionCacheVersionResolver;
155
+ resolveToolAuthScopeDigest: WorkerToolAuthScopeDigestResolver;
156
+ // REQUIRED: the AUTH_SCOPE_CHANGED re-claim depends on evicting the cached
157
+ // digest before re-resolving. When this was optional, the root (top-level)
158
+ // scheduler was constructed without it: the requeue silently reused the
159
+ // stale digest, re-claimed the SAME receipt key, collided with its own
160
+ // still-running receipt, and the run stalled until the whole workflow
161
+ // invocation was retried minutes later. A missing wiring must be a type
162
+ // error, not a 5-minute invisible stall.
163
+ invalidateToolAuthScopeDigest: (toolId: string) => void;
164
+ executeTool: WorkerToolDispatchExecuteTool;
165
+ callbacks?: WorkerToolDispatchCallbacks;
166
+ // Observability hook for the bounded AUTH_SCOPE_CHANGED re-claim: called
167
+ // once per requeued group so the runner can emit a durable run-log line
168
+ // (marker: auth_scope_changed_reclaim). The credential-swap transition must
169
+ // be visible in run logs, not a silent continuation.
170
+ onAuthScopeReclaim?: (input: {
171
+ toolId: string;
172
+ requestIds: string[];
173
+ previousReceiptKeys: Array<string | null>;
174
+ nextReceiptKeys: string[];
175
+ }) => void;
176
+ };
177
+
178
+ export type WorkerToolBatchSchedulerOptions = ToolDispatchStore &
179
+ ToolDispatchGovernor &
180
+ ToolExecutor & {
181
+ req: WorkerToolDispatchRunRequest;
182
+ nowMs: () => number;
183
+ batchGraceMs?: number;
184
+ recordPerfTrace?: WorkerToolDispatchPerfRecorder;
185
+ abortSignal?: AbortSignal;
186
+ onRequestsSettled?: (count: number) => void;
187
+ runtimeDeadlineMs?: number;
188
+ persistenceLatch?: RuntimePersistenceLatch;
189
+ receiptSalvage?: ReceiptSalvageBuffer;
190
+ };
191
+
192
+ type WorkerToolBatchRequest = {
193
+ id: string;
194
+ cacheKey: string;
195
+ receiptKey: string | null;
196
+ executionAuthScopeDigest: string;
197
+ force: boolean;
198
+ runForceRefresh: boolean;
199
+ runForceFailedRefresh: boolean;
200
+ staleAfterSeconds?: number | null;
201
+ receiptWaitMaxAttempts: number;
202
+ authScopeRetryUsed?: boolean;
203
+ runtimeTimeoutMs?: number;
204
+ toolId: string;
205
+ input: Record<string, unknown>;
206
+ workflowStep?: WorkflowStepLike;
207
+ directOptions?: WorkerToolDirectOptions;
208
+ resolve: (value: unknown) => void;
209
+ reject: (error: unknown) => void;
210
+ };
211
+
212
+ type ClaimedWorkerToolBatchRequest = {
213
+ request: WorkerToolBatchRequest;
214
+ receiptKey: string | null;
215
+ receiptLeaseId: string | null;
216
+ providerForce?: boolean;
217
+ followers: WorkerToolBatchRequest[];
218
+ };
219
+
220
+ type PreparedWorkerToolBatchRequests = {
221
+ claimedRequests: ClaimedWorkerToolBatchRequest[];
222
+ deferredClaimedRequests: Promise<ClaimedWorkerToolBatchRequest[]>[];
223
+ };
224
+
225
+ type WorkerToolCompletionResult =
226
+ | { ok: true; value: unknown }
227
+ | { ok: false; error: unknown };
228
+
229
+ const WORKER_TOOL_BATCH_GRACE_MS = 250;
230
+ const COMPLETE_RECEIPT_RETRY_BACKOFFS_MS = [1_000, 3_000, 9_000] as const;
231
+ const COMPLETE_RECEIPT_RETRY_MIN_HEADROOM_MS = 10_000;
232
+ export const WORKER_TOOL_COMPLETE_RECEIPTS_MAX_BATCH_BYTES = 8 * 1024 * 1024;
233
+ export const WORKER_TOOL_COMPLETE_RECEIPTS_MAX_BATCH_MEMBERS = 500;
234
+ export const WORKER_TOOL_RECEIPT_RECOVERY_MAX_CONCURRENT_READS = 25;
235
+
236
+ type DurableToolCompletionEntry = {
237
+ index: number;
238
+ claimed: ClaimedWorkerToolBatchRequest;
239
+ ownerResult: unknown;
240
+ receipt: {
241
+ runId: string;
242
+ runAttempt: number;
243
+ key: string;
244
+ leaseId: string | null;
245
+ output: unknown;
246
+ };
247
+ serializedBytes: number;
248
+ };
249
+
250
+ type DurableToolCompletionBatch = {
251
+ entries: DurableToolCompletionEntry[];
252
+ bytes: number;
253
+ };
254
+
255
+ const jsonByteLength = (value: unknown): number =>
256
+ new TextEncoder().encode(JSON.stringify(value)).byteLength;
257
+
258
+ async function mapWithConcurrency<T>(
259
+ values: T[],
260
+ concurrency: number,
261
+ run: (value: T, index: number) => Promise<void>,
262
+ ): Promise<void> {
263
+ const workerCount = Math.max(
264
+ 1,
265
+ Math.min(Math.floor(concurrency), values.length),
266
+ );
267
+ let nextIndex = 0;
268
+ await Promise.all(
269
+ Array.from({ length: workerCount }, async () => {
270
+ while (true) {
271
+ const index = nextIndex;
272
+ nextIndex += 1;
273
+ if (index >= values.length) return;
274
+ await run(values[index]!, index);
275
+ }
276
+ }),
277
+ );
278
+ }
279
+
280
+ async function sleepWorkerMs(ms: number): Promise<void> {
281
+ await new Promise((resolve) => setTimeout(resolve, ms));
282
+ }
283
+
284
+ function workReceiptFailureKindForError(
285
+ error: unknown,
286
+ ): WorkReceiptFailureKind {
287
+ if (
288
+ isPlayExecutionSuspendedError(error) ||
289
+ isPlayRowExecutionSuspendedError(error)
290
+ ) {
291
+ return 'repairable';
292
+ }
293
+ return getToolHttpErrorReceiptFailureKind(error) ?? 'terminal';
294
+ }
295
+
296
+ export class RuntimeReceiptPersistenceError extends Error {
297
+ readonly receiptKeys: string[];
298
+ readonly errors: unknown[];
299
+
300
+ constructor(input: {
301
+ message: string;
302
+ receiptKeys: string[];
303
+ errors?: unknown[];
304
+ cause?: unknown;
305
+ }) {
306
+ super(input.message, { cause: input.cause });
307
+ this.name = 'RuntimeReceiptPersistenceError';
308
+ this.receiptKeys = input.receiptKeys;
309
+ this.errors =
310
+ input.errors ?? (input.cause === undefined ? [] : [input.cause]);
311
+ }
312
+ }
313
+
314
+ function receiptPersistenceError(input: {
315
+ operation: 'complete' | 'fail';
316
+ receiptKeys: string[];
317
+ cause: unknown;
318
+ }): RuntimeReceiptPersistenceError {
319
+ const keys = input.receiptKeys.filter((key) => key.trim().length > 0);
320
+ const keySummary =
321
+ keys.length === 0
322
+ ? 'unknown receipt'
323
+ : keys.length === 1
324
+ ? keys[0]!
325
+ : `${keys.length} receipts`;
326
+ return new RuntimeReceiptPersistenceError({
327
+ message: `Runtime receipt ${input.operation} persistence failed for ${keySummary}.`,
328
+ receiptKeys: keys,
329
+ cause: input.cause,
330
+ });
331
+ }
332
+
333
+ function buildBatchDurableCallReceiptKey(input: {
334
+ req: WorkerToolDispatchRunRequest;
335
+ batchOperation: string;
336
+ receiptKeys: string[];
337
+ }): string {
338
+ return buildDurableToolAggregateReceiptKey({
339
+ receiptKeys: input.receiptKeys,
340
+ prefix: 'batch',
341
+ aggregateReceiptPrefix: buildDurableToolReceiptPrefix({
342
+ orgId: input.req.orgId,
343
+ toolId: input.batchOperation,
344
+ }),
345
+ });
346
+ }
347
+
348
+ function buildBatchProviderIdempotencyKey(input: {
349
+ aggregateReceiptKey: string;
350
+ receiptKeys: string[];
351
+ providerIdempotencyKeys: string[];
352
+ }): string {
353
+ return buildDurableToolAggregateProviderIdempotencyKey({
354
+ aggregateReceiptKey: input.aggregateReceiptKey,
355
+ receiptKeys: input.receiptKeys,
356
+ providerIdempotencyKeys: input.providerIdempotencyKeys,
357
+ });
358
+ }
359
+
360
+ function durableCallReceiptKeyForClaimed(input: {
361
+ req: WorkerToolDispatchRunRequest;
362
+ claimed: ClaimedWorkerToolBatchRequest;
363
+ }): string {
364
+ return input.claimed.receiptKey ?? input.claimed.request.cacheKey;
365
+ }
366
+
367
+ function providerIdempotencyKeyForClaimed(input: {
368
+ req: WorkerToolDispatchRunRequest;
369
+ claimed: ClaimedWorkerToolBatchRequest;
370
+ }): string {
371
+ const fallbackAttemptId =
372
+ (input.claimed.providerForce === true || input.claimed.request.force) &&
373
+ !input.claimed.receiptLeaseId
374
+ ? input.claimed.request.id
375
+ : null;
376
+ return buildDurableToolProviderIdempotencyKey({
377
+ receiptKey: durableCallReceiptKeyForClaimed(input),
378
+ force: input.claimed.providerForce === true || input.claimed.request.force,
379
+ receiptLeaseId: input.claimed.receiptLeaseId,
380
+ fallbackAttemptId,
381
+ });
382
+ }
383
+
384
+ export function wrapWorkerToolResult(
385
+ toolId: string,
386
+ result: unknown,
387
+ metadata: ToolResultMetadataInput | null,
388
+ status = result == null ? 'no_result' : 'completed',
389
+ ): ToolExecuteResult {
390
+ return createToolExecuteResult({
391
+ status,
392
+ result,
393
+ metadata: metadata ?? { toolId },
394
+ execution: toolExecutionMetadataForOutcome({ kind: 'live' }),
395
+ });
396
+ }
397
+
398
+ function toolMetadataFallback(toolId: string): ToolResultMetadataInput {
399
+ if (toolId === 'test_rate_limit') {
400
+ // Batched members resolve metadata through this fallback because the lean
401
+ // worker does not bundle the catalog. It MUST mirror the same
402
+ // `email_status: emailStatus({...})` contract registered in
403
+ // src/lib/integrations/test/index.ts so batched results normalize to the
404
+ // same rich email_status OBJECT (status from statusMap, catch_all as a
405
+ // signal) that the single-execute real-metadata path produces.
406
+ return {
407
+ toolId,
408
+ extractors: {
409
+ email_status: {
410
+ paths: ['email_status'],
411
+ emailStatus: {
412
+ provider: 'test',
413
+ rawStatus: ['email_status'],
414
+ catchAll: ['mx_security_gateway'],
415
+ statusMap: {
416
+ valid: { status: 'valid', verdict: 'send', verified: true },
417
+ invalid: { status: 'invalid', verdict: 'drop', verified: false },
418
+ catch_all: {
419
+ status: 'catch_all',
420
+ verdict: 'verify_next',
421
+ verified: false,
422
+ },
423
+ unknown: { status: 'unknown', verdict: 'hold', verified: false },
424
+ },
425
+ },
426
+ },
427
+ },
428
+ targetGetters: {
429
+ email: ['email', 'value'],
430
+ email_status: ['email_status'],
431
+ },
432
+ };
433
+ }
434
+ return { toolId };
435
+ }
436
+
437
+ function workerDurableToolCallCacheKey(input: {
438
+ req: WorkerToolDispatchRunRequest;
439
+ toolId: string;
440
+ requestInput: Record<string, unknown>;
441
+ providerActionVersion: string;
442
+ executionAuthScopeDigest: string;
443
+ staleAfterSeconds?: number | null;
444
+ }): string {
445
+ return buildDurableToolCallCacheKey({
446
+ orgId: input.req.orgId,
447
+ playLocalScope: input.req.playName,
448
+ toolId: input.toolId,
449
+ requestInput: input.requestInput,
450
+ authScopeDigest: buildDurableToolCallAuthScopeDigest({
451
+ orgId: input.req.orgId,
452
+ toolId: input.toolId,
453
+ executionAuthScopeDigest: input.executionAuthScopeDigest,
454
+ }),
455
+ providerActionVersion: input.providerActionVersion,
456
+ staleAfterSeconds: input.staleAfterSeconds,
457
+ });
458
+ }
459
+
460
+ function workerRuntimeReceiptKey(input: {
461
+ req: WorkerToolDispatchRunRequest;
462
+ key: string;
463
+ }): string {
464
+ const orgId = input.req.orgId?.trim() || 'org';
465
+ if (input.key.startsWith(`ctx:${orgId}:`)) {
466
+ return input.key;
467
+ }
468
+ return buildScopedWorkReceiptKey({
469
+ orgId: input.req.orgId,
470
+ playName: input.req.playName,
471
+ key: input.key,
472
+ });
473
+ }
474
+
475
+ export class WorkerToolBatchScheduler {
476
+ private queue: WorkerToolBatchRequest[] = [];
477
+ private scheduled = false;
478
+ private drainTimer: ReturnType<typeof setTimeout> | null = null;
479
+ private immediateDrainQueued = false;
480
+
481
+ constructor(private readonly options: WorkerToolBatchSchedulerOptions) {}
482
+
483
+ private runtimeDeadlineRemainingMs(): number {
484
+ if (typeof this.options.runtimeDeadlineMs !== 'number') {
485
+ return Number.POSITIVE_INFINITY;
486
+ }
487
+ return Math.max(0, this.options.runtimeDeadlineMs - this.options.nowMs());
488
+ }
489
+
490
+ private async completeReceiptWithRetryLadder<T>(
491
+ run: () => Promise<T>,
492
+ onGiveUp?: (attempts: number) => void,
493
+ ): Promise<T> {
494
+ if (!this.options.persistenceLatch && !this.options.receiptSalvage) {
495
+ return await run();
496
+ }
497
+ const latch = this.options.persistenceLatch;
498
+ let attempt = 0;
499
+ let committedToFullLadder = false;
500
+ const giveUp = (error: unknown): never => {
501
+ if (latch) {
502
+ latch.latchRetryBudgetExhausted = true;
503
+ tripRuntimePersistenceLatch(latch, error);
504
+ }
505
+ onGiveUp?.(attempt);
506
+ throw error;
507
+ };
508
+ while (true) {
509
+ try {
510
+ return await run();
511
+ } catch (error) {
512
+ attempt += 1;
513
+ if (
514
+ this.options.abortSignal?.aborted ||
515
+ attempt > COMPLETE_RECEIPT_RETRY_BACKOFFS_MS.length ||
516
+ (latch &&
517
+ shouldShortenCompleteReceiptLadder(latch, committedToFullLadder))
518
+ ) {
519
+ return giveUp(error);
520
+ }
521
+ const backoffMs = COMPLETE_RECEIPT_RETRY_BACKOFFS_MS[attempt - 1]!;
522
+ if (
523
+ this.runtimeDeadlineRemainingMs() <=
524
+ backoffMs + COMPLETE_RECEIPT_RETRY_MIN_HEADROOM_MS
525
+ ) {
526
+ return giveUp(error);
527
+ }
528
+ committedToFullLadder = true;
529
+ await sleepWorkerMs(backoffMs);
530
+ }
531
+ }
532
+ }
533
+
534
+ private recordReceiptSalvage(input: {
535
+ claimed: ClaimedWorkerToolBatchRequest;
536
+ output: unknown;
537
+ attempts: number;
538
+ }): void {
539
+ if (!this.options.receiptSalvage || !input.claimed.receiptKey) return;
540
+ this.options.receiptSalvage.push({
541
+ receiptKey: input.claimed.receiptKey,
542
+ toolId: input.claimed.request.toolId,
543
+ cacheKey: input.claimed.request.cacheKey,
544
+ output: input.output,
545
+ completeAttempts: input.attempts,
546
+ firstErrorAt: this.options.nowMs(),
547
+ });
548
+ }
549
+
550
+ private runtimePersistenceCircuitOpenError(
551
+ skippedCalls: number,
552
+ ): Error | null {
553
+ const latch = this.options.persistenceLatch;
554
+ if (!latch?.tripped) return null;
555
+ latch.preventedCallCount += Math.max(1, skippedCalls);
556
+ return new RuntimePersistenceCircuitOpenError(latch);
557
+ }
558
+
559
+ /**
560
+ * Report a provider 429 / Retry-After back into the Governor's shared pacer
561
+ * so future acquires for this (org, provider) bucket back off across all
562
+ * isolates. Provider comes from the same pacing resolver the Governor uses
563
+ * (the worker has no local catalog), so callers pass only the toolId.
564
+ */
565
+ private reportBackpressure(toolId: string, retryAfterMs: number): void {
566
+ if (retryAfterMs <= 0) return;
567
+ void (async () => {
568
+ const pacing = await this.options.resolvePacing(toolId).catch(() => null);
569
+ if (pacing?.provider) {
570
+ this.options.governor.reportProviderBackpressure({
571
+ provider: pacing.provider,
572
+ retryAfterMs,
573
+ });
574
+ }
575
+ })();
576
+ }
577
+
578
+ async execute(
579
+ id: string,
580
+ toolId: string,
581
+ input: Record<string, unknown>,
582
+ workflowStep?: WorkflowStepLike,
583
+ options?: {
584
+ force?: boolean;
585
+ runForceRefresh?: boolean;
586
+ runForceFailedRefresh?: boolean;
587
+ staleAfterSeconds?: number | null;
588
+ timeoutMs?: number;
589
+ receiptWaitMs?: number;
590
+ },
591
+ ): Promise<unknown> {
592
+ this.options.budgetMeter?.count('tool');
593
+ const [providerActionVersion, executionAuthScopeDigest] = await Promise.all(
594
+ [
595
+ this.options.resolveToolActionCacheVersion(toolId),
596
+ this.options.resolveToolAuthScopeDigest(toolId),
597
+ ],
598
+ );
599
+ return await new Promise((resolve, reject) => {
600
+ const queuedAt = this.options.nowMs();
601
+ const receiptKey = workerDurableToolCallCacheKey({
602
+ req: this.options.req,
603
+ toolId,
604
+ requestInput: input,
605
+ providerActionVersion,
606
+ executionAuthScopeDigest,
607
+ staleAfterSeconds: options?.staleAfterSeconds,
608
+ });
609
+ const queryResultDatasetRead = isQueryResultDatasetReadRequest(
610
+ toolId,
611
+ input,
612
+ );
613
+ const receiptEligible = !queryResultDatasetRead;
614
+ this.queue.push({
615
+ id,
616
+ cacheKey: receiptEligible ? receiptKey : '',
617
+ receiptKey: receiptEligible ? receiptKey : null,
618
+ executionAuthScopeDigest,
619
+ force: options?.force === true,
620
+ runForceRefresh: options?.runForceRefresh === true,
621
+ runForceFailedRefresh: options?.runForceFailedRefresh === true,
622
+ staleAfterSeconds: options?.staleAfterSeconds ?? null,
623
+ receiptWaitMaxAttempts: resolveRuntimeToolReceiptWaitMaxAttempts(
624
+ typeof options?.receiptWaitMs === 'number'
625
+ ? { max_wait_ms: options.receiptWaitMs }
626
+ : input,
627
+ ),
628
+ runtimeTimeoutMs: options?.timeoutMs,
629
+ toolId,
630
+ input,
631
+ workflowStep,
632
+ ...(queryResultDatasetRead
633
+ ? {
634
+ directOptions: {
635
+ customerDbDataset: {
636
+ offset: 0,
637
+ pageSize: QUERY_RESULT_DATASET_PAGE_SIZE,
638
+ },
639
+ },
640
+ }
641
+ : {}),
642
+ resolve: (value) => {
643
+ this.options.recordPerfTrace?.({
644
+ phase: 'runner.tool.request',
645
+ ms: this.options.nowMs() - queuedAt,
646
+ extra: { id, toolId },
647
+ });
648
+ resolve(value);
649
+ },
650
+ reject,
651
+ });
652
+ this.scheduleDrain();
653
+ });
654
+ }
655
+
656
+ private scheduleDrain(): void {
657
+ if (this.shouldDrainQueuedToolsImmediately()) {
658
+ this.scheduleImmediateDrain();
659
+ return;
660
+ }
661
+ if (this.scheduled) return;
662
+ this.scheduled = true;
663
+ this.drainTimer = setTimeout(() => {
664
+ this.drainTimer = null;
665
+ this.scheduleImmediateDrain();
666
+ }, this.options.batchGraceMs ?? WORKER_TOOL_BATCH_GRACE_MS);
667
+ }
668
+
669
+ private scheduleImmediateDrain(): void {
670
+ if (this.drainTimer) {
671
+ clearTimeout(this.drainTimer);
672
+ this.drainTimer = null;
673
+ }
674
+ if (this.immediateDrainQueued) return;
675
+ this.scheduled = true;
676
+ this.immediateDrainQueued = true;
677
+ queueMicrotask(() => {
678
+ this.immediateDrainQueued = false;
679
+ void this.drain().catch((error) => {
680
+ const queued = this.queue;
681
+ this.queue = [];
682
+ this.scheduled = false;
683
+ if (queued.length > 0) {
684
+ this.rejectRawRequests(queued, error);
685
+ }
686
+ });
687
+ });
688
+ }
689
+
690
+ private shouldDrainQueuedToolsImmediately(): boolean {
691
+ if (!this.hasBatchableQueuedTool()) return true;
692
+ const byTool = new Map<string, number>();
693
+ for (const request of this.queue) {
694
+ if (request.toolId === 'test_wait_for_event') continue;
695
+ const strategy = getPlayRuntimeBatchStrategy(request.toolId);
696
+ if (!strategy) continue;
697
+ const nextCount = (byTool.get(request.toolId) ?? 0) + 1;
698
+ if (nextCount >= strategy.maxBatchSize) {
699
+ return true;
700
+ }
701
+ byTool.set(request.toolId, nextCount);
702
+ }
703
+ return false;
704
+ }
705
+
706
+ private hasBatchableQueuedTool(): boolean {
707
+ return this.queue.some(
708
+ (request) =>
709
+ request.toolId !== 'test_wait_for_event' &&
710
+ getPlayRuntimeBatchStrategy(request.toolId) !== null,
711
+ );
712
+ }
713
+
714
+ private async drain(): Promise<void> {
715
+ const requests = this.queue;
716
+ this.queue = [];
717
+ this.scheduled = false;
718
+ const drainStartedAt = this.options.nowMs();
719
+ await Promise.all(
720
+ [...groupWorkerToolRequestsByTool(requests).entries()].map(
721
+ async ([toolId, groupedRequests]) => {
722
+ try {
723
+ await this.executeToolGroup(toolId, groupedRequests);
724
+ } catch (error) {
725
+ this.rejectRawRequests(groupedRequests, error);
726
+ }
727
+ },
728
+ ),
729
+ );
730
+ this.options.recordPerfTrace?.({
731
+ phase: 'runner.tool.drain',
732
+ ms: this.options.nowMs() - drainStartedAt,
733
+ extra: { requests: requests.length },
734
+ });
735
+ if (this.queue.length > 0) {
736
+ this.scheduleDrain();
737
+ }
738
+ }
739
+
740
+ private async waitForDurableToolReceipt(input: {
741
+ receiptKey: string;
742
+ maxAttempts: number;
743
+ }): Promise<unknown> {
744
+ if (!this.options.receiptStore) {
745
+ throw new Error('No receipt store.');
746
+ }
747
+ if (!this.options.receiptStore.getReceipt) {
748
+ throw new Error('No receipt lookup.');
749
+ }
750
+ this.options.budgetMeter?.count('receipt');
751
+ const receipt = await waitForCompletedRuntimeReceipt({
752
+ receiptKey: input.receiptKey,
753
+ maxAttempts: input.maxAttempts,
754
+ abortSignal: this.options.abortSignal,
755
+ store: {
756
+ getMany: async (receiptKeys) => {
757
+ const receipts = new Map<string, RuntimeStepReceipt>();
758
+ await Promise.all(
759
+ receiptKeys.map(async (key) => {
760
+ const receipt = await this.options.receiptStore!.getReceipt!({
761
+ playName: this.options.req.playName,
762
+ key,
763
+ });
764
+ if (!receipt) return;
765
+ receipts.set(key, {
766
+ key: receipt.key,
767
+ status: receipt.status,
768
+ output: receipt.output,
769
+ error: receipt.error ?? undefined,
770
+ runId: receipt.runId ?? null,
771
+ });
772
+ }),
773
+ );
774
+ return receipts;
775
+ },
776
+ },
777
+ });
778
+ return markWorkerToolReceiptResultCached(
779
+ this.options.deserializeDurableValue(receipt.output),
780
+ input.receiptKey,
781
+ input.receiptKey,
782
+ );
783
+ }
784
+
785
+ private settleRequests(
786
+ claimed: ClaimedWorkerToolBatchRequest,
787
+ result: unknown,
788
+ ): void {
789
+ claimed.request.resolve(result);
790
+ for (const follower of claimed.followers) {
791
+ follower.resolve(
792
+ claimed.receiptKey
793
+ ? markWorkerToolReceiptResultExecution(result, {
794
+ kind: 'in_flight',
795
+ receiptKey: claimed.receiptKey,
796
+ attachedToReceiptKey: claimed.receiptKey,
797
+ })
798
+ : result,
799
+ );
800
+ }
801
+ this.options.onRequestsSettled?.(1 + claimed.followers.length);
802
+ }
803
+
804
+ private rejectRequests(
805
+ claimed: ClaimedWorkerToolBatchRequest,
806
+ error: unknown,
807
+ ): void {
808
+ claimed.request.reject(error);
809
+ for (const follower of claimed.followers) {
810
+ follower.reject(error);
811
+ }
812
+ this.options.onRequestsSettled?.(1 + claimed.followers.length);
813
+ }
814
+
815
+ private rejectRawRequests(
816
+ requests: WorkerToolBatchRequest[],
817
+ error: unknown,
818
+ ): void {
819
+ for (const request of requests) {
820
+ request.reject(error);
821
+ }
822
+ this.options.onRequestsSettled?.(requests.length);
823
+ }
824
+
825
+ private async requeueAfterAuthScopeChanged(
826
+ requests: WorkerToolBatchRequest[],
827
+ error: unknown,
828
+ ): Promise<boolean> {
829
+ if (!(error instanceof ToolExecuteAuthScopeChangedError)) {
830
+ return false;
831
+ }
832
+ const [first] = requests;
833
+ if (!first) return false;
834
+ if (requests.some((request) => request.authScopeRetryUsed === true)) {
835
+ return false;
836
+ }
837
+ this.options.invalidateToolAuthScopeDigest(first.toolId);
838
+ const [providerActionVersion, executionAuthScopeDigest] = await Promise.all(
839
+ [
840
+ this.options.resolveToolActionCacheVersion(first.toolId),
841
+ this.options.resolveToolAuthScopeDigest(first.toolId),
842
+ ],
843
+ );
844
+ const requeued = requests.map((request) => ({
845
+ request,
846
+ receiptKey: workerDurableToolCallCacheKey({
847
+ req: this.options.req,
848
+ toolId: request.toolId,
849
+ requestInput: request.input,
850
+ providerActionVersion,
851
+ executionAuthScopeDigest,
852
+ staleAfterSeconds: request.staleAfterSeconds,
853
+ }),
854
+ }));
855
+ // If the re-resolved scope produced the exact same receipt keys, the
856
+ // credential identity did NOT actually change from this isolate's point of
857
+ // view (stale cache, no-op invalidation, or a racing revert). Requeuing
858
+ // would claim the same key this group just left `running` and stall on its
859
+ // own abandoned receipt until the lease TTL. Refuse instead: the caller
860
+ // rejects the request with the typed 409 error — loud and immediate.
861
+ if (
862
+ requeued.every(
863
+ (entry) => entry.receiptKey === (entry.request.receiptKey ?? null),
864
+ )
865
+ ) {
866
+ return false;
867
+ }
868
+ for (const { request, receiptKey } of requeued) {
869
+ this.queue.push({
870
+ ...request,
871
+ cacheKey: receiptKey,
872
+ receiptKey,
873
+ executionAuthScopeDigest,
874
+ authScopeRetryUsed: true,
875
+ });
876
+ }
877
+ this.options.onAuthScopeReclaim?.({
878
+ toolId: first.toolId,
879
+ requestIds: requests.map((request) => request.id),
880
+ previousReceiptKeys: requests.map(
881
+ (request) => request.receiptKey ?? null,
882
+ ),
883
+ nextReceiptKeys: requeued.map((entry) => entry.receiptKey),
884
+ });
885
+ this.scheduleDrain();
886
+ return true;
887
+ }
888
+
889
+ private async reclaimTimedOutDurableToolReceiptGroup(input: {
890
+ group: WorkerToolBatchRequest[];
891
+ receiptKey: string;
892
+ runningReceipt: WorkerRuntimeReceipt;
893
+ waitError: unknown;
894
+ }): Promise<ClaimedWorkerToolBatchRequest[]> {
895
+ const [request, ...followers] = input.group;
896
+ if (!request || !this.options.receiptStore) {
897
+ this.rejectRawRequests(input.group, input.waitError);
898
+ return [];
899
+ }
900
+ if (
901
+ !canReclaimTimedOutWorkerToolReceipt({
902
+ ownerRunId: input.runningReceipt.runId,
903
+ currentRunId: this.options.req.runId,
904
+ })
905
+ ) {
906
+ this.rejectRawRequests(input.group, input.waitError);
907
+ return [];
908
+ }
909
+ let claim: WorkerRuntimeReceiptClaim;
910
+ try {
911
+ this.options.budgetMeter?.count('receipt');
912
+ claim = await this.options.receiptStore.claimReceipt({
913
+ playName: this.options.req.playName,
914
+ runId: this.options.req.runId,
915
+ runAttempt: this.options.req.runAttempt ?? 0,
916
+ key: input.receiptKey,
917
+ leaseAware: true,
918
+ leaseTtlMs: this.options.runtimeReceiptLeaseTtlMs,
919
+ reclaimRunning: true,
920
+ });
921
+ } catch (error) {
922
+ this.rejectRawRequests(input.group, error);
923
+ return [];
924
+ }
925
+ if (claim.disposition === 'claimed') {
926
+ return [
927
+ {
928
+ request,
929
+ receiptKey: input.receiptKey,
930
+ receiptLeaseId: claim.receipt.leaseId ?? null,
931
+ providerForce: true,
932
+ followers,
933
+ },
934
+ ];
935
+ }
936
+ if (claim.disposition === 'reused') {
937
+ const result = markWorkerToolReceiptResultCached(
938
+ this.options.deserializeDurableValue(claim.receipt.output),
939
+ request.cacheKey,
940
+ input.receiptKey,
941
+ );
942
+ for (const pending of input.group) {
943
+ pending.resolve(result);
944
+ }
945
+ this.options.onRequestsSettled?.(input.group.length);
946
+ return [];
947
+ }
948
+ if (claim.disposition === 'failed') {
949
+ this.rejectRawRequests(
950
+ input.group,
951
+ new Error(
952
+ `Durable tool call ${input.receiptKey} failed: ${claim.receipt.error ?? 'unknown error'}`,
953
+ ),
954
+ );
955
+ return [];
956
+ }
957
+ this.rejectRawRequests(input.group, input.waitError);
958
+ return [];
959
+ }
960
+
961
+ private async claimForcedDurableToolReceiptGroupAfterWait(input: {
962
+ group: WorkerToolBatchRequest[];
963
+ receiptKey: string;
964
+ }): Promise<ClaimedWorkerToolBatchRequest[]> {
965
+ const [request, ...followers] = input.group;
966
+ if (!request || !this.options.receiptStore) {
967
+ this.rejectRawRequests(
968
+ input.group,
969
+ new Error(
970
+ `No receipt store for forced durable tool call ${input.receiptKey}.`,
971
+ ),
972
+ );
973
+ return [];
974
+ }
975
+ let claim: WorkerRuntimeReceiptClaim;
976
+ try {
977
+ this.options.budgetMeter?.count('receipt');
978
+ claim = await this.options.receiptStore.claimReceipt({
979
+ playName: this.options.req.playName,
980
+ runId: this.options.req.runId,
981
+ runAttempt: this.options.req.runAttempt ?? 0,
982
+ key: input.receiptKey,
983
+ leaseAware: true,
984
+ leaseTtlMs: this.options.runtimeReceiptLeaseTtlMs,
985
+ forceRefresh: true,
986
+ reclaimRunning: true,
987
+ });
988
+ } catch (error) {
989
+ this.rejectRawRequests(input.group, error);
990
+ return [];
991
+ }
992
+ if (claim.disposition === 'claimed') {
993
+ return [
994
+ {
995
+ request,
996
+ receiptKey: input.receiptKey,
997
+ receiptLeaseId: claim.receipt.leaseId ?? null,
998
+ providerForce: true,
999
+ followers,
1000
+ },
1001
+ ];
1002
+ }
1003
+ const error =
1004
+ claim.disposition === 'failed'
1005
+ ? new Error(
1006
+ `Durable tool call ${input.receiptKey} failed: ${claim.receipt.error ?? 'unknown error'}`,
1007
+ )
1008
+ : new Error(
1009
+ `Forced durable tool call ${input.receiptKey} could not claim execution ownership after the active lease resolved; disposition=${claim.disposition}.`,
1010
+ );
1011
+ this.rejectRawRequests(input.group, error);
1012
+ return [];
1013
+ }
1014
+
1015
+ private async failureForRejectedToolRequest(
1016
+ claimed: ClaimedWorkerToolBatchRequest,
1017
+ error: unknown,
1018
+ ): Promise<unknown> {
1019
+ try {
1020
+ if (
1021
+ isPlayExecutionSuspendedError(error) ||
1022
+ isPlayRowExecutionSuspendedError(error)
1023
+ ) {
1024
+ await this.releaseDurableToolRequest(claimed, error);
1025
+ } else {
1026
+ await this.failDurableToolRequest(claimed, error);
1027
+ }
1028
+ return error;
1029
+ } catch (receiptError) {
1030
+ return receiptPersistenceError({
1031
+ operation: 'fail',
1032
+ receiptKeys: claimed.receiptKey ? [claimed.receiptKey] : [],
1033
+ cause: new AggregateError(
1034
+ [error, receiptError],
1035
+ 'Receipt fail mark failed',
1036
+ ),
1037
+ });
1038
+ }
1039
+ }
1040
+
1041
+ private async claimDurableToolReceiptGroups(
1042
+ groups: ReturnType<
1043
+ typeof planWorkerToolReceiptGroups<WorkerToolBatchRequest>
1044
+ >['durableGroups'],
1045
+ ): Promise<
1046
+ Array<{
1047
+ group: ReturnType<
1048
+ typeof planWorkerToolReceiptGroups<WorkerToolBatchRequest>
1049
+ >['durableGroups'][number];
1050
+ receiptKey: string;
1051
+ claim: WorkerRuntimeReceiptClaim;
1052
+ }>
1053
+ > {
1054
+ if (!this.options.receiptStore) return [];
1055
+ const planned = groups.map((group) => ({
1056
+ group,
1057
+ receiptKey: workerRuntimeReceiptKey({
1058
+ req: this.options.req,
1059
+ key: group.claimableReceiptKey,
1060
+ }),
1061
+ }));
1062
+ const claimOne = async (entry: (typeof planned)[number]) => {
1063
+ this.options.budgetMeter?.count('receipt');
1064
+ return {
1065
+ ...entry,
1066
+ claim: await this.options.receiptStore!.claimReceipt({
1067
+ playName: this.options.req.playName,
1068
+ runId: this.options.req.runId,
1069
+ runAttempt: this.options.req.runAttempt ?? 0,
1070
+ key: entry.receiptKey,
1071
+ leaseAware: true,
1072
+ leaseTtlMs: this.options.runtimeReceiptLeaseTtlMs,
1073
+ ...(entry.group.forceDurableRefresh
1074
+ ? { forceRefresh: true }
1075
+ : entry.group.forceFailedDurableRefresh
1076
+ ? { forceFailedRefresh: true }
1077
+ : {}),
1078
+ }),
1079
+ };
1080
+ };
1081
+ if (!this.options.receiptStore.claimReceipts) {
1082
+ return await Promise.all(planned.map(claimOne));
1083
+ }
1084
+ const claimed: Array<{
1085
+ group: (typeof planned)[number]['group'];
1086
+ receiptKey: string;
1087
+ claim: WorkerRuntimeReceiptClaim;
1088
+ }> = [];
1089
+ for (const claimMode of ['normal', 'failed', 'full'] as const) {
1090
+ const entries = planned.filter((entry) =>
1091
+ claimMode === 'full'
1092
+ ? entry.group.forceDurableRefresh
1093
+ : claimMode === 'failed'
1094
+ ? !entry.group.forceDurableRefresh &&
1095
+ entry.group.forceFailedDurableRefresh
1096
+ : !entry.group.forceDurableRefresh &&
1097
+ !entry.group.forceFailedDurableRefresh,
1098
+ );
1099
+ if (entries.length === 0) continue;
1100
+ // Bulk claim is a single service-binding RPC regardless of key count, so
1101
+ // charge one subrequest per RPC issued (once per claimMode page), not one
1102
+ // per receipt key.
1103
+ this.options.budgetMeter?.count('receipt');
1104
+ const claims = await this.options.receiptStore.claimReceipts({
1105
+ playName: this.options.req.playName,
1106
+ runId: this.options.req.runId,
1107
+ runAttempt: this.options.req.runAttempt ?? 0,
1108
+ keys: entries.map((entry) => entry.receiptKey),
1109
+ leaseAware: true,
1110
+ leaseTtlMs: this.options.runtimeReceiptLeaseTtlMs,
1111
+ ...(claimMode === 'full'
1112
+ ? { forceRefresh: true }
1113
+ : claimMode === 'failed'
1114
+ ? { forceFailedRefresh: true }
1115
+ : {}),
1116
+ });
1117
+ entries.forEach((entry, index) => {
1118
+ const claim = claims[index];
1119
+ if (!claim) {
1120
+ throw new Error(
1121
+ `Runtime receipt batch claim did not return receipt ${entry.receiptKey}.`,
1122
+ );
1123
+ }
1124
+ claimed.push({ ...entry, claim });
1125
+ });
1126
+ }
1127
+ return claimed;
1128
+ }
1129
+
1130
+ private async prepareDurableToolRequests(
1131
+ requests: WorkerToolBatchRequest[],
1132
+ ): Promise<PreparedWorkerToolBatchRequests> {
1133
+ if (!this.options.receiptStore) {
1134
+ return {
1135
+ claimedRequests: requests.map((request) => ({
1136
+ request,
1137
+ receiptKey: null,
1138
+ receiptLeaseId: null,
1139
+ followers: [],
1140
+ })),
1141
+ deferredClaimedRequests: [],
1142
+ };
1143
+ }
1144
+
1145
+ const claimedRequests: ClaimedWorkerToolBatchRequest[] = [];
1146
+ const deferredClaimedRequests: Promise<ClaimedWorkerToolBatchRequest[]>[] =
1147
+ [];
1148
+ const receiptGroupPlan = planWorkerToolReceiptGroups(requests, {
1149
+ allowLocalRetryReceipts: this.options.allowLocalRetryReceipts === true,
1150
+ getReceiptInput: (request) => ({
1151
+ durableReceiptKey: request.receiptKey,
1152
+ localRetryCacheKey: request.cacheKey,
1153
+ force: request.force || request.runForceRefresh,
1154
+ forceFailed: request.runForceFailedRefresh,
1155
+ }),
1156
+ });
1157
+ for (const request of receiptGroupPlan.localRequests) {
1158
+ claimedRequests.push({
1159
+ request,
1160
+ receiptKey: null,
1161
+ receiptLeaseId: null,
1162
+ followers: [],
1163
+ });
1164
+ }
1165
+
1166
+ try {
1167
+ const claimEntries = await this.claimDurableToolReceiptGroups(
1168
+ receiptGroupPlan.durableGroups,
1169
+ );
1170
+ for (const { group: groupState, receiptKey, claim } of claimEntries) {
1171
+ const group = groupState.requests;
1172
+ const [first] = group;
1173
+ if (!first) continue;
1174
+ if (
1175
+ claim.disposition === 'failed' &&
1176
+ !groupState.forceDurableRefresh &&
1177
+ group.some((request) => request.runForceRefresh === true)
1178
+ ) {
1179
+ deferredClaimedRequests.push(
1180
+ this.claimForcedDurableToolReceiptGroupAfterWait({
1181
+ group,
1182
+ receiptKey,
1183
+ }),
1184
+ );
1185
+ continue;
1186
+ }
1187
+ if (claim.disposition === 'reused') {
1188
+ const result = markWorkerToolReceiptResultCached(
1189
+ this.options.deserializeDurableValue(claim.receipt.output),
1190
+ first.cacheKey,
1191
+ receiptKey,
1192
+ );
1193
+ for (const request of group) {
1194
+ request.resolve(result);
1195
+ }
1196
+ this.options.onRequestsSettled?.(group.length);
1197
+ continue;
1198
+ }
1199
+ if (claim.disposition === 'failed') {
1200
+ if (
1201
+ canReclaimFailedWorkerToolReceipt({
1202
+ error: claim.receipt.error ?? null,
1203
+ })
1204
+ ) {
1205
+ deferredClaimedRequests.push(
1206
+ this.claimForcedDurableToolReceiptGroupAfterWait({
1207
+ group,
1208
+ receiptKey,
1209
+ }),
1210
+ );
1211
+ continue;
1212
+ }
1213
+ this.rejectRawRequests(
1214
+ group,
1215
+ new Error(
1216
+ `Durable tool call ${receiptKey} failed: ${claim.receipt.error ?? 'unknown error'}`,
1217
+ ),
1218
+ );
1219
+ continue;
1220
+ }
1221
+ if (
1222
+ claim.disposition === 'running' ||
1223
+ claim.disposition === 'blocked_active_lease'
1224
+ ) {
1225
+ deferredClaimedRequests.push(
1226
+ (async (): Promise<ClaimedWorkerToolBatchRequest[]> => {
1227
+ let waitError: unknown = new RuntimeReceiptWaitTimeoutError(
1228
+ receiptKey,
1229
+ );
1230
+ try {
1231
+ const result = await this.waitForDurableToolReceipt({
1232
+ receiptKey,
1233
+ maxAttempts: resolveWorkerToolReceiptGroupWaitMaxAttempts(
1234
+ group,
1235
+ (request) => request.receiptWaitMaxAttempts,
1236
+ ),
1237
+ });
1238
+ if (groupState.forceDurableRefresh) {
1239
+ return await this.claimForcedDurableToolReceiptGroupAfterWait(
1240
+ {
1241
+ group,
1242
+ receiptKey,
1243
+ },
1244
+ );
1245
+ }
1246
+ for (const request of group) {
1247
+ request.resolve(
1248
+ markWorkerToolReceiptResultExecution(result, {
1249
+ kind: 'in_flight',
1250
+ receiptKey,
1251
+ attachedToReceiptKey: receiptKey,
1252
+ }),
1253
+ );
1254
+ }
1255
+ this.options.onRequestsSettled?.(group.length);
1256
+ return [];
1257
+ } catch (error) {
1258
+ waitError = error;
1259
+ if (!(error instanceof RuntimeReceiptWaitTimeoutError)) {
1260
+ this.rejectRawRequests(group, error);
1261
+ return [];
1262
+ }
1263
+ }
1264
+ return await this.reclaimTimedOutDurableToolReceiptGroup({
1265
+ group,
1266
+ receiptKey,
1267
+ runningReceipt: claim.receipt,
1268
+ waitError,
1269
+ });
1270
+ })(),
1271
+ );
1272
+ continue;
1273
+ }
1274
+ const [request, ...followers] = group;
1275
+ if (!request) continue;
1276
+ claimedRequests.push({
1277
+ request,
1278
+ receiptKey,
1279
+ receiptLeaseId: claim.receipt.leaseId ?? null,
1280
+ providerForce: groupState.forceDurableRefresh,
1281
+ followers,
1282
+ });
1283
+ }
1284
+ } catch (error) {
1285
+ for (const group of receiptGroupPlan.durableGroups) {
1286
+ this.rejectRawRequests(group.requests, error);
1287
+ }
1288
+ }
1289
+ return { claimedRequests, deferredClaimedRequests };
1290
+ }
1291
+
1292
+ private async completeDurableToolRequest(
1293
+ claimed: ClaimedWorkerToolBatchRequest,
1294
+ result: unknown,
1295
+ ): Promise<unknown> {
1296
+ if (!this.options.receiptStore || !claimed.receiptKey) {
1297
+ return result;
1298
+ }
1299
+ const ownerResult = markWorkerToolReceiptResultExecution(result, {
1300
+ kind: 'live',
1301
+ receiptKey: claimed.receiptKey,
1302
+ });
1303
+ this.options.budgetMeter?.count('receipt');
1304
+ let completed: WorkerRuntimeReceipt | null;
1305
+ try {
1306
+ let completeAttempts = 0;
1307
+ const output = this.options.serializeDurableValue(ownerResult);
1308
+ completed = await this.completeReceiptWithRetryLadder(
1309
+ async () => {
1310
+ completeAttempts += 1;
1311
+ return await this.options.receiptStore!.completeReceipt({
1312
+ playName: this.options.req.playName,
1313
+ runId: this.options.req.runId,
1314
+ runAttempt: this.options.req.runAttempt ?? 0,
1315
+ key: claimed.receiptKey!,
1316
+ leaseId: claimed.receiptLeaseId,
1317
+ output,
1318
+ });
1319
+ },
1320
+ (attempts) =>
1321
+ this.recordReceiptSalvage({
1322
+ claimed,
1323
+ output,
1324
+ attempts: Math.max(attempts, completeAttempts),
1325
+ }),
1326
+ );
1327
+ } catch (error) {
1328
+ throw receiptPersistenceError({
1329
+ operation: 'complete',
1330
+ receiptKeys: [claimed.receiptKey],
1331
+ cause: error,
1332
+ });
1333
+ }
1334
+ const completedResult = this.completedDurableToolRequestResult({
1335
+ claimed,
1336
+ ownerResult,
1337
+ completed,
1338
+ });
1339
+ if (completedResult.ok) return completedResult.value;
1340
+ throw completedResult.error;
1341
+ }
1342
+
1343
+ private completedDurableToolRequestResult(input: {
1344
+ claimed: ClaimedWorkerToolBatchRequest;
1345
+ ownerResult: unknown;
1346
+ completed: WorkerRuntimeReceipt | null | undefined;
1347
+ }): WorkerToolCompletionResult {
1348
+ const { claimed, completed, ownerResult } = input;
1349
+ if (
1350
+ completed &&
1351
+ (completed.status === 'completed' || completed.status === 'skipped') &&
1352
+ completed.output !== undefined
1353
+ ) {
1354
+ const recovered = this.options.deserializeDurableValue(completed.output);
1355
+ return {
1356
+ ok: true,
1357
+ value:
1358
+ completed.runId && completed.runId !== this.options.req.runId
1359
+ ? markWorkerToolReceiptResultCached(
1360
+ recovered,
1361
+ claimed.request.cacheKey,
1362
+ claimed.receiptKey ?? claimed.request.cacheKey,
1363
+ )
1364
+ : recovered,
1365
+ };
1366
+ }
1367
+ if (completed?.status === 'failed') {
1368
+ const receiptKey = claimed.receiptKey ?? claimed.request.cacheKey;
1369
+ return {
1370
+ ok: false,
1371
+ error: receiptPersistenceError({
1372
+ operation: 'complete',
1373
+ receiptKeys: [receiptKey],
1374
+ cause: completed.error
1375
+ ? new Error(completed.error)
1376
+ : new Error(
1377
+ `Durable tool call ${receiptKey} recovered failed receipt completion without an error message.`,
1378
+ ),
1379
+ }),
1380
+ };
1381
+ }
1382
+ return {
1383
+ ok: false,
1384
+ error: receiptPersistenceError({
1385
+ operation: 'complete',
1386
+ receiptKeys: [claimed.receiptKey ?? claimed.request.cacheKey],
1387
+ cause: new Error(
1388
+ `Durable tool call ${claimed.receiptKey ?? claimed.request.cacheKey} lost receipt ownership before completion.`,
1389
+ ),
1390
+ }),
1391
+ };
1392
+ }
1393
+
1394
+ private async recoverCompletedDurableToolRequestResult(input: {
1395
+ claimed: ClaimedWorkerToolBatchRequest;
1396
+ ownerResult: unknown;
1397
+ completed: WorkerRuntimeReceipt | null | undefined;
1398
+ completeAttempts: number;
1399
+ }): Promise<WorkerToolCompletionResult> {
1400
+ const completedResult = this.completedDurableToolRequestResult(input);
1401
+ if (completedResult.ok) {
1402
+ return completedResult;
1403
+ }
1404
+
1405
+ const receiptKey = input.claimed.receiptKey;
1406
+ if (!receiptKey || !this.options.receiptStore?.getReceipt) {
1407
+ return completedResult;
1408
+ }
1409
+
1410
+ let receipt: WorkerRuntimeReceipt | null = null;
1411
+ try {
1412
+ this.options.budgetMeter?.count('receipt');
1413
+ receipt = await this.options.receiptStore.getReceipt({
1414
+ playName: this.options.req.playName,
1415
+ key: receiptKey,
1416
+ });
1417
+ } catch (readError) {
1418
+ this.recordReceiptSalvage({
1419
+ claimed: input.claimed,
1420
+ output: this.options.serializeDurableValue(input.ownerResult),
1421
+ attempts: input.completeAttempts,
1422
+ });
1423
+ return {
1424
+ ok: false,
1425
+ error: receiptPersistenceError({
1426
+ operation: 'complete',
1427
+ receiptKeys: [receiptKey],
1428
+ cause: new AggregateError(
1429
+ [completedResult.error, readError],
1430
+ 'Receipt completion returned no terminal receipt and recovery read failed',
1431
+ ),
1432
+ }),
1433
+ };
1434
+ }
1435
+
1436
+ if (this.canCompleteRecoveredRunningReceipt(input.claimed, receipt)) {
1437
+ const output = this.options.serializeDurableValue(input.ownerResult);
1438
+ try {
1439
+ this.options.budgetMeter?.count('receipt');
1440
+ const completed = await this.completeReceiptWithRetryLadder(async () =>
1441
+ this.options.receiptStore!.completeReceipt({
1442
+ playName: this.options.req.playName,
1443
+ runId: this.options.req.runId,
1444
+ runAttempt: this.options.req.runAttempt ?? 0,
1445
+ key: receiptKey,
1446
+ leaseId: input.claimed.receiptLeaseId,
1447
+ output,
1448
+ }),
1449
+ );
1450
+ return this.completedDurableToolRequestResult({
1451
+ claimed: input.claimed,
1452
+ ownerResult: input.ownerResult,
1453
+ completed,
1454
+ });
1455
+ } catch (completeError) {
1456
+ this.recordReceiptSalvage({
1457
+ claimed: input.claimed,
1458
+ output,
1459
+ attempts: input.completeAttempts,
1460
+ });
1461
+ return {
1462
+ ok: false,
1463
+ error: receiptPersistenceError({
1464
+ operation: 'complete',
1465
+ receiptKeys: [receiptKey],
1466
+ cause: new AggregateError(
1467
+ [completedResult.error, completeError],
1468
+ 'Receipt completion returned no terminal receipt and owned receipt recovery failed',
1469
+ ),
1470
+ }),
1471
+ };
1472
+ }
1473
+ }
1474
+
1475
+ // Bulk complete can persist terminal receipts, throw once, then return null
1476
+ // on retry because ownership is already gone. The durable row is the source
1477
+ // of truth for that transition; read it before treating the call as lost.
1478
+ const recoveredResult = this.completedDurableToolRequestResult({
1479
+ claimed: input.claimed,
1480
+ ownerResult: input.ownerResult,
1481
+ completed: receipt,
1482
+ });
1483
+ if (recoveredResult.ok) {
1484
+ return recoveredResult;
1485
+ }
1486
+
1487
+ this.recordReceiptSalvage({
1488
+ claimed: input.claimed,
1489
+ output: this.options.serializeDurableValue(input.ownerResult),
1490
+ attempts: input.completeAttempts,
1491
+ });
1492
+ if (receipt?.status === 'failed') {
1493
+ return {
1494
+ ok: false,
1495
+ error: receiptPersistenceError({
1496
+ operation: 'complete',
1497
+ receiptKeys: [receiptKey],
1498
+ cause: receipt.error
1499
+ ? new AggregateError(
1500
+ [completedResult.error, new Error(receipt.error)],
1501
+ 'Receipt completion recovered failed terminal receipt',
1502
+ )
1503
+ : completedResult.error,
1504
+ }),
1505
+ };
1506
+ }
1507
+ return completedResult;
1508
+ }
1509
+
1510
+ private canCompleteRecoveredRunningReceipt(
1511
+ claimed: ClaimedWorkerToolBatchRequest,
1512
+ receipt: WorkerRuntimeReceipt | null,
1513
+ ): boolean {
1514
+ if (!claimed.receiptKey || !receipt || receipt.status !== 'running') {
1515
+ return false;
1516
+ }
1517
+ const ownerRunId = receipt.leaseOwnerRunId ?? receipt.runId ?? null;
1518
+ if (ownerRunId !== this.options.req.runId) {
1519
+ return false;
1520
+ }
1521
+ const ownerAttempt =
1522
+ typeof receipt.leaseOwnerAttempt === 'number'
1523
+ ? receipt.leaseOwnerAttempt
1524
+ : 0;
1525
+ if (ownerAttempt !== (this.options.req.runAttempt ?? 0)) {
1526
+ return false;
1527
+ }
1528
+ if (claimed.receiptLeaseId) {
1529
+ return receipt.leaseId === claimed.receiptLeaseId;
1530
+ }
1531
+ return receipt.leaseId == null;
1532
+ }
1533
+
1534
+ private buildDurableToolCompletionBatches(
1535
+ entries: DurableToolCompletionEntry[],
1536
+ ): DurableToolCompletionBatch[] {
1537
+ const envelopeBytes = jsonByteLength({
1538
+ playName: this.options.req.playName,
1539
+ receipts: [],
1540
+ });
1541
+ const batches: DurableToolCompletionBatch[] = [];
1542
+ let current: DurableToolCompletionBatch = {
1543
+ entries: [],
1544
+ bytes: envelopeBytes,
1545
+ };
1546
+ for (const entry of entries) {
1547
+ const entryBytes = entry.serializedBytes;
1548
+ const singleBytes = envelopeBytes + entryBytes;
1549
+ if (singleBytes > WORKER_TOOL_COMPLETE_RECEIPTS_MAX_BATCH_BYTES) {
1550
+ throw receiptPersistenceError({
1551
+ operation: 'complete',
1552
+ receiptKeys: [entry.receipt.key],
1553
+ cause: new Error(
1554
+ `Runtime receipt completion payload for ${entry.receipt.key} is ${singleBytes} bytes, exceeding the ${WORKER_TOOL_COMPLETE_RECEIPTS_MAX_BATCH_BYTES} byte service-binding cap.`,
1555
+ ),
1556
+ });
1557
+ }
1558
+ const commaBytes = current.entries.length > 0 ? 1 : 0;
1559
+ const wouldExceedBytes =
1560
+ current.bytes + commaBytes + entryBytes >
1561
+ WORKER_TOOL_COMPLETE_RECEIPTS_MAX_BATCH_BYTES;
1562
+ const wouldExceedMembers =
1563
+ current.entries.length >=
1564
+ WORKER_TOOL_COMPLETE_RECEIPTS_MAX_BATCH_MEMBERS;
1565
+ if (
1566
+ current.entries.length > 0 &&
1567
+ (wouldExceedBytes || wouldExceedMembers)
1568
+ ) {
1569
+ batches.push(current);
1570
+ current = { entries: [], bytes: envelopeBytes };
1571
+ }
1572
+ current.bytes += (current.entries.length > 0 ? 1 : 0) + entryBytes;
1573
+ current.entries.push(entry);
1574
+ }
1575
+ if (current.entries.length > 0) {
1576
+ batches.push(current);
1577
+ }
1578
+ return batches;
1579
+ }
1580
+
1581
+ private async completeDurableToolRequests(
1582
+ entries: Array<{ claimed: ClaimedWorkerToolBatchRequest; result: unknown }>,
1583
+ ): Promise<WorkerToolCompletionResult[]> {
1584
+ const results: WorkerToolCompletionResult[] = new Array(entries.length);
1585
+ const durableEntries: DurableToolCompletionEntry[] = [];
1586
+ for (let index = 0; index < entries.length; index += 1) {
1587
+ const entry = entries[index]!;
1588
+ if (!this.options.receiptStore || !entry.claimed.receiptKey) {
1589
+ results[index] = { ok: true, value: entry.result };
1590
+ continue;
1591
+ }
1592
+ const ownerResult = markWorkerToolReceiptResultExecution(entry.result, {
1593
+ kind: 'live',
1594
+ receiptKey: entry.claimed.receiptKey,
1595
+ });
1596
+ const receipt = {
1597
+ runId: this.options.req.runId,
1598
+ runAttempt: this.options.req.runAttempt ?? 0,
1599
+ key: entry.claimed.receiptKey!,
1600
+ leaseId: entry.claimed.receiptLeaseId,
1601
+ output: this.options.serializeDurableValue(ownerResult),
1602
+ };
1603
+ durableEntries.push({
1604
+ index,
1605
+ claimed: entry.claimed,
1606
+ ownerResult,
1607
+ receipt,
1608
+ serializedBytes: jsonByteLength(receipt),
1609
+ });
1610
+ }
1611
+ if (durableEntries.length === 0) {
1612
+ return results;
1613
+ }
1614
+ if (!this.options.receiptStore?.completeReceipts) {
1615
+ await Promise.all(
1616
+ durableEntries.map(async (entry) => {
1617
+ try {
1618
+ results[entry.index] = {
1619
+ ok: true,
1620
+ value: await this.completeDurableToolRequest(
1621
+ entry.claimed,
1622
+ entries[entry.index]!.result,
1623
+ ),
1624
+ };
1625
+ } catch (error) {
1626
+ results[entry.index] = { ok: false, error };
1627
+ }
1628
+ }),
1629
+ );
1630
+ return results;
1631
+ }
1632
+ const completionBatches =
1633
+ this.buildDurableToolCompletionBatches(durableEntries);
1634
+ let completeAttempts = 0;
1635
+ for (const batch of completionBatches) {
1636
+ this.options.budgetMeter?.count('receipt');
1637
+ let completed: Array<WorkerRuntimeReceipt | null>;
1638
+ try {
1639
+ completeAttempts += 1;
1640
+ completed = await this.options.receiptStore!.completeReceipts!({
1641
+ playName: this.options.req.playName,
1642
+ receipts: batch.entries.map((entry) => entry.receipt),
1643
+ });
1644
+ } catch (error) {
1645
+ const persistenceError = receiptPersistenceError({
1646
+ operation: 'complete',
1647
+ receiptKeys: batch.entries.map((entry) => entry.receipt.key),
1648
+ cause: error,
1649
+ });
1650
+ if (!this.options.receiptStore.getReceipt) {
1651
+ for (const entry of batch.entries) {
1652
+ this.recordReceiptSalvage({
1653
+ claimed: entry.claimed,
1654
+ output: entry.receipt.output,
1655
+ attempts: completeAttempts,
1656
+ });
1657
+ results[entry.index] = { ok: false, error: persistenceError };
1658
+ }
1659
+ continue;
1660
+ }
1661
+ await mapWithConcurrency(
1662
+ batch.entries,
1663
+ WORKER_TOOL_RECEIPT_RECOVERY_MAX_CONCURRENT_READS,
1664
+ async (entry) => {
1665
+ const receiptKey = entry.receipt.key;
1666
+ let receipt: WorkerRuntimeReceipt | null = null;
1667
+ try {
1668
+ this.options.budgetMeter?.count('receipt');
1669
+ receipt = await this.options.receiptStore!.getReceipt!({
1670
+ playName: this.options.req.playName,
1671
+ key: receiptKey,
1672
+ });
1673
+ } catch (readError) {
1674
+ this.recordReceiptSalvage({
1675
+ claimed: entry.claimed,
1676
+ output: entry.receipt.output,
1677
+ attempts: completeAttempts,
1678
+ });
1679
+ results[entry.index] = {
1680
+ ok: false,
1681
+ error: receiptPersistenceError({
1682
+ operation: 'complete',
1683
+ receiptKeys: [receiptKey],
1684
+ cause: new AggregateError(
1685
+ [error, readError],
1686
+ 'Receipt completion failed and receipt recovery read failed',
1687
+ ),
1688
+ }),
1689
+ };
1690
+ return;
1691
+ }
1692
+ if (
1693
+ receipt &&
1694
+ (receipt.status === 'completed' ||
1695
+ receipt.status === 'skipped') &&
1696
+ receipt.output !== undefined
1697
+ ) {
1698
+ results[entry.index] = this.completedDurableToolRequestResult({
1699
+ claimed: entry.claimed,
1700
+ ownerResult: entry.ownerResult,
1701
+ completed: receipt,
1702
+ });
1703
+ return;
1704
+ }
1705
+ if (
1706
+ this.canCompleteRecoveredRunningReceipt(entry.claimed, receipt)
1707
+ ) {
1708
+ try {
1709
+ results[entry.index] = {
1710
+ ok: true,
1711
+ value: await this.completeDurableToolRequest(
1712
+ entry.claimed,
1713
+ entries[entry.index]!.result,
1714
+ ),
1715
+ };
1716
+ return;
1717
+ } catch (singleCompleteError) {
1718
+ results[entry.index] = {
1719
+ ok: false,
1720
+ error: receiptPersistenceError({
1721
+ operation: 'complete',
1722
+ receiptKeys: [receiptKey],
1723
+ cause: new AggregateError(
1724
+ [error, singleCompleteError],
1725
+ 'Receipt completion failed and owned receipt recovery failed',
1726
+ ),
1727
+ }),
1728
+ };
1729
+ return;
1730
+ }
1731
+ }
1732
+ this.recordReceiptSalvage({
1733
+ claimed: entry.claimed,
1734
+ output: entry.receipt.output,
1735
+ attempts: completeAttempts,
1736
+ });
1737
+ results[entry.index] = {
1738
+ ok: false,
1739
+ error: receiptPersistenceError({
1740
+ operation: 'complete',
1741
+ receiptKeys: [receiptKey],
1742
+ cause: receipt?.error
1743
+ ? new AggregateError(
1744
+ [error, new Error(receipt.error)],
1745
+ 'Receipt completion failed and receipt recovered failed',
1746
+ )
1747
+ : error,
1748
+ }),
1749
+ };
1750
+ },
1751
+ );
1752
+ this.options.recordPerfTrace?.({
1753
+ phase: 'worker.tool_receipts.partial_complete_recovery',
1754
+ extra: {
1755
+ recovered: batch.entries.filter((entry) => results[entry.index]?.ok)
1756
+ .length,
1757
+ failed: batch.entries.filter((entry) => {
1758
+ const result = results[entry.index];
1759
+ return result && !result.ok;
1760
+ }).length,
1761
+ total: batch.entries.length,
1762
+ failedReceiptKeys: batch.entries
1763
+ .filter((entry) => {
1764
+ const result = results[entry.index];
1765
+ return result && !result.ok;
1766
+ })
1767
+ .slice(0, 5)
1768
+ .map((entry) => entry.receipt.key),
1769
+ },
1770
+ });
1771
+ continue;
1772
+ }
1773
+ await mapWithConcurrency(
1774
+ batch.entries,
1775
+ WORKER_TOOL_RECEIPT_RECOVERY_MAX_CONCURRENT_READS,
1776
+ async (entry, resultIndex) => {
1777
+ results[entry.index] =
1778
+ await this.recoverCompletedDurableToolRequestResult({
1779
+ claimed: entry.claimed,
1780
+ ownerResult: entry.ownerResult,
1781
+ completed: completed[resultIndex],
1782
+ completeAttempts,
1783
+ });
1784
+ },
1785
+ );
1786
+ }
1787
+ return results;
1788
+ }
1789
+
1790
+ private async failDurableToolRequest(
1791
+ claimed: ClaimedWorkerToolBatchRequest,
1792
+ error: unknown,
1793
+ ): Promise<void> {
1794
+ if (!this.options.receiptStore || !claimed.receiptKey) {
1795
+ return;
1796
+ }
1797
+ this.options.budgetMeter?.count('receipt');
1798
+ try {
1799
+ await this.options.receiptStore.failReceipt({
1800
+ playName: this.options.req.playName,
1801
+ runId: this.options.req.runId,
1802
+ runAttempt: this.options.req.runAttempt ?? 0,
1803
+ key: claimed.receiptKey,
1804
+ leaseId: claimed.receiptLeaseId,
1805
+ error: error instanceof Error ? error.message : String(error),
1806
+ failureKind: workReceiptFailureKindForError(error),
1807
+ });
1808
+ } catch (receiptError) {
1809
+ if (this.options.persistenceLatch) {
1810
+ tripRuntimePersistenceLatch(
1811
+ this.options.persistenceLatch,
1812
+ receiptError,
1813
+ );
1814
+ }
1815
+ throw receiptPersistenceError({
1816
+ operation: 'fail',
1817
+ receiptKeys: [claimed.receiptKey],
1818
+ cause: receiptError,
1819
+ });
1820
+ }
1821
+ }
1822
+
1823
+ private async releaseDurableToolRequest(
1824
+ claimed: ClaimedWorkerToolBatchRequest,
1825
+ error: unknown,
1826
+ ): Promise<void> {
1827
+ if (!this.options.receiptStore || !claimed.receiptKey) {
1828
+ return;
1829
+ }
1830
+ if (!this.options.receiptStore.releaseReceipt) {
1831
+ throw new Error(
1832
+ `Durable tool call ${claimed.receiptKey} suspended but the receipt store cannot release ownership.`,
1833
+ );
1834
+ }
1835
+ this.options.budgetMeter?.count('receipt');
1836
+ try {
1837
+ const released = await this.options.receiptStore.releaseReceipt({
1838
+ playName: this.options.req.playName,
1839
+ runId: this.options.req.runId,
1840
+ runAttempt: this.options.req.runAttempt ?? 0,
1841
+ key: claimed.receiptKey,
1842
+ leaseId: claimed.receiptLeaseId,
1843
+ });
1844
+ if (!released || released.status !== 'pending') {
1845
+ throw new Error(
1846
+ `Durable tool call ${claimed.receiptKey} suspended but receipt ownership could not be released.`,
1847
+ );
1848
+ }
1849
+ } catch (receiptError) {
1850
+ if (this.options.persistenceLatch) {
1851
+ tripRuntimePersistenceLatch(
1852
+ this.options.persistenceLatch,
1853
+ receiptError,
1854
+ );
1855
+ }
1856
+ throw receiptPersistenceError({
1857
+ operation: 'fail',
1858
+ receiptKeys: [claimed.receiptKey],
1859
+ cause: new AggregateError(
1860
+ [error, receiptError],
1861
+ 'Tool receipt release failed after durable suspension',
1862
+ ),
1863
+ });
1864
+ }
1865
+ }
1866
+
1867
+ private async failDurableToolRequests(
1868
+ claimedRequests: ClaimedWorkerToolBatchRequest[],
1869
+ error: unknown,
1870
+ ): Promise<void> {
1871
+ const durable = claimedRequests.filter(
1872
+ (claimed) => this.options.receiptStore && claimed.receiptKey,
1873
+ );
1874
+ if (durable.length === 0) return;
1875
+ if (
1876
+ isPlayExecutionSuspendedError(error) ||
1877
+ isPlayRowExecutionSuspendedError(error)
1878
+ ) {
1879
+ await Promise.all(
1880
+ durable.map((claimed) =>
1881
+ this.releaseDurableToolRequest(claimed, error),
1882
+ ),
1883
+ );
1884
+ return;
1885
+ }
1886
+ if (!this.options.receiptStore?.failReceipts) {
1887
+ await Promise.all(
1888
+ durable.map((claimed) => this.failDurableToolRequest(claimed, error)),
1889
+ );
1890
+ return;
1891
+ }
1892
+ // Bulk fail is a single service-binding RPC regardless of key count.
1893
+ this.options.budgetMeter?.count('receipt');
1894
+ try {
1895
+ await this.options.receiptStore.failReceipts({
1896
+ playName: this.options.req.playName,
1897
+ receipts: durable.map((claimed) => ({
1898
+ runId: this.options.req.runId,
1899
+ runAttempt: this.options.req.runAttempt ?? 0,
1900
+ key: claimed.receiptKey!,
1901
+ leaseId: claimed.receiptLeaseId,
1902
+ error: error instanceof Error ? error.message : String(error),
1903
+ failureKind: workReceiptFailureKindForError(error),
1904
+ })),
1905
+ });
1906
+ } catch (receiptError) {
1907
+ if (this.options.persistenceLatch) {
1908
+ tripRuntimePersistenceLatch(
1909
+ this.options.persistenceLatch,
1910
+ receiptError,
1911
+ );
1912
+ }
1913
+ throw receiptPersistenceError({
1914
+ operation: 'fail',
1915
+ receiptKeys: durable
1916
+ .map((claimed) => claimed.receiptKey)
1917
+ .filter((key): key is string => typeof key === 'string'),
1918
+ cause: receiptError,
1919
+ });
1920
+ }
1921
+ }
1922
+
1923
+ private async executeToolGroup(
1924
+ toolId: string,
1925
+ requests: WorkerToolBatchRequest[],
1926
+ ): Promise<void> {
1927
+ const { claimedRequests, deferredClaimedRequests } =
1928
+ await this.prepareDurableToolRequests(requests);
1929
+ await this.executeClaimedToolRequests(
1930
+ toolId,
1931
+ requests.length,
1932
+ claimedRequests,
1933
+ );
1934
+ if (deferredClaimedRequests.length === 0) {
1935
+ return;
1936
+ }
1937
+ const reclaimedRequests = (
1938
+ await Promise.all(deferredClaimedRequests)
1939
+ ).flat();
1940
+ await this.executeClaimedToolRequests(
1941
+ toolId,
1942
+ requests.length,
1943
+ reclaimedRequests,
1944
+ );
1945
+ }
1946
+
1947
+ private async executeClaimedToolRequests(
1948
+ toolId: string,
1949
+ requestCount: number,
1950
+ claimedRequests: ClaimedWorkerToolBatchRequest[],
1951
+ ): Promise<void> {
1952
+ if (claimedRequests.length === 0) {
1953
+ return;
1954
+ }
1955
+ const strategy = getPlayRuntimeBatchStrategy(toolId);
1956
+ if (
1957
+ !strategy ||
1958
+ toolId === 'test_wait_for_event' ||
1959
+ claimedRequests.length < 2
1960
+ ) {
1961
+ const groupStartedAt = this.options.nowMs();
1962
+ await Promise.all(
1963
+ claimedRequests.map(async (claimed) => {
1964
+ const { request } = claimed;
1965
+ const toolContract = await this.options
1966
+ .resolvePacing(toolId)
1967
+ .catch(() => null);
1968
+ const circuitOpenError = this.runtimePersistenceCircuitOpenError(1);
1969
+ if (circuitOpenError) {
1970
+ this.rejectRequests(
1971
+ claimed,
1972
+ await this.failureForRejectedToolRequest(
1973
+ claimed,
1974
+ circuitOpenError,
1975
+ ),
1976
+ );
1977
+ return;
1978
+ }
1979
+ // Each unbatched provider call takes its own tool slot: the Governor
1980
+ // charges tool budget, holds a global tool-concurrency slot, and
1981
+ // applies per-(org,provider) pacing before the call runs.
1982
+ let slot: { release: () => void };
1983
+ try {
1984
+ slot = await this.options.governor.acquireToolSlot(toolId, {
1985
+ signal: this.options.abortSignal,
1986
+ });
1987
+ } catch (error) {
1988
+ this.rejectRequests(
1989
+ claimed,
1990
+ await this.failureForRejectedToolRequest(claimed, error),
1991
+ );
1992
+ return;
1993
+ }
1994
+ this.options.budgetMeter?.count('provider');
1995
+ this.options.budgetMeter?.count(
1996
+ 'egress',
1997
+ Math.max(
1998
+ 0,
1999
+ WORKER_PLATFORM_SUBREQUESTS_PER_UNBATCHED_TOOL_CALL - 1,
2000
+ ),
2001
+ );
2002
+ try {
2003
+ let result: unknown;
2004
+ try {
2005
+ const durableReceiptKey = claimed.receiptKey
2006
+ ? durableCallReceiptKeyForClaimed({
2007
+ req: this.options.req,
2008
+ claimed,
2009
+ })
2010
+ : null;
2011
+ result = await executeWithWorkerReceiptHeartbeats({
2012
+ playName: this.options.req.playName,
2013
+ runId: this.options.req.runId,
2014
+ runAttempt: this.options.req.runAttempt ?? 0,
2015
+ ...(this.options.receiptStore
2016
+ ? { receiptStore: this.options.receiptStore }
2017
+ : {}),
2018
+ budgetMeter: this.options.budgetMeter,
2019
+ heartbeatIntervalMs:
2020
+ this.options.runtimeReceiptHeartbeatIntervalMs,
2021
+ targets: claimed.receiptKey
2022
+ ? [
2023
+ {
2024
+ key: claimed.receiptKey,
2025
+ leaseId: claimed.receiptLeaseId,
2026
+ },
2027
+ ]
2028
+ : [],
2029
+ execute: () =>
2030
+ this.options.executeTool({
2031
+ id: request.id,
2032
+ toolId,
2033
+ input: request.input,
2034
+ workflowStep: request.workflowStep,
2035
+ callbacks: this.options.callbacks,
2036
+ onProviderBackpressure: (retryAfterMs) =>
2037
+ this.reportBackpressure(toolId, retryAfterMs),
2038
+ onRetryAttempt: () =>
2039
+ this.options.governor.chargeBudget('retry'),
2040
+ transientHttpRetrySafe:
2041
+ toolContract?.retrySafeTransientHttp === true,
2042
+ ...(durableReceiptKey
2043
+ ? {
2044
+ durableCallReceiptKey: durableReceiptKey,
2045
+ executionAuthScopeDigest:
2046
+ request.executionAuthScopeDigest,
2047
+ providerIdempotencyKey:
2048
+ providerIdempotencyKeyForClaimed({
2049
+ req: this.options.req,
2050
+ claimed,
2051
+ }),
2052
+ }
2053
+ : {}),
2054
+ runtimeTimeoutMs: resolveWorkerToolRuntimeTimeoutMs(
2055
+ [claimed],
2056
+ {
2057
+ resolveOwnerTimeoutMs: (request) =>
2058
+ request.runtimeTimeoutMs,
2059
+ },
2060
+ ),
2061
+ directOptions: request.directOptions,
2062
+ }),
2063
+ });
2064
+ } catch (error) {
2065
+ if (error instanceof RuntimeLeaseLostError) {
2066
+ this.rejectRequests(claimed, error);
2067
+ return;
2068
+ }
2069
+ if (
2070
+ await this.requeueAfterAuthScopeChanged(
2071
+ [request, ...claimed.followers],
2072
+ error,
2073
+ )
2074
+ ) {
2075
+ return;
2076
+ }
2077
+ this.rejectRequests(
2078
+ claimed,
2079
+ await this.failureForRejectedToolRequest(claimed, error),
2080
+ );
2081
+ return;
2082
+ }
2083
+ try {
2084
+ this.settleRequests(
2085
+ claimed,
2086
+ await this.completeDurableToolRequest(claimed, result),
2087
+ );
2088
+ } catch (receiptError) {
2089
+ this.rejectRequests(claimed, receiptError);
2090
+ }
2091
+ } finally {
2092
+ slot.release();
2093
+ }
2094
+ }),
2095
+ );
2096
+ this.options.recordPerfTrace?.({
2097
+ phase: 'runner.tool.group',
2098
+ ms: this.options.nowMs() - groupStartedAt,
2099
+ extra: {
2100
+ toolId,
2101
+ requests: requestCount,
2102
+ executed: claimedRequests.length,
2103
+ batched: false,
2104
+ },
2105
+ });
2106
+ return;
2107
+ }
2108
+
2109
+ const batchStartedAt = this.options.nowMs();
2110
+ await this.executeBatchedWorkerToolGroup({
2111
+ sourceToolId: toolId,
2112
+ requests: claimedRequests,
2113
+ strategy,
2114
+ suggestedParallelism: await this.options.governor.suggestedParallelism(
2115
+ toolId,
2116
+ this.options.governor.policy.pacing.workerToolBatchDefaultParallelism,
2117
+ ),
2118
+ });
2119
+ this.options.recordPerfTrace?.({
2120
+ phase: 'runner.tool.group',
2121
+ ms: this.options.nowMs() - batchStartedAt,
2122
+ extra: {
2123
+ toolId,
2124
+ requests: requestCount,
2125
+ executed: claimedRequests.length,
2126
+ batched: true,
2127
+ },
2128
+ });
2129
+ }
2130
+
2131
+ private async executeBatchedWorkerToolGroup(input: {
2132
+ sourceToolId: string;
2133
+ requests: ClaimedWorkerToolBatchRequest[];
2134
+ strategy: AnyBatchOperationStrategy;
2135
+ suggestedParallelism: number;
2136
+ }): Promise<void> {
2137
+ const compiledBatches = compileRequestsWithStrategy({
2138
+ requests: input.requests,
2139
+ strategy: input.strategy,
2140
+ getPayload: (request) => request.request.input,
2141
+ });
2142
+ this.options.recordPerfTrace?.({
2143
+ phase: 'runner.tool.batch.compile',
2144
+ ms: 0,
2145
+ extra: {
2146
+ sourceOperation: input.strategy.sourceOperation,
2147
+ batchOperation: input.strategy.batchOperation,
2148
+ requests: input.requests.length,
2149
+ batches: compiledBatches.length,
2150
+ batchSizes: compiledBatches.map((batch) => batch.memberRequests.length),
2151
+ },
2152
+ });
2153
+ await executeChunkedRequests({
2154
+ requests: compiledBatches,
2155
+ // Chunk parallelism is the Governor's per-tool suggestion (provider rate
2156
+ // hints tightened to the policy ceiling), bounded by the batch count.
2157
+ batchSize: Math.max(
2158
+ 1,
2159
+ Math.min(input.suggestedParallelism, compiledBatches.length || 1),
2160
+ ),
2161
+ execute: async (batch) => {
2162
+ const toolContract = await this.options
2163
+ .resolvePacing(batch.batchOperation)
2164
+ .catch(() => null);
2165
+ const circuitOpenError = this.runtimePersistenceCircuitOpenError(
2166
+ batch.memberRequests.length,
2167
+ );
2168
+ if (circuitOpenError) {
2169
+ throw circuitOpenError;
2170
+ }
2171
+ // One provider call per batch -> one tool slot (budget + global
2172
+ // concurrency + per-(org,provider) pacing) around the whole batch.
2173
+ const slot = await this.options.governor.acquireToolSlot(
2174
+ batch.batchOperation,
2175
+ {
2176
+ signal: this.options.abortSignal,
2177
+ },
2178
+ );
2179
+ this.options.budgetMeter?.count('provider');
2180
+ try {
2181
+ const receiptKeys = batch.memberRequests.map((member) =>
2182
+ durableCallReceiptKeyForClaimed({
2183
+ req: this.options.req,
2184
+ claimed: member,
2185
+ }),
2186
+ );
2187
+ const aggregateReceiptKey = buildBatchDurableCallReceiptKey({
2188
+ req: this.options.req,
2189
+ batchOperation: batch.batchOperation,
2190
+ receiptKeys,
2191
+ });
2192
+ return await executeWithWorkerReceiptHeartbeats({
2193
+ playName: this.options.req.playName,
2194
+ runId: this.options.req.runId,
2195
+ runAttempt: this.options.req.runAttempt ?? 0,
2196
+ ...(this.options.receiptStore
2197
+ ? { receiptStore: this.options.receiptStore }
2198
+ : {}),
2199
+ budgetMeter: this.options.budgetMeter,
2200
+ heartbeatIntervalMs: this.options.runtimeReceiptHeartbeatIntervalMs,
2201
+ targets: batch.memberRequests.map((member) => ({
2202
+ key: member.receiptKey,
2203
+ leaseId: member.receiptLeaseId,
2204
+ })),
2205
+ execute: () =>
2206
+ this.options.executeTool({
2207
+ id: `batch:${batch.memberRequests.map((request) => request.request.id).join('|')}`,
2208
+ toolId: batch.batchOperation,
2209
+ input: batch.batchPayload,
2210
+ callbacks: this.options.callbacks,
2211
+ onProviderBackpressure: (retryAfterMs) =>
2212
+ this.reportBackpressure(input.sourceToolId, retryAfterMs),
2213
+ onRetryAttempt: () =>
2214
+ this.options.governor.chargeBudget('retry'),
2215
+ transientHttpRetrySafe:
2216
+ toolContract?.retrySafeTransientHttp === true,
2217
+ durableCallReceiptKey: aggregateReceiptKey,
2218
+ executionAuthScopeDigest:
2219
+ batch.memberRequests[0]?.request.executionAuthScopeDigest ??
2220
+ null,
2221
+ providerIdempotencyKey: buildBatchProviderIdempotencyKey({
2222
+ aggregateReceiptKey,
2223
+ receiptKeys,
2224
+ providerIdempotencyKeys: batch.memberRequests.map((member) =>
2225
+ providerIdempotencyKeyForClaimed({
2226
+ req: this.options.req,
2227
+ claimed: member,
2228
+ }),
2229
+ ),
2230
+ }),
2231
+ runtimeTimeoutMs: resolveWorkerToolRuntimeTimeoutMs(
2232
+ batch.memberRequests,
2233
+ {
2234
+ resolveOwnerTimeoutMs: (request) =>
2235
+ request.runtimeTimeoutMs,
2236
+ },
2237
+ ),
2238
+ }),
2239
+ });
2240
+ } finally {
2241
+ slot.release();
2242
+ }
2243
+ },
2244
+ onChunkComplete: async (
2245
+ chunkResults: Array<
2246
+ ChunkExecutionResult<(typeof compiledBatches)[number], unknown>
2247
+ >,
2248
+ ) => {
2249
+ for (const entry of chunkResults) {
2250
+ if (entry.error !== undefined) {
2251
+ // One batch's provider error stays scoped to that batch's member
2252
+ // requests. Sibling batches in this chunk keep their results so a
2253
+ // single provider hiccup cannot cascade into a whole-map failure.
2254
+ if (
2255
+ entry.error instanceof ToolExecuteAuthScopeChangedError &&
2256
+ entry.request.memberRequests.every(
2257
+ (claimed) => claimed.request.authScopeRetryUsed !== true,
2258
+ ) &&
2259
+ (await this.requeueAfterAuthScopeChanged(
2260
+ entry.request.memberRequests.map((claimed) => claimed.request),
2261
+ entry.error,
2262
+ ))
2263
+ ) {
2264
+ continue;
2265
+ }
2266
+ let rejection: unknown = entry.error;
2267
+ if (!(entry.error instanceof RuntimeLeaseLostError)) {
2268
+ try {
2269
+ await this.failDurableToolRequests(
2270
+ entry.request.memberRequests,
2271
+ entry.error,
2272
+ );
2273
+ } catch (receiptError) {
2274
+ rejection = receiptPersistenceError({
2275
+ operation: 'fail',
2276
+ receiptKeys: entry.request.memberRequests
2277
+ .map((claimed) => claimed.receiptKey)
2278
+ .filter((key): key is string => typeof key === 'string'),
2279
+ cause: new AggregateError(
2280
+ [entry.error, receiptError],
2281
+ 'Tool receipts fail mark failed',
2282
+ ),
2283
+ });
2284
+ }
2285
+ }
2286
+ for (const claimed of entry.request.memberRequests) {
2287
+ this.rejectRequests(claimed, rejection);
2288
+ }
2289
+ continue;
2290
+ }
2291
+ const batchResult = isToolExecuteResult(entry.result)
2292
+ ? entry.result.toolOutput.raw
2293
+ : entry.result;
2294
+ const splitResults =
2295
+ batchResult != null
2296
+ ? entry.request.splitResults(batchResult)
2297
+ : entry.request.memberRequests.map(() => null);
2298
+ let completedResults: WorkerToolCompletionResult[];
2299
+ try {
2300
+ completedResults = await this.completeDurableToolRequests(
2301
+ entry.request.memberRequests.map((claimed, index) => ({
2302
+ claimed,
2303
+ result: wrapWorkerToolResult(
2304
+ claimed.request.toolId,
2305
+ splitResults[index] ?? null,
2306
+ toolMetadataFallback(claimed.request.toolId),
2307
+ ),
2308
+ })),
2309
+ );
2310
+ } catch (receiptError) {
2311
+ for (const claimed of entry.request.memberRequests) {
2312
+ this.rejectRequests(claimed, receiptError);
2313
+ }
2314
+ continue;
2315
+ }
2316
+ for (let index = 0; index < completedResults.length; index += 1) {
2317
+ const claimed = entry.request.memberRequests[index]!;
2318
+ const completed = completedResults[index];
2319
+ if (!completed) {
2320
+ this.rejectRequests(
2321
+ claimed,
2322
+ new Error(
2323
+ `Tool batch completion did not return member ${claimed.request.id}.`,
2324
+ ),
2325
+ );
2326
+ } else if (completed.ok) {
2327
+ this.settleRequests(claimed, completed.value);
2328
+ } else {
2329
+ this.rejectRequests(claimed, completed.error);
2330
+ }
2331
+ }
2332
+ }
2333
+ },
2334
+ }).catch(async (error) => {
2335
+ let rejection: unknown = error;
2336
+ if (
2337
+ error instanceof ToolExecuteAuthScopeChangedError &&
2338
+ (await this.requeueAfterAuthScopeChanged(
2339
+ input.requests.map((claimed) => claimed.request),
2340
+ error,
2341
+ ))
2342
+ ) {
2343
+ return;
2344
+ }
2345
+ if (!(error instanceof RuntimeLeaseLostError)) {
2346
+ try {
2347
+ await this.failDurableToolRequests(input.requests, error);
2348
+ } catch (receiptError) {
2349
+ rejection = receiptPersistenceError({
2350
+ operation: 'fail',
2351
+ receiptKeys: input.requests
2352
+ .map((claimed) => claimed.receiptKey)
2353
+ .filter((key): key is string => typeof key === 'string'),
2354
+ cause: new AggregateError(
2355
+ [error, receiptError],
2356
+ 'Tool receipts fail mark failed',
2357
+ ),
2358
+ });
2359
+ }
2360
+ }
2361
+ for (const claimed of input.requests) {
2362
+ this.rejectRequests(claimed, rejection);
2363
+ }
2364
+ });
2365
+ }
2366
+ }
2367
+
2368
+ function groupWorkerToolRequestsByTool(
2369
+ requests: WorkerToolBatchRequest[],
2370
+ ): Map<string, WorkerToolBatchRequest[]> {
2371
+ const byTool = new Map<string, WorkerToolBatchRequest[]>();
2372
+ for (const request of requests) {
2373
+ const existing = byTool.get(request.toolId);
2374
+ if (existing) {
2375
+ existing.push(request);
2376
+ } else {
2377
+ byTool.set(request.toolId, [request]);
2378
+ }
2379
+ }
2380
+ return byTool;
2381
+ }