@relayflows/sdk 2.0.37 → 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 (136) 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-node-runner.d.ts.map +1 -1
  13. package/dist/authored-node-runner.js +3 -2
  14. package/dist/authored-node-runner.js.map +1 -1
  15. package/dist/authored-root.d.ts.map +1 -1
  16. package/dist/authored-root.js +141 -6
  17. package/dist/authored-root.js.map +1 -1
  18. package/dist/authored-worker-step.js +1 -0
  19. package/dist/authored-worker-step.js.map +1 -1
  20. package/dist/child-stop.d.ts +3 -1
  21. package/dist/child-stop.d.ts.map +1 -1
  22. package/dist/child-stop.js +42 -2
  23. package/dist/child-stop.js.map +1 -1
  24. package/dist/cli/add-extension.js +1 -1
  25. package/dist/cli/add-extension.js.map +1 -1
  26. package/dist/cli/check.d.ts.map +1 -1
  27. package/dist/cli/check.js +25 -7
  28. package/dist/cli/check.js.map +1 -1
  29. package/dist/cli/cli-probe.d.ts +1 -0
  30. package/dist/cli/cli-probe.d.ts.map +1 -1
  31. package/dist/cli/cli-probe.js +72 -36
  32. package/dist/cli/cli-probe.js.map +1 -1
  33. package/dist/cli/direct-run.d.ts.map +1 -1
  34. package/dist/cli/direct-run.js +4 -1
  35. package/dist/cli/direct-run.js.map +1 -1
  36. package/dist/cli/pinned-cli-alias.d.ts +13 -0
  37. package/dist/cli/pinned-cli-alias.d.ts.map +1 -0
  38. package/dist/cli/pinned-cli-alias.js +58 -0
  39. package/dist/cli/pinned-cli-alias.js.map +1 -0
  40. package/dist/cli/run.d.ts +8 -0
  41. package/dist/cli/run.d.ts.map +1 -1
  42. package/dist/cli/run.js +91 -8
  43. package/dist/cli/run.js.map +1 -1
  44. package/dist/cli/step-failure.d.ts.map +1 -1
  45. package/dist/cli/step-failure.js +18 -0
  46. package/dist/cli/step-failure.js.map +1 -1
  47. package/dist/cloud-run.d.ts.map +1 -1
  48. package/dist/cloud-run.js +9 -2
  49. package/dist/cloud-run.js.map +1 -1
  50. package/dist/communication/worker.d.ts.map +1 -1
  51. package/dist/communication/worker.js +24 -6
  52. package/dist/communication/worker.js.map +1 -1
  53. package/dist/compile.d.ts.map +1 -1
  54. package/dist/compile.js +16 -7
  55. package/dist/compile.js.map +1 -1
  56. package/dist/failure-kinds.d.ts +9 -1
  57. package/dist/failure-kinds.d.ts.map +1 -1
  58. package/dist/failure-kinds.js +6 -0
  59. package/dist/failure-kinds.js.map +1 -1
  60. package/dist/flow-extension-loader.d.ts +1 -1
  61. package/dist/flow-extension-loader.d.ts.map +1 -1
  62. package/dist/flow-extension-loader.js +143 -14
  63. package/dist/flow-extension-loader.js.map +1 -1
  64. package/dist/flow-extension-manifest.d.ts +1 -0
  65. package/dist/flow-extension-manifest.d.ts.map +1 -1
  66. package/dist/flow-extension-manifest.js +12 -3
  67. package/dist/flow-extension-manifest.js.map +1 -1
  68. package/dist/hosted-extension-manifest.d.ts.map +1 -1
  69. package/dist/hosted-extension-manifest.js +25 -4
  70. package/dist/hosted-extension-manifest.js.map +1 -1
  71. package/dist/hosted-extension-runtime.js +1 -1
  72. package/dist/hosted-extension-sandbox.js +2 -2
  73. package/dist/llm-worker.d.ts.map +1 -1
  74. package/dist/llm-worker.js +4 -2
  75. package/dist/llm-worker.js.map +1 -1
  76. package/dist/mcp-stdio.d.ts +2 -1
  77. package/dist/mcp-stdio.d.ts.map +1 -1
  78. package/dist/mcp-stdio.js +38 -21
  79. package/dist/mcp-stdio.js.map +1 -1
  80. package/dist/preflight.d.ts +2 -0
  81. package/dist/preflight.d.ts.map +1 -1
  82. package/dist/preflight.js.map +1 -1
  83. package/dist/protocol.d.ts +9 -0
  84. package/dist/protocol.d.ts.map +1 -1
  85. package/dist/resolved-cli-identity.d.ts +14 -0
  86. package/dist/resolved-cli-identity.d.ts.map +1 -0
  87. package/dist/resolved-cli-identity.js +19 -0
  88. package/dist/resolved-cli-identity.js.map +1 -0
  89. package/dist/worker-cli.d.ts +3 -1
  90. package/dist/worker-cli.d.ts.map +1 -1
  91. package/dist/worker-cli.js +116 -40
  92. package/dist/worker-cli.js.map +1 -1
  93. package/dist/worker-lease.d.ts +3 -1
  94. package/dist/worker-lease.d.ts.map +1 -1
  95. package/dist/worker-lease.js +47 -9
  96. package/dist/worker-lease.js.map +1 -1
  97. package/dist/worker.d.ts.map +1 -1
  98. package/dist/worker.js +4 -2
  99. package/dist/worker.js.map +1 -1
  100. package/dist/wrapper-session.d.ts +3 -1
  101. package/dist/wrapper-session.d.ts.map +1 -1
  102. package/dist/wrapper-session.js +93 -53
  103. package/dist/wrapper-session.js.map +1 -1
  104. package/package.json +2 -2
  105. package/src/agent-reaper.ts +7 -4
  106. package/src/authored-budget.ts +73 -1
  107. package/src/authored-flow-error.ts +6 -0
  108. package/src/authored-node-runner.ts +3 -2
  109. package/src/authored-root.ts +150 -7
  110. package/src/authored-worker-step.ts +1 -0
  111. package/src/child-stop.ts +40 -1
  112. package/src/cli/add-extension.ts +1 -1
  113. package/src/cli/check.ts +25 -4
  114. package/src/cli/cli-probe.ts +73 -37
  115. package/src/cli/direct-run.ts +4 -1
  116. package/src/cli/pinned-cli-alias.ts +74 -0
  117. package/src/cli/run.ts +103 -7
  118. package/src/cli/step-failure.ts +18 -0
  119. package/src/cloud-run.ts +9 -2
  120. package/src/communication/worker.ts +24 -9
  121. package/src/compile.ts +28 -13
  122. package/src/failure-kinds.ts +14 -0
  123. package/src/flow-extension-loader.ts +154 -14
  124. package/src/flow-extension-manifest.ts +6 -3
  125. package/src/hosted-extension-manifest.ts +28 -5
  126. package/src/hosted-extension-runtime.ts +1 -1
  127. package/src/hosted-extension-sandbox.ts +2 -2
  128. package/src/llm-worker.ts +6 -4
  129. package/src/mcp-stdio.ts +34 -20
  130. package/src/preflight.ts +2 -0
  131. package/src/protocol.ts +9 -0
  132. package/src/resolved-cli-identity.ts +42 -0
  133. package/src/worker-cli.ts +123 -37
  134. package/src/worker-lease.ts +50 -9
  135. package/src/worker.ts +6 -4
  136. package/src/wrapper-session.ts +89 -56
@@ -12,13 +12,13 @@ import {
12
12
  type LoadedAuthoredFlow,
13
13
  type SurfaceModuleAuthority,
14
14
  } from './authored-flow-loader.js';
15
- import { JournalClient } from './journal-client.js';
15
+ import { JournalClient, JournalProtocolError } from './journal-client.js';
16
16
  import type { RunOutcome, StepDispatchEvent } from './protocol.js';
17
17
  import { SPEC_SCHEMA_VERSION } from './spec.js';
18
18
  import type { RunLifecycleOptions } from './cli/run.js';
19
- import { withWorkerLease } from './worker-lease.js';
19
+ import { isLeaseLost, withWorkerLease } from './worker-lease.js';
20
20
  import { AuthoredFlowExecutionError, AuthoredHumanParked } from './authored-flow-error.js';
21
- import { readOpenHumanWaits } from './authored-human.js';
21
+ import { readOpenHumanWaits, resumeCommand } from './authored-human.js';
22
22
  import { isSurfaceCompletionReason } from './authored-step-output.js';
23
23
  import { readSubscriptionPark } from './authored-subscription-park.js';
24
24
  import { localAgentCredentialEnvironment } from './local-agent-environment.js';
@@ -134,7 +134,7 @@ export async function executeDurableAuthoredFlow(
134
134
  if (parked !== undefined) return { state: 'suspended', name: metadata.flowName,
135
135
  suspension: parked, journalSteps: [], rootRunId: outcome.run_id };
136
136
  const dispatch = await dispatchWait.promise;
137
- return await driveRoot(loaded, metadata, journal, peer, dispatch, options);
137
+ return await driveRootFollowingRetries(loaded, metadata, journal, peer, dispatch, options);
138
138
  } finally {
139
139
  cancelDispatch();
140
140
  peer.close();
@@ -177,7 +177,7 @@ export async function resumeDurableAuthoredFlow(
177
177
  if (parked !== undefined) return { state: 'suspended', name: metadata.flowName,
178
178
  suspension: parked, journalSteps: [], rootRunId };
179
179
  const dispatch = await dispatchWait.promise;
180
- return await driveRoot(loaded, metadata, journal, peer, dispatch, options);
180
+ return await driveRootFollowingRetries(loaded, metadata, journal, peer, dispatch, options);
181
181
  } finally {
182
182
  cancelDispatch();
183
183
  peer.close();
@@ -214,6 +214,102 @@ export async function readAuthoredRootMetadata(
214
214
  return Object.freeze({ ...value, extensions: value.extensions ?? [] });
215
215
  }
216
216
 
217
+ /**
218
+ * How many times one CLI re-drives a root whose lease it lost. Each re-drive
219
+ * replays completed steps from their journaled receipts, so the bound guards a
220
+ * lease that keeps being lost, not the cost of the work.
221
+ */
222
+ const MAX_ROOT_REDRIVES = 3;
223
+ /**
224
+ * How long to wait for the kernel to re-dispatch a root whose lease was lost:
225
+ * a full lease (30s, relayflowd LEASE_RENEWAL_MS) in case the daemon still
226
+ * held it, plus its sweep and grace.
227
+ */
228
+ const ROOT_REDISPATCH_TIMEOUT_MS = 45_000;
229
+
230
+ /**
231
+ * Drive the root, and FOLLOW the kernel when it retries the root attempt.
232
+ *
233
+ * A lost root lease is not a failed flow. The kernel journals the attempt
234
+ * `lease_expired` with `disposition: retry` without charging an iteration
235
+ * (relayflowd-core recovery.rs) and re-dispatches the root to the worker
236
+ * still attached on `peer` -- this process. Exiting there with
237
+ * "worker wait canceled" (customer rw_3a0fcb71) threw away a run the kernel
238
+ * was about to continue, including a child agent already on its second
239
+ * attempt: the re-driven body replays completed steps from their receipts and
240
+ * adopts that in-flight child (authored-step-output.ts). Only when no retry
241
+ * arrives does the error leave, as `root_lease_lost` naming the resume.
242
+ */
243
+ async function driveRootFollowingRetries(
244
+ loaded: LoadedAuthoredFlow,
245
+ metadata: AuthoredRootMetadata,
246
+ journal: JournalClient,
247
+ peer: JournalClient,
248
+ firstDispatch: StepDispatchEvent,
249
+ options: Omit<DurableAuthoredOptions, 'admissionKey'>,
250
+ ): Promise<DurableAuthoredFlowResult> {
251
+ let dispatch = firstDispatch;
252
+ for (let redrives = 0; ; redrives += 1) {
253
+ // Listen before driving: the retry can be dispatched the moment the
254
+ // daemon sweeps the lease, before the aborted body has unwound. The
255
+ // timeout starts only once there is a lost lease to wait on.
256
+ const next = rootRedispatch(peer);
257
+ try {
258
+ const result = await driveRoot(loaded, metadata, journal, peer, dispatch, options);
259
+ next.cancel();
260
+ return result;
261
+ } catch (error) {
262
+ // `run_terminal` from a step verb means the run has already reached a
263
+ // terminal state. It is lease-shaped for worker cleanup, but the kernel
264
+ // cannot re-dispatch this root, so do not turn it into a 45s retry wait.
265
+ const terminal = error instanceof JournalProtocolError && error.code === 'run_terminal';
266
+ if (!isLeaseLost(error) || terminal || options.lifecycle?.signal?.aborted === true) {
267
+ next.cancel();
268
+ throw error;
269
+ }
270
+ const lost = sentence(error instanceof Error ? error.message : String(error));
271
+ const resume = resumeCommand(dispatch.run_id, options.dataDir, options.localAgentStream !== undefined);
272
+ if (redrives >= MAX_ROOT_REDRIVES) {
273
+ next.cancel();
274
+ throw rootLeaseLost(dispatch, `${lost} It was lost ${redrives + 1} times in this process; continue the run with: ${resume}`, error);
275
+ }
276
+ process.emitWarning(
277
+ `authored root run_id=${dispatch.run_id} attempt=${dispatch.attempt}: ${lost} Waiting for the kernel to retry it.`,
278
+ { code: 'FLOWS_ROOT_LEASE_LOST' },
279
+ );
280
+ try {
281
+ dispatch = await next.wait(ROOT_REDISPATCH_TIMEOUT_MS, options.lifecycle?.signal);
282
+ } catch (waitError) {
283
+ // A caller cancel while waiting is the caller's, not a lost lease.
284
+ if (aborted(options.lifecycle?.signal)) throw waitError;
285
+ throw rootLeaseLost(dispatch, `${lost} The kernel did not re-dispatch the root within ${ROOT_REDISPATCH_TIMEOUT_MS / 1000}s; continue the run with: ${resume}`, error);
286
+ }
287
+ if (dispatch.run_id !== firstDispatch.run_id) {
288
+ throw new Error('authored root retry was dispatched for a different run');
289
+ }
290
+ }
291
+ }
292
+ }
293
+
294
+ /** Read at call time: `signal.aborted` changes while this function awaits. */
295
+ function aborted(signal: AbortSignal | undefined): boolean {
296
+ return signal?.aborted === true;
297
+ }
298
+
299
+ function sentence(text: string): string {
300
+ return /[.!?]$/.test(text) ? text : `${text}.`;
301
+ }
302
+
303
+ function rootLeaseLost(dispatch: StepDispatchEvent, message: string, cause: unknown): AuthoredFlowExecutionError {
304
+ const error = new AuthoredFlowExecutionError('root_lease_lost',
305
+ `authored root run ${dispatch.run_id} lost its worker lease on attempt ${dispatch.attempt}. ${message}`
306
+ + ' Completed steps are journaled and are not re-run.',
307
+ 'lease_expired');
308
+ error.rootRunId = dispatch.run_id;
309
+ (error as { cause?: unknown }).cause = cause;
310
+ return error;
311
+ }
312
+
217
313
  async function driveRoot(
218
314
  loaded: LoadedAuthoredFlow,
219
315
  metadata: AuthoredRootMetadata,
@@ -269,6 +365,9 @@ async function driveRoot(
269
365
  );
270
366
  return Object.freeze({ ...result, rootRunId: dispatch.run_id });
271
367
  } catch (error) {
368
+ // A lost lease is owned by the kernel's retry path, not by this attempt's
369
+ // terminalization or suspension handling.
370
+ if (isLeaseLost(error)) throw error;
272
371
  if (error instanceof AuthoredFlowExecutionError
273
372
  && error.code === 'subscription_suspended'
274
373
  && error.suspension !== undefined) {
@@ -376,7 +475,51 @@ function hasRootStream(step: Record<string, unknown>): boolean {
376
475
  try { rootStreamFromMetadata(step); return true; } catch { return false; }
377
476
  }
378
477
 
379
- function nextRootDispatch(peer: JournalClient): {
478
+ /**
479
+ * Buffers the next root dispatch from the moment it is created; `wait`
480
+ * bounds only the time spent waiting for it. Unlike `nextRootDispatch`, no
481
+ * timer runs while the body is being driven -- a body longer than the
482
+ * timeout must not leave a rejected promise nobody awaits.
483
+ */
484
+ function rootRedispatch(peer: JournalClient): {
485
+ wait(timeoutMs: number, signal?: AbortSignal): Promise<StepDispatchEvent>;
486
+ cancel(): void;
487
+ } {
488
+ let received: StepDispatchEvent | undefined;
489
+ let deliver: ((dispatch: StepDispatchEvent) => void) | undefined;
490
+ const onDispatch = (dispatch: StepDispatchEvent): void => {
491
+ if (dispatch.step_id !== 'authored-root') return;
492
+ peer.off('step.dispatch', onDispatch);
493
+ if (deliver === undefined) received = dispatch;
494
+ else deliver(dispatch);
495
+ };
496
+ peer.on('step.dispatch', onDispatch);
497
+ const cancel = (): void => { peer.off('step.dispatch', onDispatch); };
498
+ return {
499
+ cancel,
500
+ wait: (timeoutMs, signal) => new Promise((resolve, reject) => {
501
+ if (received !== undefined) { resolve(received); return; }
502
+ const settle = (): void => {
503
+ clearTimeout(timer);
504
+ signal?.removeEventListener('abort', onAbort);
505
+ cancel();
506
+ };
507
+ const onAbort = (): void => {
508
+ settle();
509
+ reject(new Error('waiting for the authored root to be re-dispatched was canceled', { cause: signal?.reason }));
510
+ };
511
+ const timer = setTimeout(() => {
512
+ settle();
513
+ reject(new Error('authored root was not re-dispatched'));
514
+ }, timeoutMs);
515
+ deliver = dispatch => { settle(); resolve(dispatch); };
516
+ if (signal?.aborted === true) onAbort();
517
+ else signal?.addEventListener('abort', onAbort, { once: true });
518
+ }),
519
+ };
520
+ }
521
+
522
+ function nextRootDispatch(peer: JournalClient, timeoutMs = 30_000): {
380
523
  promise: Promise<StepDispatchEvent>;
381
524
  cancel(): void;
382
525
  } {
@@ -385,7 +528,7 @@ function nextRootDispatch(peer: JournalClient): {
385
528
  const timer = setTimeout(() => {
386
529
  peer.off('step.dispatch', onDispatch);
387
530
  reject(new Error('authored root was not dispatched'));
388
- }, 30_000);
531
+ }, timeoutMs);
389
532
  const onDispatch = (dispatch: StepDispatchEvent) => {
390
533
  if (dispatch.step_id !== 'authored-root') return;
391
534
  clearTimeout(timer);
@@ -343,6 +343,7 @@ function stepDetails(
343
343
  ...(found.completionReason === undefined ? {} : { completionReason: found.completionReason }),
344
344
  ...(found.attempt === undefined ? {} : { attempt: found.attempt }),
345
345
  ...(found.maxIterations === undefined ? {} : { maxIterations: found.maxIterations }),
346
+ ...(found.unchargedAttempts === undefined ? {} : { unchargedAttempts: found.unchargedAttempts }),
346
347
  ...(found.exitCode === undefined ? {} : { exitCode: found.exitCode }),
347
348
  ...(found.stdoutTail === undefined ? {} : { stdoutTail: found.stdoutTail }),
348
349
  ...(found.stderrTail === undefined ? {} : { stderrTail: found.stderrTail }),
package/src/child-stop.ts CHANGED
@@ -6,6 +6,8 @@ import { execFileSync, type ChildProcess } from 'node:child_process';
6
6
  * the `flows run` event loop alive long after the step itself has settled.
7
7
  */
8
8
  export const FORCE_KILL_DELAY_MS = 1_000;
9
+ /** Bound for proving that a forced process group has actually disappeared. */
10
+ export const GROUP_EXIT_CONFIRM_TIMEOUT_MS = 1_000;
9
11
 
10
12
  /**
11
13
  * The one stop path for a spawned agent process.
@@ -65,6 +67,7 @@ export function childStop(
65
67
  child: ChildProcess,
66
68
  ownsGroup: boolean,
67
69
  forceKillDelayMs: number = FORCE_KILL_DELAY_MS,
70
+ onStopped: (error?: Error) => void = () => {},
68
71
  ): ChildStop {
69
72
  // Pinned at the spawn rather than read per signal. Every interesting use of
70
73
  // this id happens AFTER the direct child has been reaped — the escalation
@@ -74,6 +77,17 @@ export function childStop(
74
77
  // which is exactly the window both of those need to address.
75
78
  const pid = child.pid;
76
79
  let forceTimer: NodeJS.Timeout | undefined;
80
+ let groupPoll: NodeJS.Timeout | undefined;
81
+ let forced = false;
82
+ let forcedAt: number | undefined;
83
+ let stopped = false;
84
+ const notifyStopped = (error?: Error): void => {
85
+ if (stopped) return;
86
+ stopped = true;
87
+ if (groupPoll !== undefined) clearTimeout(groupPoll);
88
+ groupPoll = undefined;
89
+ onStopped(error);
90
+ };
77
91
  const cancel = (): void => {
78
92
  if (forceTimer !== undefined) clearTimeout(forceTimer);
79
93
  forceTimer = undefined;
@@ -106,6 +120,21 @@ export function childStop(
106
120
  }
107
121
  });
108
122
  };
123
+ const awaitGroupExit = (): void => {
124
+ if (!groupAnswers()) {
125
+ notifyStopped();
126
+ return;
127
+ }
128
+ if (forcedAt !== undefined && Date.now() - forcedAt >= GROUP_EXIT_CONFIRM_TIMEOUT_MS) {
129
+ notifyStopped(new Error(`Process groups did not stop answering within ${GROUP_EXIT_CONFIRM_TIMEOUT_MS}ms after SIGKILL.`));
130
+ return;
131
+ }
132
+ if (groupPoll !== undefined) return;
133
+ groupPoll = setTimeout(() => {
134
+ groupPoll = undefined;
135
+ awaitGroupExit();
136
+ }, 10);
137
+ };
109
138
  const signalTree = (name: NodeJS.Signals): void => {
110
139
  if (ownsGroup && pid !== undefined) {
111
140
  escapedGroups ??= descendantGroups(pid);
@@ -127,6 +156,9 @@ export function childStop(
127
156
  kill: (): void => {
128
157
  cancel();
129
158
  signalTree('SIGKILL');
159
+ forced = true;
160
+ forcedAt ??= Date.now();
161
+ awaitGroupExit();
130
162
  },
131
163
  terminate: (): void => {
132
164
  cancel();
@@ -134,6 +166,9 @@ export function childStop(
134
166
  forceTimer = setTimeout(() => {
135
167
  forceTimer = undefined;
136
168
  signalTree('SIGKILL');
169
+ forced = true;
170
+ forcedAt ??= Date.now();
171
+ awaitGroupExit();
137
172
  }, forceKillDelayMs);
138
173
  // Deliberately REFERENCED, unlike every other timer we arm. The survivor
139
174
  // this escalation exists for is the one that ignored `SIGTERM` and holds
@@ -144,9 +179,13 @@ export function childStop(
144
179
  // `maySettleOnChildExit` confirms the group is empty.
145
180
  },
146
181
  maySettleOnChildExit: (): boolean => {
147
- if (forceTimer === undefined) return true;
182
+ if (forceTimer === undefined && !forced) {
183
+ notifyStopped();
184
+ return true;
185
+ }
148
186
  if (groupAnswers()) return false;
149
187
  cancel();
188
+ notifyStopped();
150
189
  return true;
151
190
  },
152
191
  };
@@ -36,7 +36,7 @@ function eventKeys(manifest: FlowExtensionManifest): readonly string[] {
36
36
 
37
37
  function formatBudget(budget: FlowExtensionManifest['permissions']['budget']): string {
38
38
  if (budget === undefined) return 'inherits base';
39
- return [budget.dollars === undefined ? '' : `$${budget.dollars}`, budget.wallclock ?? ''].filter(Boolean).join(' / ') || 'inherits base';
39
+ return [budget.tokens === undefined ? '' : `${budget.tokens} tokens`, budget.dollars === undefined ? '' : `$${budget.dollars}`, budget.wallclock ?? ''].filter(Boolean).join(' / ') || 'inherits base';
40
40
  }
41
41
 
42
42
  export function describeExtension(manifest: FlowExtensionManifest): string[] {
package/src/cli/check.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { communicationInstruction } from '../communication/spec.js';
2
+ import { rememberResolvedCliIdentities } from '../resolved-cli-identity.js';
2
3
  import { checkCommunicationEnvironment } from '../communication/preflight.js';
3
4
  import { accessSync, constants, readFileSync } from 'node:fs';
4
5
  import { dirname, isAbsolute, join, parse as parsePath, resolve } from 'node:path';
@@ -15,6 +16,7 @@ import { parseMcpConfig } from '../mcp-config.js';
15
16
  import type { StepGateInspection } from '../gate-contract.js';
16
17
  import type { CheckFailureKind, CheckWarningKind } from '../failure-kinds.js';
17
18
  import {
19
+ cliProbeKey,
18
20
  preflight,
19
21
  type CliResolution,
20
22
  type CliProbeResult,
@@ -202,11 +204,15 @@ export function checkAuthoredFlow(
202
204
  const projectConfig = options !== undefined
203
205
  ? options.projectConfig
204
206
  : projectConfigOrOptions as ProjectConfig | undefined;
205
- const effectiveProbeCache = cliProbeCache ?? options?.probeCache;
207
+ // Keep the exact probe result so the executable identity selected during
208
+ // readiness is the one written onto the admitted step. Without an
209
+ // explicit map, preflight would allocate a private cache and a bare CLI
210
+ // name could be resolved again under the later worker cwd.
211
+ const effectiveProbeCache = cliProbeCache ?? options?.probeCache ?? new Map<string, CliProbeOutcome>();
206
212
  const config = projectConfig ?? readProjectConfig(dirname(absolutePath));
207
213
  const probes = systemProbes(dirname(absolutePath), config, invocation.environment);
208
214
  const result = preflight(authoring, {
209
- ...(effectiveProbeCache === undefined ? {} : { cliProbeCache: effectiveProbeCache }),
215
+ cliProbeCache: effectiveProbeCache,
210
216
  projectCli: config.cli,
211
217
  projectConfigPath: config.path,
212
218
  projectSearchStart: dirname(absolutePath),
@@ -220,6 +226,7 @@ export function checkAuthoredFlow(
220
226
  result.resolutions,
221
227
  dirname(absolutePath),
222
228
  config.directory,
229
+ effectiveProbeCache,
223
230
  )
224
231
  : undefined;
225
232
  if (flow?.steps.some(step => step.type === 'agent' && communicationInstruction(step.instruction))) {
@@ -472,20 +479,34 @@ function bindResolvedCliPaths(
472
479
  resolutions: readonly CliResolution[],
473
480
  flowDirectory: string,
474
481
  configDirectory: string,
482
+ probeCache: ReadonlyMap<string, CliProbeOutcome>,
475
483
  ): FlowSpec {
476
484
  const byStep = new Map(resolutions.map((resolution) => [resolution.stepId, resolution]));
477
- return {
485
+ const identities = new Map<string, string>();
486
+ const bound: FlowSpec = {
478
487
  ...flow,
479
488
  steps: flow.steps.map((step) => {
480
489
  if (step.type === 'deterministic') return step;
481
490
  const resolution = byStep.get(step.id);
482
491
  if (resolution === undefined) return step;
483
492
  const directory = resolution.source === 'project' ? configDirectory : flowDirectory;
484
- return { ...step, cli: canonicalCli(resolution.cli, directory),
493
+ const managed = step.type === 'agent' && communicationInstruction(step.instruction) !== undefined;
494
+ const outcome = probeCache.get(cliProbeKey(resolution, managed));
495
+ const executable = outcome !== undefined && 'result' in outcome
496
+ ? outcome.result.executable
497
+ : undefined;
498
+ const boundCli = executable !== undefined && isAbsolute(executable)
499
+ ? executable
500
+ : canonicalCli(resolution.cli, directory);
501
+ if (executable !== undefined && isAbsolute(executable)) {
502
+ identities.set(step.id, resolution.cli);
503
+ }
504
+ return { ...step, cli: boundCli,
485
505
  ...(resolution.modelSource === 'adapter' && resolution.model !== undefined
486
506
  ? { model: resolution.model } : {}) };
487
507
  }),
488
508
  };
509
+ return rememberResolvedCliIdentities(bound, identities);
489
510
  }
490
511
 
491
512
  function canonicalCli(cli: string, directory: string): string {
@@ -1,13 +1,15 @@
1
- import { accessSync, constants } from 'node:fs';
2
- import { isAbsolute, resolve } from 'node:path';
1
+ import { accessSync, constants, realpathSync } from 'node:fs';
2
+ import { basename, isAbsolute, resolve } from 'node:path';
3
3
  import { execFile, spawnSync } from 'node:child_process';
4
4
  import { agentEnvironment, brokerEnvironment } from '../communication/environment.js';
5
5
  import { adapterIdentification, authenticationProbe, classifyModelProbeFailure, cliAdapterKind,
6
6
  displayInvocation, modelReadinessProbe, type CliInvocation } from '../cli-adapter.js';
7
7
  import { MODEL_ENV } from '../worker-cli.js';
8
8
  import { CliProbeError, type CliProbeResult } from '../preflight.js';
9
+ import { pinCliAlias, pinCliAliasSync } from './pinned-cli-alias.js';
9
10
 
10
11
  interface ProbeRequest {
12
+ argv0?: string;
11
13
  executable: string;
12
14
  directory: string;
13
15
  invocation: CliInvocation;
@@ -35,12 +37,19 @@ function driveSync<T>(sequence: Generator<ProbeRequest, T, ProbeOutput>): T {
35
37
  let next = sequence.next();
36
38
  while (!next.done) {
37
39
  const request = next.value;
38
- const result = spawnSync(request.executable, request.invocation.args, {
39
- ...probeOptions(request),
40
- // Preserve the old synchronous probe contract: provider CLIs must not
41
- // inherit a readable stdin that can block auth/identify probes.
42
- stdio: ['ignore', 'pipe', 'pipe'],
43
- });
40
+ const pinned = pinCliAliasSync(request.executable, request.argv0 ?? basename(request.executable));
41
+ const result = (() => {
42
+ try {
43
+ return spawnSync(pinned.executable, request.invocation.args, {
44
+ ...probeOptions(request),
45
+ // Preserve the old synchronous probe contract: provider CLIs must not
46
+ // inherit a readable stdin that can block auth/identify probes.
47
+ stdio: ['ignore', 'pipe', 'pipe'],
48
+ });
49
+ } finally {
50
+ pinned.release();
51
+ }
52
+ })();
44
53
  const failure = classifySpawnFailure(result.error, result.signal, request.invocation.timeoutMs);
45
54
  if (failure !== undefined) throw failure;
46
55
  next = sequence.next({ status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' });
@@ -48,30 +57,35 @@ function driveSync<T>(sequence: Generator<ProbeRequest, T, ProbeOutput>): T {
48
57
  return next.value;
49
58
  }
50
59
 
51
- function probeOptions({ directory, invocation, environment }: ProbeRequest) {
60
+ function probeOptions({ argv0, directory, invocation, environment }: ProbeRequest) {
52
61
  const env = { ...environment };
53
62
  delete env[MODEL_ENV];
54
63
  if (invocation.modelEnv !== undefined) env[MODEL_ENV] = invocation.modelEnv;
55
64
  return { cwd: directory, encoding: 'utf8' as const, timeout: invocation.timeoutMs,
56
- maxBuffer: 1024 * 1024, env };
65
+ maxBuffer: 1024 * 1024, env, ...(argv0 === undefined ? {} : { argv0 }) };
57
66
  }
58
67
 
59
- function runProbeAsync(request: ProbeRequest): Promise<ProbeOutput> {
60
- return new Promise((resolve, reject) => {
61
- const child = execFile(request.executable, request.invocation.args, probeOptions(request),
62
- (error, stdout, stderr) => {
63
- // Numeric exit codes are probe results; launch, timeout, signal and buffer
64
- // errors are failures to collect a fact, just as in the synchronous driver.
65
- if (error?.killed && typeof error.code !== 'string') return reject(new CliProbeError(`timeout:${request.invocation.timeoutMs}ms`));
66
- const failure = classifySpawnFailure(
67
- error !== null && typeof error.code !== 'number' && error.signal == null ? error : undefined,
68
- error?.signal ?? null, request.invocation.timeoutMs);
69
- if (failure !== undefined) return reject(failure);
70
- resolve({ status: error === null ? 0 : typeof error.code === 'number' ? error.code : null,
71
- stdout, stderr });
72
- });
73
- child.stdin?.end();
74
- });
68
+ async function runProbeAsync(request: ProbeRequest): Promise<ProbeOutput> {
69
+ const pinned = await pinCliAlias(request.executable, request.argv0 ?? basename(request.executable));
70
+ try {
71
+ return await new Promise((resolve, reject) => {
72
+ const child = execFile(pinned.executable, request.invocation.args, probeOptions(request),
73
+ (error, stdout, stderr) => {
74
+ // Numeric exit codes are probe results; launch, timeout, signal and buffer
75
+ // errors are failures to collect a fact, just as in the synchronous driver.
76
+ if (error?.killed && typeof error.code !== 'string') return reject(new CliProbeError(`timeout:${request.invocation.timeoutMs}ms`));
77
+ const failure = classifySpawnFailure(
78
+ error !== null && typeof error.code !== 'number' && error.signal == null ? error : undefined,
79
+ error?.signal ?? null, request.invocation.timeoutMs);
80
+ if (failure !== undefined) return reject(failure);
81
+ resolve({ status: error === null ? 0 : typeof error.code === 'number' ? error.code : null,
82
+ stdout, stderr });
83
+ });
84
+ child.stdin?.end();
85
+ });
86
+ } finally {
87
+ await pinned.release();
88
+ }
75
89
  }
76
90
 
77
91
  function* probeSequence(
@@ -83,17 +97,27 @@ function* probeSequence(
83
97
  ): Generator<ProbeRequest, CliProbeResult, ProbeOutput> {
84
98
  const executable = yield* executableSequence(cli, directory, sourceEnvironment);
85
99
  if (executable === undefined) return { exists: false, authenticated: false };
86
- const kind = cliAdapterKind(executable);
100
+ // The declaration selects the adapter; the canonical executable selects
101
+ // the bytes. Package-manager links commonly name `claude` or `codex` while
102
+ // targeting a generic `cli.js`, whose basename must not rewrite the adapter
103
+ // contract after resolution.
104
+ const kind = cliAdapterKind(cli);
87
105
  // Relay owns interactive CLI launch/injection. Its generic PTY path is not
88
106
  // the headless wrapper protocol; do not demand that protocol from Gemini,
89
107
  // Cursor, OpenCode, or other interactive tools. Never invent an auth pass.
90
108
  if (execution === 'managed' && kind === 'relayflows-wrapper-v1') {
91
- return { exists: true, supported: true, authenticated: 'unverified' };
109
+ return { exists: true, supported: true, authenticated: 'unverified', executable };
92
110
  }
93
111
  const environment = execution === 'managed'
94
- ? { ...brokerEnvironment(sourceEnvironment), ...agentEnvironment(executable, sourceEnvironment) }
112
+ ? { ...brokerEnvironment(sourceEnvironment), ...agentEnvironment(cli, sourceEnvironment) }
95
113
  : sourceEnvironment;
96
- const probe = (invocation: CliInvocation): ProbeRequest => ({ executable, directory, invocation, environment });
114
+ const probe = (invocation: CliInvocation): ProbeRequest => ({
115
+ executable,
116
+ argv0: basename(cli),
117
+ directory,
118
+ invocation,
119
+ environment,
120
+ });
97
121
  const identification = adapterIdentification(kind);
98
122
  const identified = yield probe(identification.invocation);
99
123
  if (
@@ -110,6 +134,7 @@ function* probeSequence(
110
134
  exists: true,
111
135
  supported: true,
112
136
  authenticated: (yield probe(auth)).status === 0,
137
+ executable,
113
138
  authCommand,
114
139
  };
115
140
  }
@@ -126,6 +151,7 @@ function* probeSequence(
126
151
  supported: true,
127
152
  authenticated: true,
128
153
  modelAvailable: true,
154
+ executable,
129
155
  authCommand,
130
156
  modelCommand,
131
157
  };
@@ -145,6 +171,7 @@ function* probeSequence(
145
171
  supported: true,
146
172
  authenticated,
147
173
  modelAvailable: false,
174
+ executable,
148
175
  authCommand,
149
176
  modelCommand,
150
177
  modelExitCode: scopedProbe.status,
@@ -181,16 +208,25 @@ function redactProbeOutput(text: string): string {
181
208
  function* executableSequence(command: string, directory: string, environment: NodeJS.ProcessEnv): Generator<ProbeRequest, string | undefined, ProbeOutput> {
182
209
  if (command.includes('/') || isAbsolute(command)) {
183
210
  const path = isAbsolute(command) ? command : resolve(directory, command);
184
- try {
185
- accessSync(path, constants.X_OK);
186
- return path;
187
- } catch {
188
- return undefined;
189
- }
211
+ return canonicalExecutable(path);
190
212
  }
191
213
  const result = yield { executable: 'which', directory: process.cwd(),
192
214
  invocation: { args: [command], timeoutMs: 5_000 }, environment };
193
- return result.status === 0 ? result.stdout.trim() : undefined;
215
+ return result.status === 0
216
+ ? canonicalExecutable(resolve(process.cwd(), result.stdout.trim()))
217
+ : undefined;
218
+ }
219
+
220
+ function canonicalExecutable(path: string): string | undefined {
221
+ try {
222
+ accessSync(path, constants.X_OK);
223
+ // Dispatch must retain the exact target that readiness probed. Keeping a
224
+ // symlink path would allow the link to be retargeted between admission and
225
+ // worker spawn even though the compiled step carries an absolute path.
226
+ return realpathSync(path);
227
+ } catch {
228
+ return undefined;
229
+ }
194
230
  }
195
231
 
196
232
  function classifySpawnFailure(
@@ -21,7 +21,7 @@ import { authoredInput, authoredWorkerRemedy, localAgentRemedy } from './local-a
21
21
  import {
22
22
  authoredCompletion,
23
23
  authoredHumanParked,
24
- authoredStepFailure,
24
+ authoredStepFailure, rootLeaseLostReport,
25
25
  connect,
26
26
  emptyReport,
27
27
  fromCheckReport,
@@ -212,6 +212,9 @@ export async function runDirectFlow(
212
212
  if (error instanceof AuthoredFlowExecutionError && (error.code === 'step_failed' || error.code === 'gate_failed')) {
213
213
  return authoredStepFailure('run', base, socketPath, error);
214
214
  }
215
+ if (error instanceof AuthoredFlowExecutionError && error.code === 'root_lease_lost') {
216
+ return rootLeaseLostReport('run', base, socketPath, error);
217
+ }
215
218
  if (error instanceof AuthoredHumanParked) {
216
219
  return authoredHumanParked('run', base, socketPath, error, { dataDir, localAgent: options.localAgent === true });
217
220
  }
@@ -0,0 +1,74 @@
1
+ import {
2
+ mkdtempSync,
3
+ rmSync,
4
+ symlinkSync,
5
+ } from 'node:fs';
6
+ import {
7
+ mkdtemp,
8
+ rm,
9
+ symlink,
10
+ } from 'node:fs/promises';
11
+ import { tmpdir } from 'node:os';
12
+ import { basename, join } from 'node:path';
13
+
14
+ export interface PinnedCliAlias {
15
+ executable: string;
16
+ release(): void;
17
+ }
18
+
19
+ export interface PinnedCliAliasSync {
20
+ executable: string;
21
+ release(): void;
22
+ }
23
+
24
+ function aliasName(identity: string): string {
25
+ const name = basename(identity);
26
+ if (name.length === 0 || name === '.' || name === '..') {
27
+ throw new Error(`Invalid CLI identity basename: ${JSON.stringify(identity)}`);
28
+ }
29
+ return name;
30
+ }
31
+
32
+ /** Execute pinned canonical bytes through the proved basename, including shebang scripts. */
33
+ export async function pinCliAlias(executable: string, identity: string): Promise<PinnedCliAlias> {
34
+ const name = aliasName(identity);
35
+ if (basename(executable) === name) {
36
+ return { executable, release() {} };
37
+ }
38
+ const directory = await mkdtemp(join(tmpdir(), 'relayflow-cli-'));
39
+ const alias = join(directory, name);
40
+ try {
41
+ await symlink(executable, alias, 'file');
42
+ } catch (error) {
43
+ await rm(directory, { recursive: true, force: true });
44
+ throw error;
45
+ }
46
+ let released = false;
47
+ return { executable: alias, release: () => {
48
+ if (released) return;
49
+ rmSync(directory, { recursive: true, force: true });
50
+ released = true;
51
+ } };
52
+ }
53
+
54
+ /** Synchronous counterpart for the synchronous preflight contract. */
55
+ export function pinCliAliasSync(executable: string, identity: string): PinnedCliAliasSync {
56
+ const name = aliasName(identity);
57
+ if (basename(executable) === name) {
58
+ return { executable, release() {} };
59
+ }
60
+ const directory = mkdtempSync(join(tmpdir(), 'relayflow-cli-'));
61
+ const alias = join(directory, name);
62
+ try {
63
+ symlinkSync(executable, alias, 'file');
64
+ } catch (error) {
65
+ rmSync(directory, { recursive: true, force: true });
66
+ throw error;
67
+ }
68
+ let released = false;
69
+ return { executable: alias, release: () => {
70
+ if (released) return;
71
+ rmSync(directory, { recursive: true, force: true });
72
+ released = true;
73
+ } };
74
+ }