@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/worker-cli.ts CHANGED
@@ -4,7 +4,7 @@ import { diffWorkspaceFiles, snapshotWorkspaceFiles } from './agent-artifacts.js
4
4
  import { claudeResultOutcome, decodeProviderResult, decodeWrapperResult, requirePricedUsage } from './worker-usage.js';
5
5
  import { openSidechannel, type SidechannelContext } from './pty-sidechannel.js';
6
6
  import { openTranscriptWriter, transcriptPath, type TranscriptDigest, type TranscriptFile, type TranscriptWriter } from './agent-transcript.js';
7
- import { spawn } from 'node:child_process';
7
+ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
8
8
  import { StringDecoder } from 'node:string_decoder';
9
9
  import { childStop } from './child-stop.js';
10
10
  import { reapOnExit } from './agent-reaper.js';
@@ -29,6 +29,7 @@ import {
29
29
  AgentRelayTransportError,
30
30
  type AgentTransport,
31
31
  } from './agent-relay-transport.js';
32
+ import { pinCliAlias } from './cli/pinned-cli-alias.js';
32
33
 
33
34
  /** Present only when a dispatched agent step carries a journaled wake context. */
34
35
  export const WAKE_CONTEXT_ENV = 'RELAYFLOW_WAKE_CONTEXT';
@@ -40,6 +41,11 @@ export const WAKE_CONTEXT_ENV = 'RELAYFLOW_WAKE_CONTEXT';
40
41
  */
41
42
  export const MODEL_ENV = 'RELAYFLOW_MODEL';
42
43
 
44
+ /** Preserve a multicall adapter's authored basename while executing pinned bytes. */
45
+ export function cliInvocationArgv0(cli: string, cliIdentity?: string): string {
46
+ return basename(cliIdentity ?? cli);
47
+ }
48
+
43
49
  export interface WorkerCliResult {
44
50
  relay_task?: import('./agent-relay-receipt.js').RelayTaskReceipt;
45
51
  tokens_input?: number;
@@ -91,13 +97,16 @@ export async function runAgentCli(
91
97
  cwd?: string,
92
98
  transport: AgentTransport = 'direct',
93
99
  relayContext?: AgentRelayContext,
100
+ processEnvironment: NodeJS.ProcessEnv = process.env,
101
+ cliIdentity?: string,
94
102
  ): Promise<WorkerCliResult> {
95
103
  signal?.throwIfAborted();
96
104
  if (signal !== undefined && process.platform === 'win32') {
97
105
  throw new Error('Lease-bound agent execution requires macOS or Linux process-group cancellation; Windows is unsupported.');
98
106
  }
99
- const kind = cliAdapterKind(cli);
100
- const effectiveModel = resolveCliModel(cli, model);
107
+ const kind = cliAdapterKind(cliIdentity ?? cli);
108
+ const effectiveModel = resolveCliModel(cliIdentity ?? cli, model);
109
+ const argv0 = cliInvocationArgv0(cli, cliIdentity);
101
110
 
102
111
  if (mode === 'agent' && transport === 'relay') {
103
112
  return runViaAgentRelay(kind, instruction, wakeContext, effectiveModel, relayContext, cwd, signal);
@@ -134,7 +143,7 @@ export async function runAgentCli(
134
143
  if (kind === 'relayflows-wrapper-v1') {
135
144
  // The closed allowlist admits no ambient RELAYFLOW_* value; the four
136
145
  // discovery names are set from this dispatch, exactly as for a direct spawn.
137
- const wrapperEnv = wrapperEnvironment(process.env);
146
+ const wrapperEnv = wrapperEnvironment(processEnvironment);
138
147
  applyStepEnvironment(wrapperEnv, sidechannel);
139
148
  return requirePricedUsage(decodeWrapperResult(await runWrapperSession(
140
149
  cli,
@@ -145,10 +154,11 @@ export async function runAgentCli(
145
154
  wrapperLimits,
146
155
  signal,
147
156
  cwd,
157
+ argv0,
148
158
  )), effectiveModel);
149
159
  }
150
160
 
151
- const env: NodeJS.ProcessEnv = { ...process.env };
161
+ const env: NodeJS.ProcessEnv = { ...processEnvironment };
152
162
  delete env[WAKE_CONTEXT_ENV];
153
163
  delete env[MODEL_ENV];
154
164
  // Where this attempt's journal is, so the agent can run `flows status` on
@@ -175,7 +185,7 @@ export async function runAgentCli(
175
185
  const args = [...invocation.args];
176
186
  args.splice(args.length - 1, 0, ...(kind === 'claude' ? ['--output-format', 'stream-json', '--verbose'] : ['--json']));
177
187
  const completion = kind === 'claude' ? claudeResultOutcome : undefined;
178
- return requirePricedUsage(decodeProviderResult(await spawnInvocation(cli, { ...invocation, args }, env, signal, sidechannel, cwd, completion), kind, env), effectiveModel);
188
+ return requirePricedUsage(decodeProviderResult(await spawnInvocation(cli, { ...invocation, args }, env, signal, sidechannel, cwd, completion, argv0), kind, env), effectiveModel);
179
189
  }
180
190
  }
181
191
 
@@ -314,40 +324,71 @@ async function spawnInvocation(
314
324
  sidechannel?: SidechannelContext,
315
325
  cwd?: string,
316
326
  completion?: (line: string) => { failed: boolean } | undefined,
327
+ argv0?: string,
317
328
  ): Promise<WorkerCliResult> {
318
329
  let writeInput: (bytes: Buffer) => Promise<boolean> = async () => false;
319
330
  let canDrive = () => false;
320
331
  let driven = false;
332
+ // Prove and pin the authored identity before opening any per-attempt
333
+ // resources. If pinning fails there is then nothing else to unwind.
334
+ const pinned = await pinCliAlias(cli, argv0 ?? basename(cli));
335
+ if (signal?.aborted) {
336
+ pinned.release();
337
+ signal.throwIfAborted();
338
+ }
321
339
  // The transcript file lives beside the PTY socket and is named by attempt.
322
340
  // Its absence (no data dir, no attempt, unwritable dir) costs the step
323
341
  // nothing: the digest is built from the buffered frames regardless. Opened
324
342
  // BEFORE the sidechannel: once `onReady` has fired, a drive peer's HELLO may
325
343
  // arrive at any time, and nothing may sit between that and the spawn that
326
344
  // arms `canDrive`.
327
- const writer: TranscriptWriter | undefined = sidechannel?.attempt === undefined ? undefined
328
- : await openTranscriptWriter(transcriptPath(sidechannel, sidechannel.attempt), env);
329
- const channel = sidechannel === undefined ? undefined : await openSidechannel({
330
- ...sidechannel,
331
- onDrive() { driven = true; sidechannel.onDrive(); },
332
- }, bytes => writeInput(bytes), () => canDrive());
333
- // Tee the transcript into bounded tail files beside the socket. Evidence
334
- // for `flows status --tail`, never the record; a failure here is a warning.
335
- const tails = sidechannel === undefined ? undefined : openTails(sidechannel);
336
- if (signal?.aborted) {
345
+ let writer: TranscriptWriter | undefined;
346
+ let channel: Awaited<ReturnType<typeof openSidechannel>> | undefined;
347
+ let tails: ReturnType<typeof openTails>;
348
+ const closeBeforeSpawn = async (): Promise<void> => {
337
349
  channel?.close();
338
- void writer?.close();
339
- void tails?.stdout.close(); void tails?.stderr.close();
350
+ const closing = Promise.allSettled([
351
+ ...(writer === undefined ? [] : [writer.close()]),
352
+ ...(tails === undefined ? [] : [tails.stdout.close(), tails.stderr.close()]),
353
+ ]);
354
+ pinned.release();
355
+ const deadline = new Promise<void>((done) => setTimeout(done, TAIL_CLOSE_TIMEOUT_MS).unref?.());
356
+ await Promise.race([closing, deadline]);
357
+ };
358
+ try {
359
+ writer = sidechannel?.attempt === undefined ? undefined
360
+ : await openTranscriptWriter(transcriptPath(sidechannel, sidechannel.attempt), env);
361
+ channel = sidechannel === undefined ? undefined : await openSidechannel({
362
+ ...sidechannel,
363
+ onDrive() { driven = true; sidechannel.onDrive(); },
364
+ }, bytes => writeInput(bytes), () => canDrive());
365
+ // Tee the transcript into bounded tail files beside the socket. Evidence
366
+ // for `flows status --tail`, never the record; a failure here is a warning.
367
+ tails = sidechannel === undefined ? undefined : openTails(sidechannel);
368
+ } catch (error) {
369
+ await closeBeforeSpawn();
370
+ throw error;
371
+ }
372
+ if (signal?.aborted) {
373
+ await closeBeforeSpawn();
340
374
  signal.throwIfAborted();
341
375
  }
342
- return new Promise((resolve) => {
343
- // Always a group of its own off Windows, so every stop — and
344
- // `reapOnExit` — reaches the whole agent tree, lease-bound or not.
345
- const ownsGroup = process.platform !== 'win32';
346
- const child = spawn(cli, invocation.args, {
376
+ // Always a group of its own off Windows, so every stop — and
377
+ // `reapOnExit` — reaches the whole agent tree, lease-bound or not.
378
+ const ownsGroup = process.platform !== 'win32';
379
+ let child: ChildProcessWithoutNullStreams;
380
+ try {
381
+ child = spawn(pinned.executable, invocation.args, {
347
382
  stdio: ['pipe', 'pipe', 'pipe'], env,
348
383
  detached: ownsGroup,
384
+ ...(argv0 === undefined ? {} : { argv0 }),
349
385
  ...(cwd === undefined ? {} : { cwd }),
350
386
  });
387
+ } catch (error) {
388
+ await closeBeforeSpawn();
389
+ throw error;
390
+ }
391
+ return new Promise((resolve) => {
351
392
  child.stdin.on('error', () => {});
352
393
  if (channel === undefined) child.stdin.end();
353
394
  canDrive = () => !child.stdin.destroyed && !child.stdin.writableEnded;
@@ -362,8 +403,7 @@ async function spawnInvocation(
362
403
  // until they flush; the sidechannel pauses its reader in the meantime.
363
404
  child.stdin.write(bytes, error => resolve(!error));
364
405
  });
365
- const stop = childStop(child, ownsGroup);
366
- const release = ownsGroup ? reapOnExit(stop) : () => {};
406
+ let release = () => {};
367
407
  const stdout: Buffer[] = [];
368
408
  const stderr: Buffer[] = [];
369
409
  let settled = false;
@@ -412,17 +452,66 @@ async function spawnInvocation(
412
452
  ? result : { ...result, transcript: { file: transcriptFile } });
413
453
  }, () => resolve(result));
414
454
  };
455
+ let pendingStopResult: {
456
+ result: WorkerCliResult;
457
+ discardTranscript: boolean;
458
+ priority: 'normal' | 'abort';
459
+ } | undefined;
460
+ const stop = childStop(child, ownsGroup, undefined, (stopError) => {
461
+ if (stopError !== undefined) {
462
+ finish({ exit_code: null, stdout_tail: '', stderr_tail: stopError.message }, true);
463
+ return;
464
+ }
465
+ try {
466
+ pinned.release();
467
+ } catch (error) {
468
+ finish({
469
+ exit_code: null,
470
+ stdout_tail: '',
471
+ stderr_tail: `CLI invocation alias cleanup failed: ${String(error)}`,
472
+ }, true);
473
+ return;
474
+ }
475
+ if (pendingStopResult !== undefined) {
476
+ finish(pendingStopResult.result, pendingStopResult.discardTranscript);
477
+ }
478
+ });
479
+ release = ownsGroup ? reapOnExit(stop, () => {
480
+ try { pinned.release(); } catch { /* host exit cannot report another result */ }
481
+ }) : () => {};
482
+ const finishAfterStop = (
483
+ result: WorkerCliResult,
484
+ action: 'kill' | 'terminate',
485
+ discardTranscript = false,
486
+ priority: 'normal' | 'abort' = 'normal',
487
+ ): void => {
488
+ // Lease loss may replace an earlier result while its stop is pending;
489
+ // once it does, no later result-grace or execution timer may replace it.
490
+ if (pendingStopResult !== undefined) {
491
+ if (pendingStopResult.priority === 'abort' || priority === 'normal') return;
492
+ }
493
+ pendingStopResult = { result, discardTranscript, priority };
494
+ stop[action]();
495
+ };
415
496
  const onAbort = (): void => {
416
- stop.kill();
417
- finish({ exit_code: null, stdout_tail: '', stderr_tail: 'Agent execution aborted: lease ownership lost.' }, true);
497
+ if (timer !== undefined) clearTimeout(timer);
498
+ timer = undefined;
499
+ if (graceTimer !== undefined) clearTimeout(graceTimer);
500
+ graceTimer = undefined;
501
+ finishAfterStop(
502
+ { exit_code: null, stdout_tail: '', stderr_tail: 'Agent execution aborted: lease ownership lost.' },
503
+ 'kill',
504
+ true,
505
+ 'abort',
506
+ );
418
507
  };
419
508
  /**
420
509
  * Same invariant as `wrapper-session.ts`: `'close'` and `'error'` are
421
510
  * evidence about the DIRECT CHILD, so they may not settle over a pending
422
511
  * escalation, and only `maySettleOnChildExit` may drop one. This settle
423
- * carries no deadline of its own because it needs none — the timeout below
424
- * settles on the spot and lets its escalation outlive that, so refusing
425
- * here can only defer to a `'close'` we are still going to get.
512
+ * carries no deadline of its own because it needs none — a stop-owned
513
+ * result now settles from the shared confirmation callback, while a normal
514
+ * child exit can only defer to a `'close'` we are still going to get.
426
515
  */
427
516
  const finishOnChildExit = (result: WorkerCliResult): void => {
428
517
  if (!stop.maySettleOnChildExit()) return;
@@ -440,12 +529,11 @@ async function spawnInvocation(
440
529
  const onResult = (outcome: { failed: boolean }): void => {
441
530
  if (graceTimer !== undefined || settled) return;
442
531
  graceTimer = setTimeout(() => {
443
- stop.terminate();
444
- finish({
532
+ finishAfterStop({
445
533
  exit_code: outcome.failed ? 1 : 0,
446
534
  stdout_tail: Buffer.concat(stdout).toString('utf8'),
447
535
  stderr_tail: `${Buffer.concat(stderr).toString('utf8')}\nCLI reported its final result but had not exited ${RESULT_EXIT_GRACE_MS}ms later; its process tree was stopped.`.trim(),
448
- });
536
+ }, 'terminate');
449
537
  }, RESULT_EXIT_GRACE_MS);
450
538
  };
451
539
  child.stdout.on('data', (chunk: Buffer) => {
@@ -482,15 +570,14 @@ async function spawnInvocation(
482
570
  }));
483
571
  if (invocation.timeoutMs > 0) {
484
572
  timer = setTimeout(() => {
485
- // The stop outlives this settle on purpose: `finish` resolves the step,
486
- // but only the forced group kill releases the pipes a leaked descendant
487
- // is holding, and until they are released `flows run` cannot exit.
488
- stop.terminate();
489
- finish({
573
+ // The stop owns this settle: a direct-child event is not proof that its
574
+ // group is gone, and an unprovable forced stop must fail the step rather
575
+ // than arriving after a successful result has already won the race.
576
+ finishAfterStop({
490
577
  exit_code: null,
491
578
  stdout_tail: Buffer.concat(stdout).toString('utf8'),
492
579
  stderr_tail: `CLI invocation timed out after ${invocation.timeoutMs}ms.`,
493
- });
580
+ }, 'terminate');
494
581
  }, invocation.timeoutMs);
495
582
  }
496
583
  });
@@ -2,8 +2,12 @@ import { JournalProtocolError, type JournalClient } from './journal-client.js';
2
2
  import type { StepDispatchEvent } from './protocol.js';
3
3
 
4
4
  export class WorkerLeaseLostError extends Error {
5
- constructor(readonly reason: 'already_expired' | 'renewal_expired' | 'completion_expired', message: string) {
6
- super(message);
5
+ constructor(
6
+ readonly reason: 'already_expired' | 'renewal_expired' | 'completion_expired',
7
+ message: string,
8
+ options?: { cause?: unknown },
9
+ ) {
10
+ super(message, options);
7
11
  this.name = 'WorkerLeaseLostError';
8
12
  }
9
13
  }
@@ -36,13 +40,19 @@ export async function withWorkerLease<T>(
36
40
  ): Promise<T> {
37
41
  const controller = new AbortController();
38
42
  let stopped = false;
39
- let latestDeadline = dispatch.lease_deadline_ms;
43
+ // Every deadline below is on THIS process's monotonic clock. The daemon
44
+ // issues `lease_deadline_ms` on its own wall clock, and comparing it with
45
+ // ours made the lease exactly as good as the two clocks' agreement: a
46
+ // worker 45s ahead refused every dispatch as already expired, one 75s
47
+ // behind scheduled its first renewal after the daemon had swept the lease
48
+ // (customer rw_3a0fcb71; tests/worker-lease-drift-live.test.ts).
49
+ let latestDeadline = Number.NaN;
40
50
  let renewalTimer: NodeJS.Timeout | undefined;
41
51
  let expiryTimer: NodeJS.Timeout | undefined;
42
52
  let pending: Promise<void> = Promise.resolve();
43
53
  const fail = (error: unknown): void => { controller.abort(error); };
44
54
  const armExpiry = (deadline: number): number => {
45
- const remaining = deadline - Date.now();
55
+ const remaining = deadline - performance.now();
46
56
  if (!Number.isFinite(remaining)) {
47
57
  throw new Error(`Agent lease is already expired for ${dispatch.run_id}/${dispatch.step_id}.`);
48
58
  }
@@ -57,16 +67,19 @@ export async function withWorkerLease<T>(
57
67
  return remaining;
58
68
  };
59
69
  const renew = async (): Promise<void> => {
70
+ // Anchored BEFORE the request: the daemon starts the renewed lease no
71
+ // earlier than it receives this, so the local deadline errs early.
72
+ const sentAt = performance.now();
60
73
  const result = await untilAborted(client.stepHeartbeat(
61
74
  dispatch.run_id, dispatch.step_id, dispatch.attempt, dispatch.lease_id,
62
75
  ), controller.signal);
63
76
  controller.signal.throwIfAborted();
64
77
  // A response handled after local expiry cannot revive ownership, even
65
78
  // if its future deadline was issued before this event loop stalled.
66
- if (Date.now() >= latestDeadline) {
79
+ if (performance.now() >= latestDeadline) {
67
80
  throw new WorkerLeaseLostError('renewal_expired', `Agent lease expired before renewal for ${dispatch.run_id}/${dispatch.step_id}.`);
68
81
  }
69
- const remaining = armExpiry(result.lease_deadline_ms);
82
+ const remaining = armExpiry(localDeadline(result.lease_deadline_ms, result.lease_ttl_ms, sentAt));
70
83
  if (!stopped) {
71
84
  renewalTimer = setTimeout(() => {
72
85
  pending = renew().catch(fail);
@@ -74,10 +87,26 @@ export async function withWorkerLease<T>(
74
87
  }
75
88
  };
76
89
  try {
77
- armExpiry(dispatch.lease_deadline_ms);
90
+ armExpiry(localDeadline(dispatch.lease_deadline_ms, dispatch.lease_ttl_ms, performance.now()));
78
91
  // Establish ownership before starting an effectful process.
79
92
  await renew();
80
- const result = await execute(controller.signal);
93
+ let result: T;
94
+ try {
95
+ result = await execute(controller.signal);
96
+ } catch (error) {
97
+ // A body that rejected because the lease was lost under it reports
98
+ // whatever its wait happened to say ("worker wait canceled"). The lease
99
+ // is the cause, and callers branch on it (isLeaseLost): an attempt the
100
+ // kernel will retry must not be recorded as the body's own failure.
101
+ const lost = controller.signal.reason;
102
+ if (isLeaseLost(lost) && error !== lost) {
103
+ if (lost instanceof WorkerLeaseLostError) {
104
+ throw new WorkerLeaseLostError(lost.reason, lost.message, { cause: error });
105
+ }
106
+ throw lost;
107
+ }
108
+ throw error;
109
+ }
81
110
  stopped = true;
82
111
  if (renewalTimer !== undefined) clearTimeout(renewalTimer);
83
112
  // Drain any renewal before the caller sends step.complete. A renewal
@@ -86,7 +115,7 @@ export async function withWorkerLease<T>(
86
115
  controller.signal.throwIfAborted();
87
116
  // Timer callbacks can be delayed behind a resolved subprocess promise.
88
117
  // Check the clock itself before permitting step.complete.
89
- if (Date.now() >= latestDeadline) {
118
+ if (performance.now() >= latestDeadline) {
90
119
  throw new WorkerLeaseLostError('completion_expired', `Agent lease expired before completion for ${dispatch.run_id}/${dispatch.step_id}.`);
91
120
  }
92
121
  return result;
@@ -99,6 +128,18 @@ export async function withWorkerLease<T>(
99
128
  }
100
129
  }
101
130
 
131
+ /**
132
+ * A daemon lease deadline on this process's monotonic clock. With the
133
+ * daemon's `lease_ttl_ms` the wall clocks never meet: the duration is added to
134
+ * `anchor`, a moment no later than the daemon started it. An older daemon
135
+ * sends only the absolute deadline, which keeps the old wall-clock comparison
136
+ * -- read NOW, not at `anchor`, or the round trip would be subtracted twice.
137
+ */
138
+ function localDeadline(daemonDeadlineMs: number, ttlMs: number | undefined, anchor: number): number {
139
+ if (typeof ttlMs === 'number' && Number.isFinite(ttlMs)) return anchor + ttlMs;
140
+ return performance.now() + (daemonDeadlineMs - Date.now());
141
+ }
142
+
102
143
  function untilAborted<T>(request: Promise<T>, signal: AbortSignal): Promise<T> {
103
144
  return new Promise((resolve, reject) => {
104
145
  const abort = (): void => { reject(signal.reason); };
package/src/worker.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { communicationInstruction } from './communication/spec.js';
2
2
  import { workerSpend } from './worker-spend.js';
3
+ import { reportedCost } from './reported-cost.js';
3
4
  import type { WorkerCliResult } from './worker-cli.js';
4
5
  import { EventEmitter } from 'node:events';
5
6
  import type { JournalClient } from './journal-client.js';
6
7
  import type { Pins, StepDispatchEvent } from './protocol.js';
7
- import type { KernelAgentStep } from './spec.js';
8
+ import type { ResolvedKernelAgentStep } from './resolved-cli-identity.js';
8
9
  import { runAgentCli } from './worker-cli.js';
9
10
  import { agentStepCwd } from './agent-cwd.js';
10
11
  import { resolveCliModel } from './cli-adapter.js';
@@ -31,6 +32,8 @@ export interface AgentWorkerOptions {
31
32
  * the root a dispatch is measured against is a value, not an assumption.
32
33
  */
33
34
  runRoot?: string;
35
+ /** Environment inherited by provider CLIs, including agent-only secrets. */
36
+ environment?: NodeJS.ProcessEnv;
34
37
  }
35
38
 
36
39
  /**
@@ -111,7 +114,7 @@ export class AgentWorker extends EventEmitter {
111
114
  };
112
115
 
113
116
  private async execute(dispatch: StepDispatchEvent): Promise<void> {
114
- const spec = dispatch.spec as Partial<KernelAgentStep>;
117
+ const spec = dispatch.spec as Partial<ResolvedKernelAgentStep>;
115
118
  const helper = helperCall(spec);
116
119
  if (helper !== undefined) {
117
120
  if (this.options.dataDir === undefined) throw new Error('Helper worker requires a data directory for durable receipts');
@@ -122,11 +125,14 @@ export class AgentWorker extends EventEmitter {
122
125
  if (communication) {
123
126
  if (!this.options.dataDir) throw new Error('Agent communication requires a worker data directory');
124
127
  const { completeCommunicationDispatch } = await import('./communication/worker.js');
125
- await completeCommunicationDispatch(this.client, dispatch, communication, this.options.dataDir, this.options.runRoot);
128
+ await completeCommunicationDispatch(this.client, dispatch, communication, this.options.dataDir,
129
+ this.options.runRoot, this.options.environment);
126
130
  return;
127
131
  }
128
132
  let humanIntervention = false;
129
- const effectiveModel = typeof spec.cli === 'string' ? resolveCliModel(spec.cli, spec.model) : spec.model;
133
+ const effectiveModel = typeof spec.cli === 'string'
134
+ ? resolveCliModel(spec.cli_identity ?? spec.cli, spec.model)
135
+ : spec.model;
130
136
  // Resolved here, before the lease, because this is the process that shares
131
137
  // the agent's filesystem. A refusal completes the step the way a missing
132
138
  // CLI does — journaled as `worker_error` with the reason — rather than
@@ -142,9 +148,11 @@ export class AgentWorker extends EventEmitter {
142
148
  }, cwd.directory ?? (spec.transport === 'relay' ? undefined : this.options.runRoot),
143
149
  spec.transport === 'relay' ? 'relay' : 'direct',
144
150
  { runId: dispatch.run_id, stepId: dispatch.step_id, idempotencyKey: dispatch.idempotency_key,
145
- dataDir: this.options.dataDir, resultSchema: spec.verification?.json_schema })
151
+ dataDir: this.options.dataDir, resultSchema: spec.verification?.json_schema },
152
+ this.options.environment, spec.cli_identity)
146
153
  : Promise.resolve({ exit_code: null, stdout_tail: '', stderr_tail: 'agent step has no declared CLI' }));
147
154
  const { result, usage } = workerSpend(completed, effectiveModel);
155
+ const cost = reportedCost(completed, effectiveModel);
148
156
  const completionReason = result.exit_code === 0 ? 'success' : 'worker_error';
149
157
 
150
158
  // Output shape: if the CLI's stdout parses as JSON, promote THAT
@@ -195,6 +203,7 @@ export class AgentWorker extends EventEmitter {
195
203
  ...(Object.keys(trajectoryTail).length === 0 ? {} : { trajectory_tail: trajectoryTail }),
196
204
  ...(humanIntervention ? { human_intervention: true } : {}),
197
205
  ...(usage !== undefined ? { usage } : {}),
206
+ ...(cost === undefined ? {} : { reported_cost: cost }),
198
207
  started_pins: dispatch.pins,
199
208
  end_pins: dispatch.pins,
200
209
  },