@relayflows/sdk 2.0.45 → 2.0.47

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 (111) hide show
  1. package/dist/authored-flow-error.d.ts +1 -1
  2. package/dist/authored-flow-error.d.ts.map +1 -1
  3. package/dist/authored-flow-error.js.map +1 -1
  4. package/dist/authored-helper-effect.d.ts.map +1 -1
  5. package/dist/authored-helper-effect.js +9 -0
  6. package/dist/authored-helper-effect.js.map +1 -1
  7. package/dist/authored-root.d.ts.map +1 -1
  8. package/dist/authored-root.js +17 -0
  9. package/dist/authored-root.js.map +1 -1
  10. package/dist/cli/direct-run.d.ts.map +1 -1
  11. package/dist/cli/direct-run.js +7 -0
  12. package/dist/cli/direct-run.js.map +1 -1
  13. package/dist/cli/run.d.ts.map +1 -1
  14. package/dist/cli/run.js +31 -2
  15. package/dist/cli/run.js.map +1 -1
  16. package/dist/cloud-mirror-step.d.ts.map +1 -1
  17. package/dist/cloud-mirror-step.js +6 -1
  18. package/dist/cloud-mirror-step.js.map +1 -1
  19. package/dist/compile.d.ts +1 -0
  20. package/dist/compile.d.ts.map +1 -1
  21. package/dist/compile.js +2 -1
  22. package/dist/compile.js.map +1 -1
  23. package/dist/failure-kinds.d.ts +1 -1
  24. package/dist/failure-kinds.d.ts.map +1 -1
  25. package/dist/failure-kinds.js +1 -0
  26. package/dist/failure-kinds.js.map +1 -1
  27. package/dist/flow-tool-adapters.d.ts +56 -0
  28. package/dist/flow-tool-adapters.d.ts.map +1 -0
  29. package/dist/flow-tool-adapters.js +42 -0
  30. package/dist/flow-tool-adapters.js.map +1 -0
  31. package/dist/flow-tool-client.d.ts +38 -0
  32. package/dist/flow-tool-client.d.ts.map +1 -0
  33. package/dist/flow-tool-client.js +105 -0
  34. package/dist/flow-tool-client.js.map +1 -0
  35. package/dist/flow-tool-contract.d.ts +103 -0
  36. package/dist/flow-tool-contract.d.ts.map +1 -0
  37. package/dist/flow-tool-contract.js +75 -0
  38. package/dist/flow-tool-contract.js.map +1 -0
  39. package/dist/flow-tool-definitions.d.ts +19 -0
  40. package/dist/flow-tool-definitions.d.ts.map +1 -0
  41. package/dist/flow-tool-definitions.js +12 -0
  42. package/dist/flow-tool-definitions.js.map +1 -0
  43. package/dist/flow-tool-http.d.ts +14 -0
  44. package/dist/flow-tool-http.d.ts.map +1 -0
  45. package/dist/flow-tool-http.js +175 -0
  46. package/dist/flow-tool-http.js.map +1 -0
  47. package/dist/flow-tool-kernel.d.ts +27 -0
  48. package/dist/flow-tool-kernel.d.ts.map +1 -0
  49. package/dist/flow-tool-kernel.js +310 -0
  50. package/dist/flow-tool-kernel.js.map +1 -0
  51. package/dist/flow-tool-manifest.d.ts +30 -0
  52. package/dist/flow-tool-manifest.d.ts.map +1 -0
  53. package/dist/flow-tool-manifest.js +79 -0
  54. package/dist/flow-tool-manifest.js.map +1 -0
  55. package/dist/flow-tool-schema.d.ts +15 -0
  56. package/dist/flow-tool-schema.d.ts.map +1 -0
  57. package/dist/flow-tool-schema.js +70 -0
  58. package/dist/flow-tool-schema.js.map +1 -0
  59. package/dist/flow-tool-wire.d.ts +26 -0
  60. package/dist/flow-tool-wire.d.ts.map +1 -0
  61. package/dist/flow-tool-wire.js +195 -0
  62. package/dist/flow-tool-wire.js.map +1 -0
  63. package/dist/helper-park.d.ts +14 -0
  64. package/dist/helper-park.d.ts.map +1 -0
  65. package/dist/helper-park.js +40 -0
  66. package/dist/helper-park.js.map +1 -0
  67. package/dist/helper-receipt.d.ts +11 -0
  68. package/dist/helper-receipt.d.ts.map +1 -0
  69. package/dist/helper-receipt.js +100 -0
  70. package/dist/helper-receipt.js.map +1 -0
  71. package/dist/helper-writeback.d.ts.map +1 -1
  72. package/dist/helper-writeback.js +7 -2
  73. package/dist/helper-writeback.js.map +1 -1
  74. package/dist/index.d.ts +9 -0
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +9 -0
  77. package/dist/index.js.map +1 -1
  78. package/dist/journal-client.d.ts +5 -1
  79. package/dist/journal-client.d.ts.map +1 -1
  80. package/dist/journal-client.js +8 -2
  81. package/dist/journal-client.js.map +1 -1
  82. package/dist/protocol.d.ts +6 -0
  83. package/dist/protocol.d.ts.map +1 -1
  84. package/dist/yaml-helper-effect.d.ts.map +1 -1
  85. package/dist/yaml-helper-effect.js +32 -23
  86. package/dist/yaml-helper-effect.js.map +1 -1
  87. package/package.json +2 -2
  88. package/src/authored-flow-error.ts +1 -0
  89. package/src/authored-helper-effect.ts +9 -0
  90. package/src/authored-root.ts +17 -0
  91. package/src/cli/direct-run.ts +7 -0
  92. package/src/cli/run.ts +30 -2
  93. package/src/cloud-mirror-step.ts +6 -1
  94. package/src/compile.ts +2 -1
  95. package/src/failure-kinds.ts +1 -0
  96. package/src/flow-tool-adapters.ts +43 -0
  97. package/src/flow-tool-client.ts +115 -0
  98. package/src/flow-tool-contract.ts +153 -0
  99. package/src/flow-tool-definitions.ts +27 -0
  100. package/src/flow-tool-http.ts +134 -0
  101. package/src/flow-tool-kernel.ts +319 -0
  102. package/src/flow-tool-manifest.ts +104 -0
  103. package/src/flow-tool-schema.ts +66 -0
  104. package/src/flow-tool-wire.ts +152 -0
  105. package/src/helper-park.ts +45 -0
  106. package/src/helper-receipt.ts +90 -0
  107. package/src/helper-writeback.ts +8 -3
  108. package/src/index.ts +21 -0
  109. package/src/journal-client.ts +8 -2
  110. package/src/protocol.ts +6 -0
  111. package/src/yaml-helper-effect.ts +12 -5
@@ -0,0 +1,45 @@
1
+ import type { JournalClient } from './journal-client.js';
2
+ import type { StepDispatchEvent } from './protocol.js';
3
+ import { HelperWritebackPending } from './helper-receipt.js';
4
+
5
+ export function helperReceiptWaitId(stepId: string, attempt: number): string {
6
+ return `helper-receipt:${stepId}:${attempt}`;
7
+ }
8
+
9
+ /** Journal a human-wait park before the worker connection closes.
10
+ * Closing without this is a transport failure, and the default budget is one
11
+ * extra retry: two receipt timeouts then fail the accepted write.
12
+ */
13
+ export async function parkHelperReceipt(
14
+ client: JournalClient, dispatch: StepDispatchEvent, error: HelperWritebackPending,
15
+ ): Promise<void> {
16
+ await client.stepWait(dispatch.run_id, dispatch.step_id, dispatch.attempt, dispatch.idempotency_key, {
17
+ wait_id: helperReceiptWaitId(dispatch.step_id, dispatch.attempt),
18
+ prompt: error.message,
19
+ requested_of: 'relayfile-receipt',
20
+ options: ['resume'],
21
+ });
22
+ }
23
+
24
+ /** Resume continues the same accepted write. Answering the park redispatches
25
+ * the attempt; it does not submit another provider draft.
26
+ */
27
+ export async function releaseHelperReceiptWaits(client: JournalClient, runId: string): Promise<void> {
28
+ type WaitEntry = { seq?: number; entry_type: string; payload?: { wait_id?: string } };
29
+ const entries: WaitEntry[] = [];
30
+ let from = 1;
31
+ for (;;) {
32
+ const page = (await client.journalRead(runId, from, 1000)).entries as WaitEntry[];
33
+ if (page.length === 0) break;
34
+ entries.push(...page);
35
+ const last = page[page.length - 1]?.seq;
36
+ if (last === undefined || page.length < 1000) break;
37
+ from = last + 1;
38
+ }
39
+ const closed = new Set(entries.filter(entry => entry.entry_type === 'wait.completed').map(entry => entry.payload?.wait_id));
40
+ for (const entry of entries) {
41
+ const waitId = entry.payload?.wait_id;
42
+ if (entry.entry_type !== 'wait.human' || waitId === undefined || !waitId.startsWith('helper-receipt:') || closed.has(waitId)) continue;
43
+ await client.eventEmit(runId, waitId, { answer: 'resume', answeredBy: 'flows-resume' });
44
+ }
45
+ }
@@ -0,0 +1,90 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { resolve, relative, isAbsolute } from 'node:path';
3
+ import { setTimeout as delay } from 'node:timers/promises';
4
+ import { writeJsonFile, type WritebackReceipt, type WritebackResult } from '@relayfile/adapter-core/vfs-client';
5
+ import { atomicJson, receiptPath, readHelperReceipt } from './helper-storage.js';
6
+ import { AuthoredFlowExecutionError } from './authored-flow-error.js';
7
+
8
+ export class HelperWritebackPending extends AuthoredFlowExecutionError {
9
+ constructor(readonly writeId: string, readonly path: string, runId: string) {
10
+ super('helper_writeback_pending', `write ${writeId} accepted or submission interrupted; delivery receipt pending at ${path}. Resume to wait; do not re-issue the write.`, undefined, runId);
11
+ }
12
+ }
13
+
14
+ export function helperReceiptTimeoutMs(): number {
15
+ const value = process.env.RELAYFLOW_HELPER_RECEIPT_TIMEOUT_MS;
16
+ if (value === undefined || value.trim() === '') return 60_000;
17
+ const timeout = Number(value);
18
+ if (!Number.isSafeInteger(timeout) || timeout <= 0) throw new Error('RELAYFLOW_HELPER_RECEIPT_TIMEOUT_MS must be a positive integer');
19
+ return timeout;
20
+ }
21
+
22
+ /** Persist intent before mount I/O: an interrupted submission is never blindly repeated. */
23
+ export async function writeHelperDraft(mount: string, provider: string, operation: string,
24
+ path: string, body: Record<string, unknown>, dataDir: string, runId: string, stepId: string,
25
+ signal: AbortSignal): Promise<WritebackResult> {
26
+ const timeout = helperReceiptTimeoutMs();
27
+ const writeId = `${runId}:${stepId}`;
28
+ const file = receiptPath(dataDir, runId, stepId) + '.pending';
29
+ let intent: { path: string; absolutePath: string; body: Record<string, unknown> };
30
+ try { intent = await readHelperReceipt(file) as typeof intent; }
31
+ catch (error) {
32
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
33
+ const absolutePath = resolve(mount, path.replace(/^\/+/, ''));
34
+ const inside = relative(mount, absolutePath);
35
+ if (inside === '..' || inside.startsWith('../') || isAbsolute(inside)) throw new Error('Helper path escapes mount root');
36
+ intent = { path, absolutePath, body };
37
+ await atomicJson(file, intent);
38
+ signal.throwIfAborted();
39
+ const submitted = await writeJsonFile({ relayfileMountRoot: mount, writebackTimeoutMs: 0 }, provider, operation, path, body);
40
+ if (providerReceipt(submitted.receipt, intent.body)) {
41
+ return { ...submitted, deliveryStatus: 'confirmed', receipt: stringReceipt(submitted.receipt) };
42
+ }
43
+ }
44
+ const deadline = Date.now() + timeout;
45
+ do {
46
+ signal.throwIfAborted();
47
+ let receipt: WritebackReceipt | undefined;
48
+ try { receipt = JSON.parse(await readFile(intent.absolutePath, 'utf8')) as WritebackReceipt; }
49
+ catch (error) {
50
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error;
51
+ }
52
+ // Identity fields only. A path, timestamp, or error object written while
53
+ // the adapter is still flushing is not a provider delivery.
54
+ if (providerReceipt(receipt, intent.body)) {
55
+ return { path: intent.path, absolutePath: intent.absolutePath, deliveryStatus: 'confirmed', receipt: stringReceipt(receipt) };
56
+ }
57
+ const remaining = deadline - Date.now();
58
+ if (remaining <= 0) break;
59
+ await delay(Math.min(250, remaining), undefined, { signal });
60
+ } while (Date.now() <= deadline);
61
+ throw new HelperWritebackPending(writeId, intent.path, runId);
62
+ }
63
+
64
+ /** A provider id the draft did not already carry. Path and created are not delivery. */
65
+ function providerReceipt(receipt: WritebackReceipt | undefined, draft: Record<string, unknown>): receipt is WritebackReceipt {
66
+ if (receipt === undefined || typeof receipt !== 'object' || Array.isArray(receipt)) return false;
67
+ return (['id', 'externalId', 'ts'] as const).some(key => {
68
+ const value = receiptIdentity(receipt[key]);
69
+ return value !== undefined && value !== receiptIdentity(draft[key]);
70
+ });
71
+ }
72
+
73
+ /** GitHub mount files store comment and issue ids as JSON numbers. */
74
+ function receiptIdentity(value: unknown): string | undefined {
75
+ if (typeof value === 'string') {
76
+ const trimmed = value.trim();
77
+ return trimmed.length > 0 ? trimmed : undefined;
78
+ }
79
+ if (typeof value === 'number' && Number.isSafeInteger(value)) return String(value);
80
+ return undefined;
81
+ }
82
+
83
+ function stringReceipt(receipt: WritebackReceipt): WritebackReceipt {
84
+ const next: WritebackReceipt = { ...receipt };
85
+ for (const key of ['id', 'externalId', 'ts'] as const) {
86
+ const value = receiptIdentity(receipt[key]);
87
+ if (value !== undefined) next[key] = value;
88
+ }
89
+ return next;
90
+ }
@@ -1,11 +1,12 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { join } from 'node:path';
3
- import { writeJsonFile, readJsonFile, listJsonFiles, type WritebackResult } from '@relayfile/adapter-core/vfs-client';
3
+ import { readJsonFile, listJsonFiles, type WritebackResult } from '@relayfile/adapter-core/vfs-client';
4
4
  import type { RelayTransport, RelayTransportRequest } from '@relayfile/relay-helpers/transport';
5
5
  import { helperClients, helperProviders, invokeHelper, type HelperCall } from '@relayflows/surface/runtime';
6
6
  import { providerMount } from './slack-preflight.js';
7
7
  import { atomicJson } from './helper-storage.js';
8
8
  import type { SlackCall } from './slack-writeback.js';
9
+ import { writeHelperDraft, HelperWritebackPending } from './helper-receipt.js';
9
10
 
10
11
  export class HelperDeliveryError extends Error {}
11
12
 
@@ -14,7 +15,10 @@ export async function helperWriteback(call: HelperCall, dataDir: string, runId:
14
15
  if (!factory) throw new Error(`No writeback client for ${call.provider}`);
15
16
  const { transport } = helperTransport(call, dataDir, runId, stepId, signal);
16
17
  try { return await invokeHelper(factory, call, transport); }
17
- catch (cause) { throw new HelperDeliveryError(cause instanceof Error ? cause.message : String(cause), { cause }); }
18
+ catch (cause) {
19
+ if (cause instanceof HelperWritebackPending) throw cause;
20
+ throw new HelperDeliveryError(cause instanceof Error ? cause.message : String(cause), { cause });
21
+ }
18
22
  }
19
23
 
20
24
  /** One transport for every provider, including Slack. Only confirmed writes succeed. */
@@ -65,8 +69,9 @@ export function helperTransport(call: HelperCall | SlackCall, dataDir: string, r
65
69
  runId, stepId, request: { ...request, body }, receipt: result.receipt,
66
70
  });
67
71
  } else {
68
- try { result = await writeJsonFile(options(), call.provider, `write.${request.resource}`, draft, body); }
72
+ try { result = await writeHelperDraft(options().relayfileMountRoot, call.provider, `write.${request.resource}`, draft, body, dataDir, runId, stepId, signal); }
69
73
  catch (cause) {
74
+ if (cause instanceof HelperWritebackPending) throw cause;
70
75
  // Upstream created() converts pending/terminal adapter errors into values.
71
76
  // A journal effect must fail instead of confirming an undelivered write.
72
77
  throw new Error(`${call.provider} writeback failed: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
package/src/index.ts CHANGED
@@ -131,6 +131,16 @@ export { localFlowEvalExecutor, type LocalFlowEvalExecutorOptions } from './flow
131
131
  export { cloudFlowEvalExecutor, type CloudFlowEvalExecutorOptions } from './flow-eval-cloud.js';
132
132
 
133
133
  export { canonicalize, specHash } from './canonical.js';
134
+ export {
135
+ createFlowToolManifest, parseFlowToolManifest, canonicalFlowToolManifest,
136
+ validateFlowToolInput, validateFlowToolResult,
137
+ type FlowToolDeclarationV1, type FlowToolManifestV1, type FlowToolDigest,
138
+ } from './flow-tool-manifest.js';
139
+ export { FLOW_TOOL_SCHEMA_DIALECT, type FlowToolJson, type FlowToolObjectSchema } from './flow-tool-schema.js';
140
+ export {
141
+ flowToolFunctionDefinition, flowToolMcpDefinition,
142
+ type FlowToolFunctionDefinition, type FlowToolMcpDefinition,
143
+ } from './flow-tool-definitions.js';
134
144
  export {
135
145
  compileAndHash,
136
146
  compileSpec,
@@ -295,6 +305,17 @@ export {
295
305
 
296
306
  export { createFlow, type CreateFlowOptions, type CreatedFlow } from './create-flow.js';
297
307
 
308
+ export * from './flow-tool-contract.js';
309
+ export { parseFlowToolCatalog, canonicalFlowToolCatalog, parseFlowToolEntry, parseFlowToolRun, parseFlowToolEvent,
310
+ parseFlowToolEvidence, parseFlowToolInvocation, flowToolInputDigest, flowToolRunLinks } from './flow-tool-wire.js';
311
+ export { FlowToolClient, type FlowToolTransport, type FlowToolRequest, type FlowToolInvocationOptions } from './flow-tool-client.js';
312
+ export { createFlowToolHttpTransport, type FlowToolHttpOptions } from './flow-tool-http.js';
313
+ export { createFlowToolAdapters } from './flow-tool-adapters.js';
314
+ export {
315
+ createKernelFlowToolControlPlane, FLOW_TOOL_INPUT_PLACEHOLDER,
316
+ type KernelFlowToolDeployment, type KernelFlowToolControlPlaneOptions,
317
+ } from './flow-tool-kernel.js';
318
+
298
319
  export { renderProgress, type ProgressEvent } from './progress.js';
299
320
  export { webhookTriggerSpec } from './trigger-executor.js';
300
321
  export { scheduleTriggerSpec, scheduleLowering, SCHEDULE_EXECUTOR, type ScheduleLowering } from './schedule-trigger.js';
@@ -57,6 +57,8 @@ export class JournalClient extends JournalConnection {
57
57
  private readonly budgetHandshake: boolean;
58
58
  /** Additive request capabilities the daemon advertised at `hello`; none until then. */
59
59
  private features: ReadonlySet<string> = new Set();
60
+ /** `hello.det_withheld`: names whose values never go into a sent env. */
61
+ private withheld: ReadonlySet<string> = new Set();
60
62
 
61
63
  constructor(socketPath: string, options: JournalClientOptions = {}) {
62
64
  super(socketPath, options.requestTimeoutMs ?? 30_000, options.connectTimeoutMs ?? 2_000);
@@ -112,6 +114,8 @@ export class JournalClient extends JournalConnection {
112
114
  const result = this.budgeted === undefined || !this.budgetHandshake ? await this.request('hello', params)
113
115
  : await this.budgeted.handshake(params, this.requestTimeoutMs);
114
116
  this.features = new Set(Array.isArray(result.features) ? result.features.filter(f => typeof f === 'string') : []);
117
+ this.withheld = new Set(Array.isArray(result.det_withheld)
118
+ ? result.det_withheld.filter(name => typeof name === 'string') : []);
115
119
  return result;
116
120
  }
117
121
 
@@ -149,13 +153,15 @@ export class JournalClient extends JournalConnection {
149
153
  * This process's environment, for the run's `f.run` steps. A shared daemon
150
154
  * otherwise spawns them in the environment of whichever CLI started it
151
155
  * (kernel/DAEMON-LIFECYCLE.md §3). Sent only to a daemon that lists
152
- * `step_env`, so an older one never sees an unknown field.
156
+ * `step_env`, so an older one never sees an unknown field. Names the
157
+ * daemon withholds (`hello.det_withheld`) are left out, on start and resume
158
+ * alike, so their values never cross the socket from this client.
153
159
  */
154
160
  private stepEnv(): { env?: Record<string, string> } {
155
161
  if (!this.features.has('step_env')) return {};
156
162
  const env: Record<string, string> = {};
157
163
  for (const [key, value] of Object.entries(process.env)) {
158
- if (value !== undefined) env[key] = value;
164
+ if (value !== undefined && !this.withheld.has(key)) env[key] = value;
159
165
  }
160
166
  return { env };
161
167
  }
package/src/protocol.ts CHANGED
@@ -84,6 +84,12 @@ export interface HelloResult {
84
84
  * predate it, which then accept none of the optional fields it gates.
85
85
  */
86
86
  features?: string[];
87
+ /**
88
+ * Env names the daemon strips from every deterministic step
89
+ * (`relayflowd serve --det-withhold`). Names only. A client must leave
90
+ * their values out of the `env` it sends; the daemon strips them anyway.
91
+ */
92
+ det_withheld?: string[];
87
93
  }
88
94
 
89
95
  export interface StepSpend {
@@ -1,7 +1,8 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { statSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
- import { writeJsonFile } from '@relayfile/adapter-core/vfs-client';
4
+ import { HelperWritebackPending, writeHelperDraft } from './helper-receipt.js';
5
+ import { parkHelperReceipt } from './helper-park.js';
5
6
  import type { RelayTransport } from '@relayfile/relay-helpers';
6
7
  import type { JournalClient } from './journal-client.js';
7
8
  import type { StepDispatchEvent } from './protocol.js';
@@ -37,8 +38,8 @@ async function writeback(call: HelperCall, dataDir: string, runId: string, stepI
37
38
  // Item updates keep the client's canonical path. Creates use a stable draft.
38
39
  const path = request.path.endsWith('.json') ? request.path
39
40
  : `${request.path}/draft-${createHash('sha256').update(idempotencyKey).digest('hex')}.json`;
40
- const result = await writeJsonFile({ relayfileMountRoot: mount }, request.provider,
41
- `write.${request.resource}`, path, body);
41
+ const result = await writeHelperDraft(mount, request.provider,
42
+ `write.${request.resource}`, path, body, dataDir, runId, stepId, signal);
42
43
  if (result.deliveryStatus !== 'confirmed' || !result.receipt) {
43
44
  throw new Error(`${call.provider} writeback is pending; no delivery receipt`);
44
45
  }
@@ -54,7 +55,9 @@ export async function completeHelperDispatch(
54
55
  client: JournalClient, dispatch: StepDispatchEvent, call: HelperCall, dataDir: string,
55
56
  ): Promise<void> {
56
57
  const surfacePath = `/${call.provider}`;
57
- const output = await withWorkerLease(client, dispatch, async signal => {
58
+ let output: { idempotencyKey: string; receipt: unknown };
59
+ try {
60
+ output = await withWorkerLease(client, dispatch, async signal => {
58
61
  const file = receiptPath(dataDir, dispatch.run_id, dispatch.step_id);
59
62
  let receipt: unknown;
60
63
  await client.performEffect({
@@ -72,7 +75,11 @@ export async function completeHelperDispatch(
72
75
  });
73
76
  if (receipt === undefined) receipt = await readSlackReceipt(file);
74
77
  return { ...call, idempotencyKey: `${dispatch.run_id}:${dispatch.step_id}`, receipt };
75
- });
78
+ });
79
+ } catch (error) {
80
+ if (error instanceof HelperWritebackPending) await parkHelperReceipt(client, dispatch, error);
81
+ throw error;
82
+ }
76
83
  await client.stepComplete(dispatch.run_id, dispatch.step_id, dispatch.attempt,
77
84
  dispatch.idempotency_key, 'success', { output, started_pins: dispatch.pins, end_pins: dispatch.pins, reported_cost: NO_MODEL_COST,
78
85
  effects: [{ surface_path: surfacePath, idempotency_key: dispatch.idempotency_key }] });