@relayflows/sdk 2.0.36 → 2.0.38

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 (230) hide show
  1. package/dist/agent-reaper.d.ts +1 -1
  2. package/dist/agent-reaper.d.ts.map +1 -1
  3. package/dist/agent-reaper.js +6 -4
  4. package/dist/agent-reaper.js.map +1 -1
  5. package/dist/authored-budget.d.ts +26 -1
  6. package/dist/authored-budget.d.ts.map +1 -1
  7. package/dist/authored-budget.js +72 -2
  8. package/dist/authored-budget.js.map +1 -1
  9. package/dist/authored-flow-error.d.ts +7 -1
  10. package/dist/authored-flow-error.d.ts.map +1 -1
  11. package/dist/authored-flow-error.js.map +1 -1
  12. package/dist/authored-flow-executor.d.ts +2 -0
  13. package/dist/authored-flow-executor.d.ts.map +1 -1
  14. package/dist/authored-flow-executor.js +2 -1
  15. package/dist/authored-flow-executor.js.map +1 -1
  16. package/dist/authored-helper-effect.d.ts.map +1 -1
  17. package/dist/authored-helper-effect.js +3 -2
  18. package/dist/authored-helper-effect.js.map +1 -1
  19. package/dist/authored-mcp.d.ts.map +1 -1
  20. package/dist/authored-mcp.js +2 -0
  21. package/dist/authored-mcp.js.map +1 -1
  22. package/dist/authored-node-entry.js +6 -0
  23. package/dist/authored-node-entry.js.map +1 -1
  24. package/dist/authored-node-runner.d.ts.map +1 -1
  25. package/dist/authored-node-runner.js +5 -3
  26. package/dist/authored-node-runner.js.map +1 -1
  27. package/dist/authored-plugin-effect.d.ts.map +1 -1
  28. package/dist/authored-plugin-effect.js +2 -0
  29. package/dist/authored-plugin-effect.js.map +1 -1
  30. package/dist/authored-preflight.d.ts +1 -1
  31. package/dist/authored-preflight.d.ts.map +1 -1
  32. package/dist/authored-preflight.js +2 -2
  33. package/dist/authored-preflight.js.map +1 -1
  34. package/dist/authored-root.d.ts +2 -0
  35. package/dist/authored-root.d.ts.map +1 -1
  36. package/dist/authored-root.js +146 -6
  37. package/dist/authored-root.js.map +1 -1
  38. package/dist/authored-verdict.d.ts +2 -0
  39. package/dist/authored-verdict.d.ts.map +1 -1
  40. package/dist/authored-verdict.js +2 -1
  41. package/dist/authored-verdict.js.map +1 -1
  42. package/dist/authored-worker-step.d.ts +1 -1
  43. package/dist/authored-worker-step.d.ts.map +1 -1
  44. package/dist/authored-worker-step.js +3 -2
  45. package/dist/authored-worker-step.js.map +1 -1
  46. package/dist/child-stop.d.ts +3 -1
  47. package/dist/child-stop.d.ts.map +1 -1
  48. package/dist/child-stop.js +42 -2
  49. package/dist/child-stop.js.map +1 -1
  50. package/dist/cli/add-extension.js +1 -1
  51. package/dist/cli/add-extension.js.map +1 -1
  52. package/dist/cli/check.d.ts +2 -0
  53. package/dist/cli/check.d.ts.map +1 -1
  54. package/dist/cli/check.js +28 -10
  55. package/dist/cli/check.js.map +1 -1
  56. package/dist/cli/cli-probe.d.ts +3 -2
  57. package/dist/cli/cli-probe.d.ts.map +1 -1
  58. package/dist/cli/cli-probe.js +79 -42
  59. package/dist/cli/cli-probe.js.map +1 -1
  60. package/dist/cli/direct-run.d.ts.map +1 -1
  61. package/dist/cli/direct-run.js +11 -3
  62. package/dist/cli/direct-run.js.map +1 -1
  63. package/dist/cli/pinned-cli-alias.d.ts +13 -0
  64. package/dist/cli/pinned-cli-alias.d.ts.map +1 -0
  65. package/dist/cli/pinned-cli-alias.js +58 -0
  66. package/dist/cli/pinned-cli-alias.js.map +1 -0
  67. package/dist/cli/run.d.ts +8 -0
  68. package/dist/cli/run.d.ts.map +1 -1
  69. package/dist/cli/run.js +106 -17
  70. package/dist/cli/run.js.map +1 -1
  71. package/dist/cli/step-failure.d.ts.map +1 -1
  72. package/dist/cli/step-failure.js +18 -0
  73. package/dist/cli/step-failure.js.map +1 -1
  74. package/dist/cloud-run.d.ts.map +1 -1
  75. package/dist/cloud-run.js +9 -2
  76. package/dist/cloud-run.js.map +1 -1
  77. package/dist/communication/local.d.ts +1 -1
  78. package/dist/communication/local.d.ts.map +1 -1
  79. package/dist/communication/local.js +2 -2
  80. package/dist/communication/local.js.map +1 -1
  81. package/dist/communication/worker.d.ts +1 -1
  82. package/dist/communication/worker.d.ts.map +1 -1
  83. package/dist/communication/worker.js +27 -9
  84. package/dist/communication/worker.js.map +1 -1
  85. package/dist/compile.d.ts.map +1 -1
  86. package/dist/compile.js +16 -7
  87. package/dist/compile.js.map +1 -1
  88. package/dist/decimal-dollars.d.ts +12 -0
  89. package/dist/decimal-dollars.d.ts.map +1 -0
  90. package/dist/decimal-dollars.js +39 -0
  91. package/dist/decimal-dollars.js.map +1 -0
  92. package/dist/effect-channel.d.ts.map +1 -1
  93. package/dist/effect-channel.js +2 -0
  94. package/dist/effect-channel.js.map +1 -1
  95. package/dist/failure-kinds.d.ts +9 -1
  96. package/dist/failure-kinds.d.ts.map +1 -1
  97. package/dist/failure-kinds.js +6 -0
  98. package/dist/failure-kinds.js.map +1 -1
  99. package/dist/flow-extension-loader.d.ts +1 -1
  100. package/dist/flow-extension-loader.d.ts.map +1 -1
  101. package/dist/flow-extension-loader.js +143 -14
  102. package/dist/flow-extension-loader.js.map +1 -1
  103. package/dist/flow-extension-manifest.d.ts +1 -0
  104. package/dist/flow-extension-manifest.d.ts.map +1 -1
  105. package/dist/flow-extension-manifest.js +12 -3
  106. package/dist/flow-extension-manifest.js.map +1 -1
  107. package/dist/helper-instruction.d.ts +3 -0
  108. package/dist/helper-instruction.d.ts.map +1 -0
  109. package/dist/helper-instruction.js +3 -0
  110. package/dist/helper-instruction.js.map +1 -0
  111. package/dist/hosted-extension-manifest.d.ts.map +1 -1
  112. package/dist/hosted-extension-manifest.js +25 -4
  113. package/dist/hosted-extension-manifest.js.map +1 -1
  114. package/dist/hosted-extension-runtime.js +1 -1
  115. package/dist/hosted-extension-sandbox.js +2 -2
  116. package/dist/journal-client.d.ts +5 -2
  117. package/dist/journal-client.d.ts.map +1 -1
  118. package/dist/journal-client.js +12 -4
  119. package/dist/journal-client.js.map +1 -1
  120. package/dist/llm-worker.d.ts +2 -1
  121. package/dist/llm-worker.d.ts.map +1 -1
  122. package/dist/llm-worker.js +10 -3
  123. package/dist/llm-worker.js.map +1 -1
  124. package/dist/local-agent-environment.d.ts +13 -0
  125. package/dist/local-agent-environment.d.ts.map +1 -0
  126. package/dist/local-agent-environment.js +104 -0
  127. package/dist/local-agent-environment.js.map +1 -0
  128. package/dist/local-agent.d.ts +1 -1
  129. package/dist/local-agent.d.ts.map +1 -1
  130. package/dist/local-agent.js +2 -2
  131. package/dist/local-agent.js.map +1 -1
  132. package/dist/mcp-stdio.d.ts +2 -1
  133. package/dist/mcp-stdio.d.ts.map +1 -1
  134. package/dist/mcp-stdio.js +38 -21
  135. package/dist/mcp-stdio.js.map +1 -1
  136. package/dist/preflight.d.ts +2 -0
  137. package/dist/preflight.d.ts.map +1 -1
  138. package/dist/preflight.js.map +1 -1
  139. package/dist/protocol.d.ts +30 -0
  140. package/dist/protocol.d.ts.map +1 -1
  141. package/dist/reported-cost-total.d.ts +41 -0
  142. package/dist/reported-cost-total.d.ts.map +1 -0
  143. package/dist/reported-cost-total.js +63 -0
  144. package/dist/reported-cost-total.js.map +1 -0
  145. package/dist/reported-cost.d.ts +24 -0
  146. package/dist/reported-cost.d.ts.map +1 -0
  147. package/dist/reported-cost.js +49 -0
  148. package/dist/reported-cost.js.map +1 -0
  149. package/dist/resolved-cli-identity.d.ts +14 -0
  150. package/dist/resolved-cli-identity.d.ts.map +1 -0
  151. package/dist/resolved-cli-identity.js +19 -0
  152. package/dist/resolved-cli-identity.js.map +1 -0
  153. package/dist/run-state.d.ts +9 -11
  154. package/dist/run-state.d.ts.map +1 -1
  155. package/dist/run-state.js +15 -38
  156. package/dist/run-state.js.map +1 -1
  157. package/dist/worker-cli.d.ts +3 -1
  158. package/dist/worker-cli.d.ts.map +1 -1
  159. package/dist/worker-cli.js +118 -42
  160. package/dist/worker-cli.js.map +1 -1
  161. package/dist/worker-lease.d.ts +3 -1
  162. package/dist/worker-lease.d.ts.map +1 -1
  163. package/dist/worker-lease.js +47 -9
  164. package/dist/worker-lease.js.map +1 -1
  165. package/dist/worker.d.ts +2 -0
  166. package/dist/worker.d.ts.map +1 -1
  167. package/dist/worker.js +8 -3
  168. package/dist/worker.js.map +1 -1
  169. package/dist/wrapper-session.d.ts +3 -1
  170. package/dist/wrapper-session.d.ts.map +1 -1
  171. package/dist/wrapper-session.js +93 -53
  172. package/dist/wrapper-session.js.map +1 -1
  173. package/dist/yaml-helper-effect.d.ts.map +1 -1
  174. package/dist/yaml-helper-effect.js +2 -1
  175. package/dist/yaml-helper-effect.js.map +1 -1
  176. package/dist/yaml-helpers.d.ts.map +1 -1
  177. package/dist/yaml-helpers.js +2 -1
  178. package/dist/yaml-helpers.js.map +1 -1
  179. package/package.json +2 -2
  180. package/src/agent-reaper.ts +7 -4
  181. package/src/authored-budget.ts +73 -1
  182. package/src/authored-flow-error.ts +6 -0
  183. package/src/authored-flow-executor.ts +4 -1
  184. package/src/authored-helper-effect.ts +3 -2
  185. package/src/authored-mcp.ts +2 -0
  186. package/src/authored-node-entry.ts +8 -1
  187. package/src/authored-node-runner.ts +5 -3
  188. package/src/authored-plugin-effect.ts +2 -0
  189. package/src/authored-preflight.ts +2 -2
  190. package/src/authored-root.ts +157 -7
  191. package/src/authored-verdict.ts +2 -1
  192. package/src/authored-worker-step.ts +3 -1
  193. package/src/child-stop.ts +40 -1
  194. package/src/cli/add-extension.ts +1 -1
  195. package/src/cli/check.ts +31 -7
  196. package/src/cli/cli-probe.ts +80 -42
  197. package/src/cli/direct-run.ts +11 -2
  198. package/src/cli/pinned-cli-alias.ts +74 -0
  199. package/src/cli/run.ts +118 -14
  200. package/src/cli/step-failure.ts +18 -0
  201. package/src/cloud-run.ts +9 -2
  202. package/src/communication/local.ts +3 -2
  203. package/src/communication/worker.ts +29 -11
  204. package/src/compile.ts +28 -13
  205. package/src/decimal-dollars.ts +38 -0
  206. package/src/effect-channel.ts +2 -0
  207. package/src/failure-kinds.ts +14 -0
  208. package/src/flow-extension-loader.ts +154 -14
  209. package/src/flow-extension-manifest.ts +6 -3
  210. package/src/helper-instruction.ts +2 -0
  211. package/src/hosted-extension-manifest.ts +28 -5
  212. package/src/hosted-extension-runtime.ts +1 -1
  213. package/src/hosted-extension-sandbox.ts +2 -2
  214. package/src/journal-client.ts +14 -5
  215. package/src/llm-worker.ts +11 -4
  216. package/src/local-agent-environment.ts +92 -0
  217. package/src/local-agent.ts +2 -1
  218. package/src/mcp-stdio.ts +34 -20
  219. package/src/preflight.ts +2 -0
  220. package/src/protocol.ts +31 -0
  221. package/src/reported-cost-total.ts +82 -0
  222. package/src/reported-cost.ts +51 -0
  223. package/src/resolved-cli-identity.ts +42 -0
  224. package/src/run-state.ts +25 -37
  225. package/src/worker-cli.ts +126 -39
  226. package/src/worker-lease.ts +50 -9
  227. package/src/worker.ts +14 -5
  228. package/src/wrapper-session.ts +89 -56
  229. package/src/yaml-helper-effect.ts +2 -1
  230. package/src/yaml-helpers.ts +2 -1
package/src/cli/run.ts CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  type LoweredCompletionReason,
40
40
  } from '../authored-completion.js';
41
41
  import { DEFAULT_LOCAL_AGENT_CAPACITY } from '../worker-slots.js';
42
+ import { localAgentEnvironment } from '../local-agent-environment.js';
42
43
  import {
43
44
  checkFlow,
44
45
  type CheckReport,
@@ -176,20 +177,22 @@ export async function runFlow(
176
177
  dataDir: string,
177
178
  options: RunLifecycleOptions = {},
178
179
  ): Promise<RunExecution> {
180
+ const agentEnvironment = localAgentEnvironment();
179
181
  const prepared = parseDigestReference(path) ? await prepareDigestRun(path, options.bucket) : undefined;
180
182
  if (prepared && 'exitCode' in prepared) return prepared;
181
- const checked = prepared ?? checkFlow(path);
183
+ const checked = prepared ?? checkFlow(path, { environment: agentEnvironment });
182
184
  if (!checked.report.ok || checked.flow === undefined) {
183
185
  return { exitCode: 2, report: fromCheckReport('run', checked.report) };
184
186
  }
185
187
 
186
- return executeCheckedFlow(checked, dataDir, options);
188
+ return executeCheckedFlow(checked, dataDir, options, agentEnvironment);
187
189
  }
188
190
 
189
191
  async function executeCheckedFlow(
190
192
  checked: ReturnType<typeof checkFlow>,
191
193
  dataDir: string,
192
194
  options: RunLifecycleOptions,
195
+ agentEnvironment?: NodeJS.ProcessEnv,
193
196
  ): Promise<RunExecution> {
194
197
  const socketPath = socketFor(dataDir);
195
198
  // Carry the preflight's diagnostics as a RunReport from here on, so the
@@ -213,12 +216,12 @@ async function executeCheckedFlow(
213
216
  if (options.localAgent) {
214
217
  localAgent = await attachLocalAgent(
215
218
  client, dataDir, options.onPtyReady, undefined, options.agentCapacity,
216
- undefined, declaredLocalAgentStreams(spec),
219
+ undefined, declaredLocalAgentStreams(spec), agentEnvironment,
217
220
  );
218
221
  }
219
222
  if (options.localAgent && spec.steps.some(step => step.type === 'agent' && communicationInstruction(step.instruction))) {
220
223
  const { attachCommunicationWorkers } = await import('../communication/local.js');
221
- communicationWorkers = await attachCommunicationWorkers(spec, socketPath, dataDir);
224
+ communicationWorkers = await attachCommunicationWorkers(spec, socketPath, dataDir, agentEnvironment);
222
225
  }
223
226
  if (options.onJournalEntry !== undefined) client.on('entry', options.onJournalEntry);
224
227
  const outcome = await startWatched(client, spec, options);
@@ -281,6 +284,9 @@ export async function resumeFlow(
281
284
  dataDir: string,
282
285
  options: RunLifecycleOptions = {},
283
286
  ): Promise<RunExecution> {
287
+ // Resume can load authored source; consume the descriptor before reading the
288
+ // journal so no source evaluation can race credential pickup.
289
+ const agentEnvironment = localAgentEnvironment();
284
290
  const socketPath = socketFor(dataDir);
285
291
  const base = emptyReport('resume');
286
292
  const client = new JournalClient(socketPath);
@@ -323,11 +329,12 @@ export async function resumeFlow(
323
329
  if (options.localAgent) {
324
330
  authoredAgent = await attachLocalAgent(
325
331
  client, dataDir, options.onPtyReady, authoredRoot.localAgentStream, workerCapacity,
332
+ undefined, [], agentEnvironment,
326
333
  );
327
334
  authoredLlmClient = new JournalClient(socketPath);
328
335
  await authoredLlmClient.connect();
329
336
  await authoredLlmClient.hello('flows-authored-resume-llm');
330
- authoredLlm = new LlmWorker(authoredLlmClient, `${authoredAgent.stream}-llm`, workerCapacity);
337
+ authoredLlm = new LlmWorker(authoredLlmClient, `${authoredAgent.stream}-llm`, workerCapacity, agentEnvironment);
331
338
  authoredLlm.on('error', onWorkerFailure('resume-llm', error => {
332
339
  llmFailure = error;
333
340
  client.close();
@@ -338,6 +345,7 @@ export async function resumeFlow(
338
345
  dataDir,
339
346
  localAgentStream: authoredAgent?.stream,
340
347
  ...(authoredAgent === undefined ? {} : { workerCapacity }),
348
+ ...(agentEnvironment === undefined ? {} : { agentEnvironment }),
341
349
  lifecycle: options,
342
350
  });
343
351
  if (result === undefined) throw new Error('authored root disappeared during resume');
@@ -356,11 +364,11 @@ export async function resumeFlow(
356
364
  if (!spec) throw new Error('Cannot attach a local worker: the journaled run spec is missing');
357
365
  authoredAgent = await attachLocalAgent(
358
366
  client, dataDir, options.onPtyReady, undefined, options.agentCapacity,
359
- undefined, declaredLocalAgentStreams(spec),
367
+ undefined, declaredLocalAgentStreams(spec), agentEnvironment,
360
368
  );
361
369
  if (spec?.steps.some(step => step.type === 'agent' && communicationInstruction(step.instruction))) {
362
370
  const { attachCommunicationWorkers } = await import('../communication/local.js');
363
- communicationWorkers = await attachCommunicationWorkers(spec, socketPath, dataDir);
371
+ communicationWorkers = await attachCommunicationWorkers(spec, socketPath, dataDir, agentEnvironment);
364
372
  }
365
373
  }
366
374
  const onEntry = options.onJournalEntry;
@@ -400,6 +408,9 @@ export async function resumeFlow(
400
408
  if (error instanceof AuthoredFlowExecutionError && (error.code === 'step_failed' || error.code === 'gate_failed')) {
401
409
  return authoredStepFailure('resume', base, socketPath, error, runId);
402
410
  }
411
+ if (error instanceof AuthoredFlowExecutionError && error.code === 'root_lease_lost') {
412
+ return rootLeaseLostReport('resume', base, socketPath, error, runId);
413
+ }
403
414
  // An authored resume that parks for want of a worker is a park, reported
404
415
  // like the run path's — and with the same remedy, because an authored root
405
416
  // admits its worker at run start: resuming again cannot attach one, so the
@@ -514,6 +525,38 @@ function authoredInputArgument(metadata: AuthoredRootMetadata | undefined): Auth
514
525
  * drove correctly, and it produces a report with no `status`, which is the
515
526
  * whole of what `RUN <id> unknown` ever meant.
516
527
  */
528
+ /**
529
+ * The authored root's lease was lost and not re-dispatched to this process
530
+ * (authored-root.ts driveRootFollowingRetries). Exit 1 because this CLI did
531
+ * not finish the run, but the report says what is true of it: still
532
+ * `running` in the journal, and resumable -- never `protocol_error` with the
533
+ * generic "worker wait canceled" and no run id, which is what it was.
534
+ */
535
+ export function rootLeaseLostReport(
536
+ command: RunCommand,
537
+ base: CheckReport | RunReport,
538
+ socketPath: string,
539
+ error: AuthoredFlowExecutionError,
540
+ fallbackRunId?: string,
541
+ ): RunExecution {
542
+ const rootRunId = error.rootRunId ?? fallbackRunId;
543
+ return {
544
+ exitCode: 1,
545
+ report: {
546
+ ...fromBase(command, base),
547
+ ok: false,
548
+ ...(rootRunId === undefined ? {} : { runId: rootRunId, rootRunId }),
549
+ socketPath,
550
+ status: 'running',
551
+ diagnostics: [...base.diagnostics, {
552
+ severity: 'failure',
553
+ kind: 'root_lease_lost',
554
+ message: error.message.replace(/^root_lease_lost: /, ''),
555
+ }],
556
+ },
557
+ };
558
+ }
559
+
517
560
  export function authoredStepFailure(
518
561
  command: RunCommand,
519
562
  base: CheckReport | RunReport,
@@ -790,13 +833,27 @@ export async function classifyOutcome(
790
833
  let parkedStep: ParkedStep | undefined;
791
834
  let needsHuman = false;
792
835
  let unclassifiedPolls = 0;
793
- while (current.status === 'parked') {
836
+ // `running` too: an authored body re-driven after a lost root lease re-admits
837
+ // its child by admission key and gets the EXISTING run back, mid-attempt.
838
+ // Only `parked` was followed, so that child -- on the attempt 2 the kernel
839
+ // retried it to -- fell straight through to "status running without a
840
+ // classifiable completion" and failed the flow (customer rw_3a0fcb71).
841
+ while (current.status === 'parked' || current.status === 'running') {
794
842
  const inspection = await inspectOutOfBandStep(client, current.run_id);
795
843
  // A runnable sibling may only be waiting for capacity held by a live
796
- // attempt. Keep driving until that lease releases before declaring a park.
797
- if (inspection?.parkedStep !== undefined && (inspection.needsHuman || inspection.runningStep === undefined)) {
844
+ // attempt or by a retry in backoff. Keep driving until that active work
845
+ // settles before declaring a park.
846
+ if (inspection?.parkedStep !== undefined && (inspection.needsHuman
847
+ || (inspection.runningStep === undefined && inspection.backoffStep === undefined))) {
798
848
  parkedStep = inspection.parkedStep;
799
849
  needsHuman = inspection.needsHuman;
850
+ // `run.get` reports the live snapshot as `running` for an ordinary
851
+ // runnable worker wait, even when `run.start`/`run.resume` already
852
+ // classified the outcome as parked. Conversely, a re-admitted run can
853
+ // arrive here with a stale `running` outcome after its worker vanished.
854
+ // The step-level inspection is authoritative that this is a park; do
855
+ // not copy its broader run status and lose that classification.
856
+ current = { ...current, status: 'parked' };
800
857
  break;
801
858
  }
802
859
  if (inspection?.runningStep !== undefined) {
@@ -804,6 +861,20 @@ export async function classifyOutcome(
804
861
  current = await client.runResume(current.run_id, command === 'run' || options.allowHumanInfluenced);
805
862
  continue;
806
863
  }
864
+ // A step in `backoff` is between attempts: the kernel has scheduled the
865
+ // next one (a dead worker's retry is due at once, but waits for the
866
+ // worker slot the dead attempt held; a failed attempt waits out the
867
+ // retry policy's delay, bounded by its max_backoff_ms). That is progress,
868
+ // not an unclassifiable state -- treating it as one gave up after 2s on a
869
+ // child whose attempt 2 then succeeded (customer rw_3a0fcb71). Once due,
870
+ // the step is dispatched (`running`, followed above) or, with no worker,
871
+ // left `runnable` and reported as a park.
872
+ if (inspection?.backoffStep !== undefined && inspection.runningStep === undefined) {
873
+ throwIfCanceled(options.signal, inspection.backoffStep.id);
874
+ await delay(EXPIRED_LEASE_POLL_MS, options.signal);
875
+ current = await client.runResume(current.run_id, command === 'run' || options.allowHumanInfluenced);
876
+ continue;
877
+ }
807
878
  if (inspection?.status === 'completed' || inspection?.status === 'failed') {
808
879
  current = await client.runResume(current.run_id, command === 'run' || options.allowHumanInfluenced);
809
880
  continue;
@@ -946,6 +1017,8 @@ interface OutOfBandInspection {
946
1017
  parkedStep?: ParkedStep;
947
1018
  needsHuman: boolean;
948
1019
  runningStep?: RunningStep;
1020
+ /** A step waiting out a retry backoff: its next attempt is scheduled. */
1021
+ backoffStep?: ParkedStep;
949
1022
  }
950
1023
 
951
1024
  interface RunningStep extends ParkedStep {
@@ -973,6 +1046,9 @@ async function inspectOutOfBandStep(
973
1046
  const runningEntry = entries.find(([, step]) =>
974
1047
  step.type !== 'deterministic' && step.state === 'running',
975
1048
  );
1049
+ const backoffEntry = entries.find(([, step]) =>
1050
+ step.type !== 'deterministic' && step.state === 'backoff',
1051
+ );
976
1052
  const parkedEntry = humanEntry ?? runnableEntry;
977
1053
  const parkedStep = parkedEntry === undefined ? undefined : {
978
1054
  id: parkedEntry[0],
@@ -988,11 +1064,21 @@ async function inspectOutOfBandStep(
988
1064
  needsHuman: humanEntry !== undefined,
989
1065
  ...(parkedStep !== undefined ? { parkedStep } : {}),
990
1066
  ...(runningStep !== undefined ? { runningStep } : {}),
1067
+ ...(backoffEntry !== undefined ? { backoffStep: {
1068
+ id: backoffEntry[0],
1069
+ type: backoffEntry[1].type as Extract<StepType, 'llm' | 'agent'>,
1070
+ } } : {}),
991
1071
  };
992
1072
  }
993
1073
 
994
1074
  // Match kernel/relayflowd/src/server/client.rs: allow the lease sweep to dispatch a retry.
995
1075
  export const LEASE_SWEEP_GRACE_MS = 5_000;
1076
+ // How often to re-ask the daemon about a step whose lease looks expired here.
1077
+ const EXPIRED_LEASE_POLL_MS = 250;
1078
+ // A deadline unchanged for a whole lease (relayflowd LEASE_RENEWAL_MS, 30s)
1079
+ // plus the sweep grace was renewed by nobody: the attempt is dead and the
1080
+ // daemon never swept it. Fail rather than hang.
1081
+ const STALE_LEASE_MS = 30_000 + LEASE_SWEEP_GRACE_MS;
996
1082
 
997
1083
  async function waitForRunningStep(
998
1084
  client: JournalClient,
@@ -1010,20 +1096,29 @@ async function waitForRunningStep(
1010
1096
  stepType: runningStep.type,
1011
1097
  leaseDeadlineMs,
1012
1098
  });
1099
+ // When the lease deadline last changed, on this process's monotonic clock.
1100
+ // A live worker renews every ~10s, moving the deadline; a dead lease the
1101
+ // daemon has not swept keeps it. That -- not a comparison of the daemon's
1102
+ // deadline with our wall clock -- is what separates the two under skew.
1103
+ let deadlineSeenAt = performance.now();
1013
1104
  while (true) {
1014
1105
  throwIfCanceled(options.signal, runningStep.id);
1015
1106
  const remainingMs = leaseDeadlineMs + LEASE_SWEEP_GRACE_MS - Date.now();
1016
- if (remainingMs <= 0) {
1107
+ if (performance.now() - deadlineSeenAt > STALE_LEASE_MS) {
1017
1108
  throw new Error(
1018
1109
  `worker lease for step "${runningStep.id}" expired at ${leaseDeadlineMs} without completion`,
1019
1110
  );
1020
1111
  }
1021
- await delay(Math.min(50, remainingMs), options.signal);
1112
+ // Past the deadline by THIS clock alone is not an expiry: the daemon may
1113
+ // still hold the lease (skew) or be sweeping it into a retry. Keep
1114
+ // following the step, at a slower poll, until the daemon says otherwise.
1115
+ await delay(remainingMs <= 0 ? EXPIRED_LEASE_POLL_MS : Math.min(50, remainingMs), options.signal);
1022
1116
  const snapshot = await client.runGet(runId);
1023
1117
  const step = snapshot.steps[runningStep.id];
1024
1118
  if (step?.state !== 'running') return;
1025
1119
  if (step.lease_deadline_ms !== undefined && step.lease_deadline_ms !== leaseDeadlineMs) {
1026
1120
  leaseDeadlineMs = step.lease_deadline_ms;
1121
+ deadlineSeenAt = performance.now();
1027
1122
  options.onWait?.({
1028
1123
  runId,
1029
1124
  stepId: runningStep.id,
@@ -1090,7 +1185,14 @@ function errorMessage(error: unknown): string {
1090
1185
  }
1091
1186
 
1092
1187
  function throwIfCanceled(signal: AbortSignal | undefined, stepId: string): void {
1093
- if (signal?.aborted === true) throw new Error(`waiting for running step "${stepId}" was canceled`);
1188
+ if (signal?.aborted === true) {
1189
+ throw new Error(`waiting for running step "${stepId}" was canceled${cancelCause(signal)}`, { cause: signal.reason });
1190
+ }
1191
+ }
1192
+
1193
+ /** Why a wait was canceled, when whoever aborted it said (a lost lease does). */
1194
+ function cancelCause(signal: AbortSignal): string {
1195
+ return signal.reason instanceof Error && signal.reason.name !== 'AbortError' ? `: ${signal.reason.message}` : '';
1094
1196
  }
1095
1197
 
1096
1198
  export function delay(ms: number, signal?: AbortSignal): Promise<void> {
@@ -1104,7 +1206,9 @@ export function delay(ms: number, signal?: AbortSignal): Promise<void> {
1104
1206
  const cancel = (): void => {
1105
1207
  clearTimeout(timer);
1106
1208
  signal.removeEventListener('abort', cancel);
1107
- rejectDelay(new Error('worker wait canceled'));
1209
+ // Carries the abort reason: a bare "worker wait canceled" was all a
1210
+ // lost root lease ever reported (customer rw_3a0fcb71).
1211
+ rejectDelay(new Error(`worker wait canceled${cancelCause(signal)}`, { cause: signal.reason }));
1108
1212
  };
1109
1213
  if (signal.aborted) cancel();
1110
1214
  else signal.addEventListener('abort', cancel, { once: true });
@@ -63,6 +63,8 @@ export async function stepFailureDetails(
63
63
  // Keyed by step, so interleaved steps never pool their attempts and a page
64
64
  // boundary never splits one step's history.
65
65
  const histories = new Map<string, AttemptHistory>();
66
+ // Attempts the kernel itself declared dead (see `unchargedAttempts`).
67
+ const uncharged = new Map<string, number>();
66
68
  while (true) {
67
69
  const { entries } = await client.journalRead(runId, fromSeq, 100);
68
70
  if (entries.length === 0) break;
@@ -93,6 +95,7 @@ export async function stepFailureDetails(
93
95
  // holds every entry; only this diagnostic's candidate is cleared.
94
96
  if (payload === undefined || typeof completionReason !== 'string' || completionReason === 'success') {
95
97
  histories.delete(stepId);
98
+ uncharged.delete(stepId);
96
99
  continue;
97
100
  }
98
101
  const history = histories.get(stepId) ?? { records: [], causes: [] };
@@ -101,6 +104,10 @@ export async function stepFailureDetails(
101
104
  history.records.push(attemptFailure(entry['attempt'], completionReason, payload['disposition'], payload));
102
105
  history.causes.push(failureCause(completionReason, payload));
103
106
  histories.set(stepId, history);
107
+ if (payload['disposition'] !== 'step_done' && payload['completed_by'] === 'kernel'
108
+ && (completionReason === 'crashed' || completionReason === 'lease_expired')) {
109
+ uncharged.set(stepId, (uncharged.get(stepId) ?? 0) + 1);
110
+ }
104
111
  // A terminal completion that is not a success is the failure, whatever
105
112
  // its step type. The old predicate also demanded a non-zero `exit_code`,
106
113
  // which no agent completion carries and which a deterministic step that
@@ -116,6 +123,7 @@ export async function stepFailureDetails(
116
123
  ...(stepType === undefined ? {} : { stepType }),
117
124
  ...(typeof attempt === 'number' && Number.isSafeInteger(attempt) && attempt > 0 ? { attempt } : {}),
118
125
  ...(maxIterations === undefined ? {} : { maxIterations }),
126
+ ...((uncharged.get(stepId) ?? 0) > 0 ? { unchargedAttempts: uncharged.get(stepId) } : {}),
119
127
  ...terminalEvidence(payload),
120
128
  // A single failed attempt is already fully described by the scalars
121
129
  // above; repeating it as a one-element history would add a clause to
@@ -169,6 +177,16 @@ export function renderStepEvidence(details: StepFailedDetails): string {
169
177
 
170
178
  function renderAttempt(details: StepFailedDetails): string {
171
179
  if (details.attempt === undefined) return '';
180
+ const uncharged = details.unchargedAttempts ?? 0;
181
+ // `attempt=N/M` compares like with like only when every attempt was charged.
182
+ // A worker that died (crashed / lease_expired) costs no iteration, so a
183
+ // one-iteration step can legitimately run attempt 2 -- printed as
184
+ // `attempt=2/1` it read as the budget being overrun. Say which is which.
185
+ if (uncharged > 0 && uncharged < details.attempt) {
186
+ return ` attempt=${details.attempt}`
187
+ + ` (iteration ${details.attempt - uncharged}${details.maxIterations === undefined ? '' : `/${details.maxIterations}`};`
188
+ + ` ${uncharged} earlier attempt${uncharged === 1 ? '' : 's'} lost to a dead worker, not charged)`;
189
+ }
172
190
  return ` attempt=${details.attempt}`
173
191
  + (details.maxIterations === undefined ? '' : `/${details.maxIterations}`);
174
192
  }
package/src/cloud-run.ts CHANGED
@@ -98,6 +98,7 @@ export async function prepareCloudSubmission(
98
98
  options: { input?: JsonValue; signal?: AbortSignal } = {},
99
99
  ): Promise<CloudSubmission> {
100
100
  let spec: FlowSpec | undefined;
101
+ let compiledKernelInput = false;
101
102
  let authored: { source: string; authority: CloudAuthoredAuthority; name: string; schedules: ScheduleTriggerSource[]; extensions: readonly FlowExtensionSubmission[] } | undefined;
102
103
  const inputPresent = Object.prototype.hasOwnProperty.call(options, 'input');
103
104
  let authoredInput: JsonValue | undefined;
@@ -150,7 +151,11 @@ export async function prepareCloudSubmission(
150
151
  // compatibility. If both fail, retain the original authoring diagnostic.
151
152
  try {
152
153
  spec = compileSpec(kernelToAuthoring(parsed));
153
- } catch {
154
+ compiledKernelInput = true;
155
+ } catch (kernelError) {
156
+ if (kernelError instanceof CompileError && kernelError.kind === 'untrusted_cli_identity') {
157
+ throw new CloudFlowError('invalid_input', kernelError.message);
158
+ }
154
159
  throw error;
155
160
  }
156
161
  }
@@ -176,7 +181,9 @@ export async function prepareCloudSubmission(
176
181
  const kernel = toKernelSpec(spec!);
177
182
  // JSON is a YAML subset. Sending canonical data preserves the exact spec
178
183
  // while using the server's existing YAML-to-config admission path.
179
- return { workflow: canonicalize(spec), fileType: 'yaml', inputPresent: false, specHash: specHash(kernel),
184
+ // A compiled input stays in the kernel dialect. kernelToAuthoring refuses
185
+ // serialized cli_identity: only local preflight may mint that proof.
186
+ return { workflow: canonicalize(compiledKernelInput ? kernel : spec), fileType: 'yaml', inputPresent: false, specHash: specHash(kernel),
180
187
  name: spec!.name ?? "flow", schedules: [] };
181
188
  }
182
189
  return {
@@ -8,7 +8,8 @@ import { loadRelayModules } from './relay.js';
8
8
  import { checkCommunicationEnvironment, CommunicationEnvironmentError } from './preflight.js';
9
9
  import { requireCommunicationCli } from './worker.js';
10
10
 
11
- export async function attachCommunicationWorkers(spec: KernelRunSpec, socketPath: string, dataDir: string) {
11
+ export async function attachCommunicationWorkers(spec: KernelRunSpec, socketPath: string, dataDir: string,
12
+ environment?: NodeJS.ProcessEnv) {
12
13
  const steps = spec.steps.filter(step => step.type === 'agent' && communicationInstruction(step.instruction));
13
14
  checkCommunicationEnvironment(spec);
14
15
  try { await loadRelayModules(); }
@@ -28,7 +29,7 @@ export async function attachCommunicationWorkers(spec: KernelRunSpec, socketPath
28
29
  requireCommunicationCli(step.cli ?? spec.cli);
29
30
  if (step.surfaces?.workspace?.length) throw new Error('Local communication workers support stream surfaces only');
30
31
  const client = new JournalClient(socketPath);
31
- const worker = new AgentWorker(client, { workerId: `communication-${randomUUID()}`, dataDir,
32
+ const worker = new AgentWorker(client, { workerId: `communication-${randomUUID()}`, dataDir, environment,
32
33
  requiredStreams: [channelName(step.id, '$receipts')],
33
34
  pins: { workspace: [], streams: step.surfaces?.streams?.map(({ stream }) => ({ stream, read_offset: 0 })) ?? [] } });
34
35
  workers.push({ client, worker });
@@ -1,9 +1,10 @@
1
1
  import { communicationHistory } from './history.js';
2
- import { basename } from 'node:path';
2
+ import { mkdir, mkdtemp, rm, symlink } from 'node:fs/promises';
3
+ import { basename, join } from 'node:path';
3
4
  import { setTimeout as delay } from 'node:timers/promises';
4
5
  import type { JournalClient } from '../journal-client.js';
5
6
  import type { StepDispatchEvent } from '../protocol.js';
6
- import type { KernelAgentStep } from '../spec.js';
7
+ import type { ResolvedKernelAgentStep } from '../resolved-cli-identity.js';
7
8
  import { withWorkerLease } from '../worker-lease.js';
8
9
  import { workerInstruction } from '../worker-input.js';
9
10
  import { resolveCliModel } from '../cli-adapter.js';
@@ -18,13 +19,15 @@ export function requireCommunicationCli(cli: string | undefined): void {
18
19
  if (!cli?.trim()) throw new Error('Agent communication requires a declared CLI executable');
19
20
  }
20
21
  export async function completeCommunicationDispatch(client: JournalClient, dispatch: StepDispatchEvent,
21
- instruction: CommunicationInstruction, dataDir: string, runRoot?: string): Promise<void> {
22
- const spec = dispatch.spec as KernelAgentStep;
22
+ instruction: CommunicationInstruction, dataDir: string, runRoot?: string,
23
+ environment?: NodeJS.ProcessEnv): Promise<void> {
24
+ const spec = dispatch.spec as ResolvedKernelAgentStep;
23
25
  requireCommunicationCli(spec.cli);
24
26
  let output: unknown;
25
27
  let completionReason: 'success' | 'worker_error' = 'success';
26
28
  try {
27
- output = await withWorkerLease(client, dispatch, signal => run(client, dispatch, instruction, spec, dataDir, signal, runRoot));
29
+ output = await withWorkerLease(client, dispatch, signal =>
30
+ run(client, dispatch, instruction, spec, dataDir, signal, runRoot, environment));
28
31
  } catch (error) {
29
32
  completionReason = 'worker_error';
30
33
  output = { error: error instanceof Error ? error.message : String(error) };
@@ -34,7 +37,8 @@ export async function completeCommunicationDispatch(client: JournalClient, dispa
34
37
  started_pins: dispatch.pins, end_pins: dispatch.pins });
35
38
  }
36
39
  async function run(client: JournalClient, dispatch: StepDispatchEvent, instruction: CommunicationInstruction,
37
- spec: KernelAgentStep, dataDir: string, lease: AbortSignal, runRoot?: string): Promise<unknown> {
40
+ spec: ResolvedKernelAgentStep, dataDir: string, lease: AbortSignal, runRoot?: string,
41
+ environment?: NodeJS.ProcessEnv): Promise<unknown> {
38
42
  // Same contract as the CLI worker: a declared directory is resolved and held
39
43
  // inside the same run root the CLI worker measures against, before anything
40
44
  // is spawned. Thrown, not reported, because `completeCommunicationDispatch`
@@ -50,6 +54,7 @@ async function run(client: JournalClient, dispatch: StepDispatchEvent, instructi
50
54
  let pumping: Promise<void> | undefined;
51
55
  let receipts = Promise.resolve();
52
56
  let unsubscribe: (() => void) | undefined;
57
+ let cliLinkDirectory: string | undefined;
53
58
  try {
54
59
  let resolve!: (value: unknown) => void;
55
60
  let reject!: (reason: unknown) => void;
@@ -77,10 +82,20 @@ async function run(client: JournalClient, dispatch: StepDispatchEvent, instructi
77
82
  void receipts.catch(reject);
78
83
  });
79
84
  // Relay supplies each CLI's launch flags and injection behavior.
80
- handle = await relay.broker.spawnPty({ name, cli: basename(spec.cli!).replace(/\.exe$/i, ''), task: prompt, channels: [], skipRelayPrompt: true,
81
- model: resolveCliModel(spec.cli!, spec.model), cwd: directory,
82
- harnessConfig: { runtime: 'pty', command: quote(spec.cli!), args: [],
83
- cwd: directory, env: { ...agentEnvironment(spec.cli!),
85
+ const cliIdentity = spec.cli_identity ?? spec.cli!;
86
+ const argv0 = basename(cliIdentity);
87
+ let command = spec.cli!;
88
+ if (basename(command) !== argv0) {
89
+ const linkRoot = join(dataDir, 'communication');
90
+ await mkdir(linkRoot, { recursive: true, mode: 0o700 });
91
+ cliLinkDirectory = await mkdtemp(join(linkRoot, 'cli-'));
92
+ command = join(cliLinkDirectory, argv0);
93
+ await symlink(spec.cli!, command, 'file');
94
+ }
95
+ handle = await relay.broker.spawnPty({ name, cli: basename(cliIdentity).replace(/\.exe$/i, ''), task: prompt, channels: [], skipRelayPrompt: true,
96
+ model: resolveCliModel(cliIdentity, spec.model), cwd: directory,
97
+ harnessConfig: { runtime: 'pty', command: quote(command), args: [],
98
+ cwd: directory, env: { ...agentEnvironment(cliIdentity, environment ?? process.env),
84
99
  RELAYFLOW_COMMUNICATION_SOCKET: tools.path, RELAYFLOW_COMMUNICATION_TOKEN: tools.token },
85
100
  delivery: { mode: 'pty-injection', format: 'relay-block' } } });
86
101
  const ready = await handle.waitForReady(Math.min(instruction.timeoutMs, 90_000));
@@ -97,7 +112,10 @@ async function run(client: JournalClient, dispatch: StepDispatchEvent, instructi
97
112
  finally {
98
113
  try { await receipts; }
99
114
  finally { try { await handle?.release('Flow communication attempt ended', { deleteIdentity: true }); }
100
- finally { try { await tools?.close(); } finally { await relay.close(); } } }
115
+ finally { try { await tools?.close(); } finally {
116
+ try { await relay.close(); }
117
+ finally { if (cliLinkDirectory) await rm(cliLinkDirectory, { recursive: true, force: true }); }
118
+ } } }
101
119
  }
102
120
  }
103
121
  }
package/src/compile.ts CHANGED
@@ -24,10 +24,7 @@ import type {
24
24
  AgentStepSpec,
25
25
  DeterministicStepSpec,
26
26
  FlowSpec,
27
- KernelAgentStep,
28
27
  KernelRunSpec,
29
- KernelStepCommon,
30
- KernelStepSpec,
31
28
  KernelTriggerSpec,
32
29
  KernelVerificationSpec,
33
30
  LlmStepSpec,
@@ -45,6 +42,13 @@ import { validateSpec, type ValidationResult } from './validate.js';
45
42
  import { snapshotJsonValue } from './json-value.js';
46
43
  import { expandYamlHelpers } from './yaml-helpers.js';
47
44
  import { expandCommunication, validateCommunicationTopology } from './communication/spec.js';
45
+ import {
46
+ inheritResolvedCliIdentities,
47
+ resolvedCliIdentities,
48
+ type ResolvedKernelAgentStep,
49
+ type ResolvedKernelStepCommon,
50
+ type ResolvedKernelStepSpec,
51
+ } from './resolved-cli-identity.js';
48
52
 
49
53
  export class CompileError extends Error {
50
54
  readonly errors: string[];
@@ -173,7 +177,7 @@ export function compileSpec(spec: unknown): CompiledFlowSpec {
173
177
  ...(input.workspace !== undefined ? { workspace: input.workspace } : {}),
174
178
  ...(input.tools !== undefined ? { tools: input.tools } : {}),
175
179
  };
176
- return flow;
180
+ return inheritResolvedCliIdentities(spec, flow);
177
181
  }
178
182
 
179
183
  function compileStep(step: StepSpec): StepSpec {
@@ -325,6 +329,7 @@ export function toKernelSpec(flow: FlowSpec): KernelRunSpec {
325
329
  // This public boundary is callable without compileSpec. Compile again so
326
330
  // runtime casts are validated and all returned schema data is snapshotted.
327
331
  const compiled = compileSpec(flow);
332
+ const cliIdentities = resolvedCliIdentities(compiled);
328
333
 
329
334
  return {
330
335
  version: compiled.version,
@@ -347,7 +352,10 @@ export function toKernelSpec(flow: FlowSpec): KernelRunSpec {
347
352
  // reverse order would hand the green gate a source whose barrier it does
348
353
  // not wait for.
349
354
  steps: lowerNamedGates(lowerStepsGreenGates(compiled.steps))
350
- .map((step) => toKernelStep(resolveNamedAgent(step, compiled.agents))),
355
+ .map((step) => toKernelStep(
356
+ resolveNamedAgent(step, compiled.agents),
357
+ cliIdentities?.get(step.id),
358
+ )),
351
359
  ...(compiled.budget !== undefined ? { budget: toKernelBudget(compiled.budget) } : {}),
352
360
  };
353
361
  }
@@ -374,7 +382,7 @@ export function kernelToAuthoring(value: unknown): unknown {
374
382
  const steps = requireKernelArray(root['steps'], 'spec.steps')
375
383
  .map((step, index) => kernelStepToAuthoring(step, `spec.steps[${index}]`));
376
384
  const triggers = root['triggers'];
377
- return {
385
+ const authoring: FlowSpec = {
378
386
  ...copyDefined(root, ['version', 'name', 'description', 'cli']),
379
387
  ...(triggers !== undefined
380
388
  ? {
@@ -386,7 +394,8 @@ export function kernelToAuthoring(value: unknown): unknown {
386
394
  ...(root['budget'] !== undefined
387
395
  ? { budget: kernelBudgetToAuthoring(root['budget'], 'spec.budget') }
388
396
  : {}),
389
- };
397
+ } as FlowSpec;
398
+ return authoring;
390
399
  }
391
400
 
392
401
  /**
@@ -443,17 +452,22 @@ function kernelStepToAuthoring(value: unknown, at: string): unknown {
443
452
  const unionKeys = [
444
453
  'id', 'type', 'depends_on', 'max_iterations', 'retry', 'verification', 'memory', 'requirements', 'input',
445
454
  'command', 'timeout_ms', 'lease_ms', 'on_non_zero', 'prompt', 'model', 'cli', 'instruction',
446
- 'cwd', 'recovery_mode', 'surfaces', 'permissions',
455
+ 'cwd', 'recovery_mode', 'surfaces', 'permissions', 'cli_identity',
447
456
  ] as const;
448
457
  const step = requireKernelObject(value, unionKeys, at);
458
+ if (step['cli_identity'] !== undefined) {
459
+ throw new CompileError([
460
+ `${at}.cli_identity: host-proved adapter identity is not accepted from serialized input`,
461
+ ], 'untrusted_cli_identity');
462
+ }
449
463
  const type = step['type'];
450
464
  const commonKeys = ['id', 'type', 'depends_on', 'max_iterations', 'retry', 'verification', 'memory', 'requirements', 'input'] as const;
451
465
  const typeKeys = type === 'deterministic'
452
466
  ? ['command', 'timeout_ms', 'lease_ms', 'on_non_zero'] as const
453
467
  : type === 'llm'
454
- ? ['prompt', 'model', 'cli'] as const
468
+ ? ['prompt', 'model', 'cli', 'cli_identity'] as const
455
469
  : type === 'agent'
456
- ? ['instruction', 'cli', 'model', 'cwd', 'recovery_mode', 'surfaces', 'permissions'] as const
470
+ ? ['instruction', 'cli', 'model', 'cwd', 'recovery_mode', 'surfaces', 'permissions', 'cli_identity'] as const
457
471
  : [];
458
472
  assertKernelKeys(step, [...commonKeys, ...typeKeys], at);
459
473
  if (step['retry'] !== undefined) validateAuthoringRetryDefaults(step['retry'], `${at}.retry`);
@@ -596,8 +610,8 @@ function isObject(value: unknown): value is Record<string, unknown> {
596
610
  return typeof value === 'object' && value !== null && !Array.isArray(value);
597
611
  }
598
612
 
599
- function toKernelStep(step: StepSpec): KernelStepSpec {
600
- const common: KernelStepCommon = {
613
+ function toKernelStep(step: StepSpec, cliIdentity?: string): ResolvedKernelStepSpec {
614
+ const common: ResolvedKernelStepCommon = {
601
615
  id: step.id,
602
616
  depends_on: step.input === undefined ? step.dependsOn ?? []
603
617
  : [...new Set([...(step.dependsOn ?? []), ...bindingDependencies(step.input)])],
@@ -605,6 +619,7 @@ function toKernelStep(step: StepSpec): KernelStepSpec {
605
619
  max_iterations: step.maxIterations ?? 1,
606
620
  retry: { ...KERNEL_RETRY_DEFAULTS },
607
621
  verification: toKernelVerification(step),
622
+ ...(cliIdentity !== undefined ? { cli_identity: cliIdentity } : {}),
608
623
  ...(step.requirements !== undefined ? { requirements: {
609
624
  ...Object.fromEntries(Object.entries(step.requirements).filter(([key]) => key !== 'expectedDurationMs')),
610
625
  ...(step.requirements.expectedDurationMs !== undefined ? { expected_duration_ms: step.requirements.expectedDurationMs } : {}),
@@ -643,7 +658,7 @@ function toKernelStep(step: StepSpec): KernelStepSpec {
643
658
  };
644
659
  }
645
660
  case 'agent': {
646
- const out: KernelAgentStep = {
661
+ const out: ResolvedKernelAgentStep = {
647
662
  ...common,
648
663
  type: 'agent',
649
664
  instruction: step.instruction,
@@ -0,0 +1,38 @@
1
+ // Exact decimal-dollar arithmetic shared by the journal folds.
2
+
3
+ /**
4
+ * Sum two decimal dollar strings exactly, at whatever scale they carry — the
5
+ * kernel's budget fold (`relayflowd-core/src/state/budget.rs`) aligns every
6
+ * fractional digit before adding, and it accepts arbitrary precision. Scaling
7
+ * to a fixed six fractional digits dropped a charge of `0.0000001` to zero, so
8
+ * `flows status` underreported spend the kernel had totalled exactly.
9
+ *
10
+ * A value that is not a decimal number is left out of the sum rather than
11
+ * counted as zero, and the caller is told by `malformed`.
12
+ */
13
+ export function addDollars(left: string, right: string): string {
14
+ const parsed = [left, right].map((value) => /^\d+(?:\.\d+)?$/.test(value) ? value : null);
15
+ const usable = parsed.filter((value): value is string => value !== null);
16
+ if (usable.length === 0) return '0';
17
+ if (usable.length === 1) return normalizeDollars(usable[0]!);
18
+ const scale = Math.max(...usable.map((value) => (value.split('.')[1] ?? '').length));
19
+ const scaled = usable.map((value) => {
20
+ const [whole, fraction = ''] = value.split('.');
21
+ return BigInt(`${whole}${fraction.padEnd(scale, '0')}`);
22
+ });
23
+ return fromScaled(scaled[0]! + scaled[1]!, scale);
24
+ }
25
+
26
+ /** `12.3400` -> `12.34`, `0.000` -> `0`; the journal's own spelling otherwise. */
27
+ function normalizeDollars(value: string): string {
28
+ const [whole, fraction = ''] = value.split('.');
29
+ return fromScaled(BigInt(`${whole}${fraction}`), fraction.length);
30
+ }
31
+
32
+ function fromScaled(total: bigint, scale: number): string {
33
+ if (scale === 0) return String(total);
34
+ const digits = String(total).padStart(scale + 1, '0');
35
+ const whole = digits.slice(0, digits.length - scale);
36
+ const fraction = digits.slice(digits.length - scale).replace(/0+$/, '');
37
+ return fraction.length === 0 ? whole : `${whole}.${fraction}`;
38
+ }