@awak-app/simy-cli 0.2.2 → 0.3.3

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.
@@ -0,0 +1,708 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ chmod,
4
+ lstat,
5
+ mkdir,
6
+ readFile,
7
+ realpath,
8
+ writeFile,
9
+ } from "node:fs/promises";
10
+ import path from "node:path";
11
+
12
+ import { prepareLocalTaskWorkspace } from "./local-task.js";
13
+ import {
14
+ BoundedLocalTaskSubtaskContractError,
15
+ boundedLocalTaskSubtaskExecution,
16
+ buildBoundedLocalTaskSubtaskBatchPlan,
17
+ DEFAULT_LOCAL_TASK_SUBTASK_PARALLELISM,
18
+ durableLocalTaskSubtaskFence,
19
+ normalizeBoundedLocalTaskParentAuthority,
20
+ normalizeDurableLocalTaskSubtaskClaim,
21
+ normalizeDurableLocalTaskSubtaskResult,
22
+ } from "./bounded-local-task-subtasks-contract.js";
23
+
24
+ const DEFAULT_LEASE_SECONDS = 120;
25
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
26
+ const TERMINAL_BATCH_STATES = new Set([
27
+ "waiting_human",
28
+ "succeeded",
29
+ "failed",
30
+ "cancelled",
31
+ ]);
32
+ const SUPPORTED_BATCH_STATES = new Set([
33
+ "active",
34
+ "running",
35
+ "cancel_requested",
36
+ ...TERMINAL_BATCH_STATES,
37
+ ]);
38
+ const TERMINAL_POOL_DISPOSITIONS = new Set([
39
+ "none",
40
+ "provider_result_unknown",
41
+ ...TERMINAL_BATCH_STATES,
42
+ ]);
43
+
44
+ /**
45
+ * Run only Backend-admitted v2 subtasks. The fixed Web route adapter owns
46
+ * authentication and injects p_device_token_hash; the pool deliberately does
47
+ * not know the paired device token.
48
+ */
49
+ export function createBoundedLocalTaskSubtaskPool({
50
+ api,
51
+ parentAuthority,
52
+ batchPlan,
53
+ storageRoot,
54
+ workspaceRoot,
55
+ runSubtask,
56
+ stopSubtask = async () => {},
57
+ downloadSource,
58
+ prepareWorkspace = prepareLocalTaskWorkspace,
59
+ leaseSeconds = DEFAULT_LEASE_SECONDS,
60
+ heartbeatIntervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS,
61
+ setIntervalImpl = setInterval,
62
+ clearIntervalImpl = clearInterval,
63
+ onError = () => {},
64
+ } = {}) {
65
+ const parent = normalizeBoundedLocalTaskParentAuthority(parentAuthority);
66
+ const plan = buildBoundedLocalTaskSubtaskBatchPlan(batchPlan);
67
+ requireApi(api);
68
+ if (typeof runSubtask !== "function") {
69
+ throw new TypeError("runSubtask is required");
70
+ }
71
+ const sourceDownloader =
72
+ downloadSource ??
73
+ api.download_local_task_subtask_source_v2?.bind(api);
74
+ if (typeof sourceDownloader !== "function") {
75
+ throw new TypeError("downloadSource is required");
76
+ }
77
+ if (typeof prepareWorkspace !== "function") {
78
+ throw new TypeError("prepareWorkspace is required");
79
+ }
80
+
81
+ const controller = new AbortController();
82
+ const active = new Map();
83
+ let initialized = false;
84
+ let closed = false;
85
+ let terminalDisposition = null;
86
+ let runPromise = null;
87
+ let claimCount = 0;
88
+ let maximumObservedConcurrency = 0;
89
+ let batchId = null;
90
+ let batchParallelism = null;
91
+ const schedulingStopped = () =>
92
+ TERMINAL_POOL_DISPOSITIONS.has(terminalDisposition);
93
+
94
+ const cancel = async (reason = "cancelled") => {
95
+ if (!controller.signal.aborted) controller.abort(reason);
96
+ terminalDisposition = "cancelled";
97
+ await Promise.allSettled(
98
+ [...active.values()].map(({ normalized }) =>
99
+ stopSubtask({
100
+ stepId: normalized.step.step_id,
101
+ attemptId: normalized.attempt.attempt_id,
102
+ reason,
103
+ })),
104
+ );
105
+ };
106
+
107
+ const runOne = async (claim) => {
108
+ let normalized;
109
+ let heartbeatTimer = null;
110
+ let heartbeatInFlight = null;
111
+ let completionConfirmed = false;
112
+ let completionState = null;
113
+ let providerStarted = false;
114
+ const startedAt = Date.now();
115
+ try {
116
+ normalized = normalizeDurableLocalTaskSubtaskClaim(claim, {
117
+ parentAuthority: parent,
118
+ leaseSeconds,
119
+ });
120
+ if (normalized.disposition !== "execute") {
121
+ throw new BoundedLocalTaskSubtaskContractError(
122
+ "Only an executable server claim can enter the bounded pool.",
123
+ );
124
+ }
125
+ if (batchId !== null && normalized.batch.batch_id !== batchId) {
126
+ throw new BoundedLocalTaskSubtaskContractError(
127
+ "The bounded pool received a claim from a different batch.",
128
+ );
129
+ }
130
+ batchId ??= normalized.batch.batch_id;
131
+
132
+ const execution = boundedLocalTaskSubtaskExecution(normalized, parent);
133
+ const runtime = await materializeSubtaskRuntime({
134
+ normalized,
135
+ execution,
136
+ parent,
137
+ storageRoot,
138
+ workspaceRoot,
139
+ prepareWorkspace,
140
+ downloadSource: sourceDownloader,
141
+ signal: controller.signal,
142
+ });
143
+ if (controller.signal.aborted) return;
144
+
145
+ const fence = durableLocalTaskSubtaskFence(normalized, parent);
146
+ let boundaryConfirmed = false;
147
+ try {
148
+ boundaryConfirmed =
149
+ await api.mark_local_task_subtask_invocation_started_v2(fence);
150
+ } catch (error) {
151
+ onError(
152
+ poolError(
153
+ "SIMY could not confirm the bounded subtask Provider boundary. The Provider was not started.",
154
+ "local_task_subtask_boundary_unconfirmed",
155
+ error,
156
+ ),
157
+ );
158
+ await api.abandon_local_task_subtask_attempt_v2({
159
+ ...fence,
160
+ p_reason_code: "invocation_start_unconfirmed",
161
+ }).catch(onError);
162
+ return;
163
+ }
164
+ if (boundaryConfirmed !== true || controller.signal.aborted) {
165
+ await api.abandon_local_task_subtask_attempt_v2({
166
+ ...fence,
167
+ p_reason_code: controller.signal.aborted
168
+ ? "cancelled_before_invocation"
169
+ : "invocation_start_not_confirmed",
170
+ }).catch(onError);
171
+ return;
172
+ }
173
+ providerStarted = true;
174
+
175
+ const heartbeat = async () => {
176
+ if (controller.signal.aborted || completionConfirmed) return;
177
+ try {
178
+ const response =
179
+ await api.heartbeat_local_task_subtask_attempt_v2({
180
+ ...fence,
181
+ p_lease_seconds: leaseSeconds,
182
+ });
183
+ if (
184
+ completionConfirmed &&
185
+ isTerminalHeartbeatCoveredByCompletion(
186
+ response,
187
+ normalized,
188
+ completionState,
189
+ )
190
+ ) {
191
+ return;
192
+ }
193
+ if (
194
+ response?.ok !== true ||
195
+ response.cancel_requested === true ||
196
+ (response.cancel_generation !== undefined &&
197
+ String(response.cancel_generation) !==
198
+ normalized.parent.cancel_generation)
199
+ ) {
200
+ await cancel("server_cancelled");
201
+ }
202
+ } catch (error) {
203
+ if (completionConfirmed && isKnownSubtaskFenceError(error)) return;
204
+ onError(
205
+ poolError(
206
+ "SIMY could not renew a bounded subtask attempt.",
207
+ "local_task_subtask_heartbeat_failed",
208
+ error,
209
+ ),
210
+ );
211
+ if (isHeartbeatLeaseLoss(error)) {
212
+ await cancel("lease_lost");
213
+ }
214
+ }
215
+ };
216
+ heartbeatTimer = setIntervalImpl(() => {
217
+ if (heartbeatInFlight) return;
218
+ heartbeatInFlight = heartbeat().finally(() => {
219
+ heartbeatInFlight = null;
220
+ });
221
+ }, heartbeatIntervalMs);
222
+
223
+ let rawResult;
224
+ try {
225
+ rawResult = await runSubtask(runtime, {
226
+ signal: controller.signal,
227
+ });
228
+ } catch (error) {
229
+ rawResult = {
230
+ status: controller.signal.aborted ? "cancelled" : "failed",
231
+ result_summary: controller.signal.aborted
232
+ ? "The bounded subtask was cancelled."
233
+ : "The bounded subtask failed without a trusted result.",
234
+ provider_tokens_used: "0",
235
+ elapsed_ms: String(Math.max(0, Date.now() - startedAt)),
236
+ failure_code: controller.signal.aborted
237
+ ? null
238
+ : "local_task_subtask_failed",
239
+ };
240
+ onError(
241
+ poolError(
242
+ "A bounded Local Task subtask failed.",
243
+ "local_task_subtask_failed",
244
+ error,
245
+ ),
246
+ );
247
+ }
248
+ const result = normalizeDurableLocalTaskSubtaskResult(
249
+ normalizeRunResult(rawResult, startedAt),
250
+ { normalizedClaim: normalized },
251
+ );
252
+ try {
253
+ // A transport-ambiguous completion is never followed by another
254
+ // Provider call or an automatic local replay. Backend owns resolution.
255
+ const completionResponse =
256
+ await api.complete_local_task_subtask_attempt_v2({
257
+ ...fence,
258
+ p_status: result.status,
259
+ p_result_summary: result.result_summary,
260
+ p_artifact_refs: [],
261
+ p_provider_tokens_used: result.provider_tokens_used,
262
+ p_elapsed_ms: result.elapsed_ms,
263
+ p_failure_code: result.failure_code,
264
+ });
265
+ const completion = normalizeCompletionResponse(
266
+ completionResponse,
267
+ fence,
268
+ result,
269
+ );
270
+ completionConfirmed = completion.confirmed;
271
+ completionState = result.status;
272
+ if (completion.batchState !== null) {
273
+ terminalDisposition = completion.batchState;
274
+ }
275
+ } catch (error) {
276
+ terminalDisposition = "provider_result_unknown";
277
+ onError(
278
+ poolError(
279
+ "SIMY could not confirm the bounded subtask's final server state. The Provider will not be replayed.",
280
+ "local_task_subtask_final_unconfirmed",
281
+ error,
282
+ ),
283
+ );
284
+ }
285
+ } catch (error) {
286
+ onError(
287
+ poolError(
288
+ "SIMY rejected an unsafe bounded Local Task subtask.",
289
+ error?.code || "invalid_local_task_subtask_claim",
290
+ error,
291
+ ),
292
+ );
293
+ if (providerStarted) {
294
+ // Once the Provider has started, an invalid or otherwise unreportable
295
+ // local result is an ambiguous boundary. Stop admitting work and let
296
+ // Backend/user resolution decide what happens next.
297
+ terminalDisposition = "provider_result_unknown";
298
+ }
299
+ if (normalized?.attempt && normalized?.step && !providerStarted) {
300
+ const fence = durableLocalTaskSubtaskFence(normalized, parent);
301
+ await api.abandon_local_task_subtask_attempt_v2({
302
+ ...fence,
303
+ p_reason_code: "local_validation_failed",
304
+ }).catch(onError);
305
+ }
306
+ } finally {
307
+ if (heartbeatTimer) clearIntervalImpl(heartbeatTimer);
308
+ if (heartbeatInFlight) await heartbeatInFlight;
309
+ }
310
+ };
311
+
312
+ const scheduler = async () => {
313
+ if (!initialized) {
314
+ const initializedBatch =
315
+ await api.initialize_local_task_subtask_batch_v2({
316
+ p_turn_id: parent.turnId,
317
+ p_worker_id: parent.workerId,
318
+ p_parent_lease_epoch: parent.parentLeaseEpoch,
319
+ p_plan: plan,
320
+ });
321
+ if (
322
+ initializedBatch?.schema_version !==
323
+ "local_task_subtask_batch_initialized.v2" ||
324
+ initializedBatch?.disposition !== "ready"
325
+ ) {
326
+ throw poolError(
327
+ "SIMY did not initialize the bounded Local Task batch.",
328
+ "local_task_subtask_batch_not_initialized",
329
+ );
330
+ }
331
+ initialized = true;
332
+ }
333
+
334
+ while (
335
+ !closed &&
336
+ !controller.signal.aborted &&
337
+ !schedulingStopped()
338
+ ) {
339
+ let admittedInPass = false;
340
+ while (
341
+ !closed &&
342
+ !controller.signal.aborted &&
343
+ !schedulingStopped() &&
344
+ active.size <
345
+ (batchParallelism ?? DEFAULT_LOCAL_TASK_SUBTASK_PARALLELISM)
346
+ ) {
347
+ // No prefetch queue: this RPC occurs only when a real execution slot
348
+ // is available. Therefore the third subtask is not claimed while two
349
+ // Provider calls are still active.
350
+ claimCount += 1;
351
+ const claim =
352
+ await api.claim_local_task_subtask_attempt_v2({
353
+ p_turn_id: parent.turnId,
354
+ p_worker_id: parent.workerId,
355
+ p_parent_lease_epoch: parent.parentLeaseEpoch,
356
+ p_cancel_generation: parent.cancelGeneration,
357
+ p_lease_seconds: leaseSeconds,
358
+ p_step_id: null,
359
+ });
360
+ const normalized = normalizeDurableLocalTaskSubtaskClaim(claim, {
361
+ parentAuthority: parent,
362
+ leaseSeconds,
363
+ });
364
+ if (normalized.step) assertClaimMatchesPlan(normalized, plan);
365
+ if (normalized.disposition === "cancelled") {
366
+ await cancel("server_cancelled");
367
+ break;
368
+ }
369
+ if (normalized.disposition === "provider_result_unknown") {
370
+ terminalDisposition = "provider_result_unknown";
371
+ onError(
372
+ poolError(
373
+ "A previous bounded subtask Provider result is unknown and cannot be replayed.",
374
+ "local_task_subtask_provider_result_unknown",
375
+ ),
376
+ );
377
+ break;
378
+ }
379
+ if (normalized.disposition === "none") {
380
+ terminalDisposition = active.size === 0 ? "none" : null;
381
+ break;
382
+ }
383
+ if (
384
+ batchParallelism !== null &&
385
+ normalized.batch.parallelism !== batchParallelism
386
+ ) {
387
+ throw new BoundedLocalTaskSubtaskContractError(
388
+ "The bounded pool received inconsistent batch parallelism.",
389
+ );
390
+ }
391
+ batchParallelism ??= normalized.batch.parallelism;
392
+ admittedInPass = true;
393
+ const key = normalized.attempt.attempt_id;
394
+ const operation = runOne(claim).finally(() => {
395
+ active.delete(key);
396
+ });
397
+ active.set(key, { normalized, operation });
398
+ maximumObservedConcurrency = Math.max(
399
+ maximumObservedConcurrency,
400
+ active.size,
401
+ );
402
+ }
403
+
404
+ if (controller.signal.aborted || closed || schedulingStopped()) break;
405
+ if (active.size > 0) {
406
+ await Promise.race([...active.values()].map(({ operation }) => operation));
407
+ continue;
408
+ }
409
+ if (!admittedInPass) break;
410
+ }
411
+ await Promise.allSettled(
412
+ [...active.values()].map(({ operation }) => operation),
413
+ );
414
+ };
415
+
416
+ return {
417
+ get signal() {
418
+ return controller.signal;
419
+ },
420
+ snapshot() {
421
+ return {
422
+ initialized,
423
+ closed,
424
+ cancelled: controller.signal.aborted,
425
+ terminal_disposition: terminalDisposition,
426
+ active_count: active.size,
427
+ claim_count: claimCount,
428
+ max_observed_concurrency: maximumObservedConcurrency,
429
+ batch_id: batchId,
430
+ };
431
+ },
432
+ async run() {
433
+ if (runPromise) return runPromise;
434
+ runPromise = scheduler().catch((error) => {
435
+ onError(
436
+ poolError(
437
+ "SIMY could not run the bounded Local Task subtask pool.",
438
+ "bounded_local_task_subtask_pool_failed",
439
+ error,
440
+ ),
441
+ );
442
+ return false;
443
+ });
444
+ await runPromise;
445
+ return !controller.signal.aborted;
446
+ },
447
+ cancel,
448
+ async waitForIdle() {
449
+ await runPromise;
450
+ await Promise.allSettled(
451
+ [...active.values()].map(({ operation }) => operation),
452
+ );
453
+ },
454
+ async close() {
455
+ closed = true;
456
+ await cancel("worker_closed");
457
+ await runPromise;
458
+ },
459
+ };
460
+ }
461
+
462
+ async function materializeSubtaskRuntime({
463
+ normalized,
464
+ execution,
465
+ parent,
466
+ storageRoot,
467
+ workspaceRoot,
468
+ prepareWorkspace,
469
+ downloadSource,
470
+ signal,
471
+ }) {
472
+ const prepared = await prepareWorkspace({
473
+ taskId: execution.workspace_id,
474
+ root: storageRoot,
475
+ workspaceRoot,
476
+ });
477
+ const canonicalInputsRoot = await realpath(prepared.inputs_root);
478
+ const materializedInputs = [];
479
+ for (const ref of normalized.step.attachment_refs) {
480
+ if (signal.aborted) throw abortError();
481
+ const bytes = await downloadSource({
482
+ turnId: normalized.turnId,
483
+ batchId: normalized.batch.batch_id,
484
+ stepId: normalized.step.step_id,
485
+ attemptId: normalized.attempt.attempt_id,
486
+ attemptLeaseEpoch: normalized.attempt.lease_epoch,
487
+ parentLeaseEpoch: normalized.parent.lease_epoch,
488
+ cancelGeneration: normalized.parent.cancel_generation,
489
+ source: { ...ref },
490
+ signal,
491
+ });
492
+ if (!Buffer.isBuffer(bytes)) {
493
+ throw poolError(
494
+ "SIMY Web returned an invalid uploaded source body.",
495
+ "local_task_subtask_source_invalid",
496
+ );
497
+ }
498
+ const expectedSize = BigInt(ref.size_bytes);
499
+ const digest = createHash("sha256").update(bytes).digest("hex");
500
+ if (BigInt(bytes.byteLength) !== expectedSize || digest !== ref.sha256) {
501
+ throw poolError(
502
+ "A bounded subtask source failed integrity verification.",
503
+ "local_task_subtask_source_integrity_failed",
504
+ );
505
+ }
506
+ const filename = `${ref.source_id}-${ref.name}`;
507
+ const target = path.join(canonicalInputsRoot, filename);
508
+ if (path.dirname(target) !== canonicalInputsRoot) {
509
+ throw poolError(
510
+ "A bounded subtask source path escaped its isolated input directory.",
511
+ "local_task_subtask_source_path_unsafe",
512
+ );
513
+ }
514
+ await mkdir(canonicalInputsRoot, { recursive: true, mode: 0o700 });
515
+ await writeFile(target, bytes, { flag: "wx", mode: 0o400 });
516
+ await chmod(target, 0o400);
517
+ const details = await lstat(target);
518
+ const canonicalTarget = await realpath(target);
519
+ if (
520
+ !details.isFile() ||
521
+ details.isSymbolicLink() ||
522
+ path.dirname(canonicalTarget) !== canonicalInputsRoot
523
+ ) {
524
+ throw poolError(
525
+ "A bounded subtask source is not a safe isolated file.",
526
+ "local_task_subtask_source_path_unsafe",
527
+ );
528
+ }
529
+ const verifiedBytes = await readFile(canonicalTarget);
530
+ if (
531
+ createHash("sha256").update(verifiedBytes).digest("hex") !== ref.sha256
532
+ ) {
533
+ throw poolError(
534
+ "A bounded subtask source changed after materialization.",
535
+ "local_task_subtask_source_integrity_failed",
536
+ );
537
+ }
538
+ materializedInputs.push({
539
+ source_id: ref.source_id,
540
+ name: ref.name,
541
+ local_path: canonicalTarget,
542
+ mime_type: ref.mime_type,
543
+ size_bytes: ref.size_bytes,
544
+ sha256: ref.sha256,
545
+ verified: true,
546
+ immutable: true,
547
+ });
548
+ }
549
+ return deepFreeze({
550
+ ...execution,
551
+ parent_turn_id: parent.turnId,
552
+ workspace_path: prepared.workspace_path,
553
+ task_root: prepared.task_root,
554
+ inputs_root: canonicalInputsRoot,
555
+ outputs_root: prepared.outputs_root,
556
+ outputs_root_expected: prepared.outputs_root_expected,
557
+ verified_uploaded_inputs: materializedInputs,
558
+ });
559
+ }
560
+
561
+ function normalizeRunResult(value, startedAt) {
562
+ const elapsed = String(Math.max(0, Date.now() - startedAt));
563
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
564
+ return {
565
+ status: "failed",
566
+ result_summary: "The bounded subtask returned no trusted result.",
567
+ provider_tokens_used: "0",
568
+ elapsed_ms: elapsed,
569
+ failure_code: "local_task_subtask_result_invalid",
570
+ };
571
+ }
572
+ return {
573
+ status: value.status,
574
+ result_summary: value.result_summary ?? null,
575
+ provider_tokens_used: value.provider_tokens_used ?? "0",
576
+ elapsed_ms: value.elapsed_ms ?? elapsed,
577
+ failure_code: value.failure_code ?? null,
578
+ };
579
+ }
580
+
581
+ function normalizeCompletionResponse(value, fence, result) {
582
+ // Legacy injected adapters returned `true`; it confirms the transition but
583
+ // cannot authoritatively stop scheduling because it carries no batch state.
584
+ if (value === true) {
585
+ return { confirmed: true, batchState: null };
586
+ }
587
+ if (
588
+ !value ||
589
+ typeof value !== "object" ||
590
+ Array.isArray(value) ||
591
+ value.schema_version !== "local_task_subtask_transition.v2" ||
592
+ value.batch_id !== fence.p_batch_id ||
593
+ value.step_id !== fence.p_step_id ||
594
+ value.attempt_id !== fence.p_attempt_id ||
595
+ value.state !== result.status ||
596
+ typeof value.replayed !== "boolean" ||
597
+ !SUPPORTED_BATCH_STATES.has(value.batch_state)
598
+ ) {
599
+ throw poolError(
600
+ "SIMY Web returned an invalid bounded subtask completion transition.",
601
+ "local_task_subtask_completion_invalid_response",
602
+ );
603
+ }
604
+ return {
605
+ confirmed: true,
606
+ batchState: TERMINAL_BATCH_STATES.has(value.batch_state)
607
+ ? value.batch_state
608
+ : null,
609
+ };
610
+ }
611
+
612
+ function isKnownSubtaskFenceError(error) {
613
+ return (
614
+ error?.status === 409 &&
615
+ (
616
+ error?.code === "local_task_lease_fenced" ||
617
+ error?.code === "local_task_subtask_attempt_fenced"
618
+ )
619
+ );
620
+ }
621
+
622
+ function isHeartbeatLeaseLoss(error) {
623
+ return error?.status === 409;
624
+ }
625
+
626
+ function isTerminalHeartbeatCoveredByCompletion(
627
+ value,
628
+ normalized,
629
+ completionState,
630
+ ) {
631
+ return (
632
+ value?.schema_version === "local_task_subtask_heartbeat.v2" &&
633
+ value?.terminal === true &&
634
+ value?.ok === true &&
635
+ value?.attempt_state === completionState &&
636
+ SUPPORTED_BATCH_STATES.has(value?.batch_state) &&
637
+ value?.cancel_requested === false &&
638
+ String(value?.cancel_generation) ===
639
+ normalized.parent.cancel_generation &&
640
+ String(value?.attempt_lease_epoch) ===
641
+ normalized.attempt.lease_epoch
642
+ );
643
+ }
644
+
645
+ function requireApi(api) {
646
+ const names = [
647
+ "initialize_local_task_subtask_batch_v2",
648
+ "claim_local_task_subtask_attempt_v2",
649
+ "heartbeat_local_task_subtask_attempt_v2",
650
+ "mark_local_task_subtask_invocation_started_v2",
651
+ "complete_local_task_subtask_attempt_v2",
652
+ "abandon_local_task_subtask_attempt_v2",
653
+ ];
654
+ if (!api || names.some((name) => typeof api[name] !== "function")) {
655
+ throw new TypeError(
656
+ `api must implement ${names.join(", ")}`,
657
+ );
658
+ }
659
+ }
660
+
661
+ function assertClaimMatchesPlan(normalized, plan) {
662
+ const candidate = plan.candidates.find(
663
+ (item) => item.candidate_key === normalized.step.stable_key,
664
+ );
665
+ const expectedSerial = plan.mode === "serial_uploaded";
666
+ if (
667
+ !candidate ||
668
+ normalized.batch.mode !== plan.mode ||
669
+ normalized.batch.decision_hash !== plan.decision_hash ||
670
+ normalized.batch.reason_codes.length !== plan.reason_codes.length ||
671
+ normalized.batch.reason_codes.some(
672
+ (code, index) => code !== plan.reason_codes[index],
673
+ ) ||
674
+ normalized.batch.fanout !== plan.candidates.length ||
675
+ normalized.batch.parallelism > (expectedSerial ? 1 : 2) ||
676
+ candidate.title !== normalized.step.title ||
677
+ candidate.instruction_fragment !== normalized.step.instruction ||
678
+ candidate.independence_reason !== normalized.step.independence_reason ||
679
+ candidate.source_ids.length !== normalized.step.source_ids.length ||
680
+ candidate.source_ids.some(
681
+ (sourceId, index) => sourceId !== normalized.step.source_ids[index],
682
+ )
683
+ ) {
684
+ throw new BoundedLocalTaskSubtaskContractError(
685
+ "The bounded subtask claim does not match its stored batch plan.",
686
+ );
687
+ }
688
+ }
689
+
690
+ function deepFreeze(value) {
691
+ if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
692
+ Object.freeze(value);
693
+ for (const child of Object.values(value)) deepFreeze(child);
694
+ return value;
695
+ }
696
+
697
+ function poolError(message, code, cause = null) {
698
+ const error = new Error(message, cause ? { cause } : undefined);
699
+ error.code = code;
700
+ return error;
701
+ }
702
+
703
+ function abortError() {
704
+ return poolError(
705
+ "The bounded Local Task subtask pool was cancelled.",
706
+ "local_task_subtask_cancelled",
707
+ );
708
+ }