@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
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;
@@ -92,13 +98,15 @@ export async function runAgentCli(
92
98
  transport: AgentTransport = 'direct',
93
99
  relayContext?: AgentRelayContext,
94
100
  processEnvironment: NodeJS.ProcessEnv = process.env,
101
+ cliIdentity?: string,
95
102
  ): Promise<WorkerCliResult> {
96
103
  signal?.throwIfAborted();
97
104
  if (signal !== undefined && process.platform === 'win32') {
98
105
  throw new Error('Lease-bound agent execution requires macOS or Linux process-group cancellation; Windows is unsupported.');
99
106
  }
100
- const kind = cliAdapterKind(cli);
101
- const effectiveModel = resolveCliModel(cli, model);
107
+ const kind = cliAdapterKind(cliIdentity ?? cli);
108
+ const effectiveModel = resolveCliModel(cliIdentity ?? cli, model);
109
+ const argv0 = cliInvocationArgv0(cli, cliIdentity);
102
110
 
103
111
  if (mode === 'agent' && transport === 'relay') {
104
112
  return runViaAgentRelay(kind, instruction, wakeContext, effectiveModel, relayContext, cwd, signal);
@@ -146,6 +154,7 @@ export async function runAgentCli(
146
154
  wrapperLimits,
147
155
  signal,
148
156
  cwd,
157
+ argv0,
149
158
  )), effectiveModel);
150
159
  }
151
160
 
@@ -176,7 +185,7 @@ export async function runAgentCli(
176
185
  const args = [...invocation.args];
177
186
  args.splice(args.length - 1, 0, ...(kind === 'claude' ? ['--output-format', 'stream-json', '--verbose'] : ['--json']));
178
187
  const completion = kind === 'claude' ? claudeResultOutcome : undefined;
179
- 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);
180
189
  }
181
190
  }
182
191
 
@@ -315,40 +324,71 @@ async function spawnInvocation(
315
324
  sidechannel?: SidechannelContext,
316
325
  cwd?: string,
317
326
  completion?: (line: string) => { failed: boolean } | undefined,
327
+ argv0?: string,
318
328
  ): Promise<WorkerCliResult> {
319
329
  let writeInput: (bytes: Buffer) => Promise<boolean> = async () => false;
320
330
  let canDrive = () => false;
321
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
+ }
322
339
  // The transcript file lives beside the PTY socket and is named by attempt.
323
340
  // Its absence (no data dir, no attempt, unwritable dir) costs the step
324
341
  // nothing: the digest is built from the buffered frames regardless. Opened
325
342
  // BEFORE the sidechannel: once `onReady` has fired, a drive peer's HELLO may
326
343
  // arrive at any time, and nothing may sit between that and the spawn that
327
344
  // arms `canDrive`.
328
- const writer: TranscriptWriter | undefined = sidechannel?.attempt === undefined ? undefined
329
- : await openTranscriptWriter(transcriptPath(sidechannel, sidechannel.attempt), env);
330
- const channel = sidechannel === undefined ? undefined : await openSidechannel({
331
- ...sidechannel,
332
- onDrive() { driven = true; sidechannel.onDrive(); },
333
- }, bytes => writeInput(bytes), () => canDrive());
334
- // Tee the transcript into bounded tail files beside the socket. Evidence
335
- // for `flows status --tail`, never the record; a failure here is a warning.
336
- const tails = sidechannel === undefined ? undefined : openTails(sidechannel);
337
- 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> => {
338
349
  channel?.close();
339
- void writer?.close();
340
- 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();
341
374
  signal.throwIfAborted();
342
375
  }
343
- return new Promise((resolve) => {
344
- // Always a group of its own off Windows, so every stop — and
345
- // `reapOnExit` — reaches the whole agent tree, lease-bound or not.
346
- const ownsGroup = process.platform !== 'win32';
347
- 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, {
348
382
  stdio: ['pipe', 'pipe', 'pipe'], env,
349
383
  detached: ownsGroup,
384
+ ...(argv0 === undefined ? {} : { argv0 }),
350
385
  ...(cwd === undefined ? {} : { cwd }),
351
386
  });
387
+ } catch (error) {
388
+ await closeBeforeSpawn();
389
+ throw error;
390
+ }
391
+ return new Promise((resolve) => {
352
392
  child.stdin.on('error', () => {});
353
393
  if (channel === undefined) child.stdin.end();
354
394
  canDrive = () => !child.stdin.destroyed && !child.stdin.writableEnded;
@@ -363,8 +403,7 @@ async function spawnInvocation(
363
403
  // until they flush; the sidechannel pauses its reader in the meantime.
364
404
  child.stdin.write(bytes, error => resolve(!error));
365
405
  });
366
- const stop = childStop(child, ownsGroup);
367
- const release = ownsGroup ? reapOnExit(stop) : () => {};
406
+ let release = () => {};
368
407
  const stdout: Buffer[] = [];
369
408
  const stderr: Buffer[] = [];
370
409
  let settled = false;
@@ -413,17 +452,66 @@ async function spawnInvocation(
413
452
  ? result : { ...result, transcript: { file: transcriptFile } });
414
453
  }, () => resolve(result));
415
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
+ };
416
496
  const onAbort = (): void => {
417
- stop.kill();
418
- 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
+ );
419
507
  };
420
508
  /**
421
509
  * Same invariant as `wrapper-session.ts`: `'close'` and `'error'` are
422
510
  * evidence about the DIRECT CHILD, so they may not settle over a pending
423
511
  * escalation, and only `maySettleOnChildExit` may drop one. This settle
424
- * carries no deadline of its own because it needs none — the timeout below
425
- * settles on the spot and lets its escalation outlive that, so refusing
426
- * 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.
427
515
  */
428
516
  const finishOnChildExit = (result: WorkerCliResult): void => {
429
517
  if (!stop.maySettleOnChildExit()) return;
@@ -441,12 +529,11 @@ async function spawnInvocation(
441
529
  const onResult = (outcome: { failed: boolean }): void => {
442
530
  if (graceTimer !== undefined || settled) return;
443
531
  graceTimer = setTimeout(() => {
444
- stop.terminate();
445
- finish({
532
+ finishAfterStop({
446
533
  exit_code: outcome.failed ? 1 : 0,
447
534
  stdout_tail: Buffer.concat(stdout).toString('utf8'),
448
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(),
449
- });
536
+ }, 'terminate');
450
537
  }, RESULT_EXIT_GRACE_MS);
451
538
  };
452
539
  child.stdout.on('data', (chunk: Buffer) => {
@@ -483,15 +570,14 @@ async function spawnInvocation(
483
570
  }));
484
571
  if (invocation.timeoutMs > 0) {
485
572
  timer = setTimeout(() => {
486
- // The stop outlives this settle on purpose: `finish` resolves the step,
487
- // but only the forced group kill releases the pipes a leaked descendant
488
- // is holding, and until they are released `flows run` cannot exit.
489
- stop.terminate();
490
- 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({
491
577
  exit_code: null,
492
578
  stdout_tail: Buffer.concat(stdout).toString('utf8'),
493
579
  stderr_tail: `CLI invocation timed out after ${invocation.timeoutMs}ms.`,
494
- });
580
+ }, 'terminate');
495
581
  }, invocation.timeoutMs);
496
582
  }
497
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
@@ -5,7 +5,7 @@ import type { WorkerCliResult } from './worker-cli.js';
5
5
  import { EventEmitter } from 'node:events';
6
6
  import type { JournalClient } from './journal-client.js';
7
7
  import type { Pins, StepDispatchEvent } from './protocol.js';
8
- import type { KernelAgentStep } from './spec.js';
8
+ import type { ResolvedKernelAgentStep } from './resolved-cli-identity.js';
9
9
  import { runAgentCli } from './worker-cli.js';
10
10
  import { agentStepCwd } from './agent-cwd.js';
11
11
  import { resolveCliModel } from './cli-adapter.js';
@@ -114,7 +114,7 @@ export class AgentWorker extends EventEmitter {
114
114
  };
115
115
 
116
116
  private async execute(dispatch: StepDispatchEvent): Promise<void> {
117
- const spec = dispatch.spec as Partial<KernelAgentStep>;
117
+ const spec = dispatch.spec as Partial<ResolvedKernelAgentStep>;
118
118
  const helper = helperCall(spec);
119
119
  if (helper !== undefined) {
120
120
  if (this.options.dataDir === undefined) throw new Error('Helper worker requires a data directory for durable receipts');
@@ -130,7 +130,9 @@ export class AgentWorker extends EventEmitter {
130
130
  return;
131
131
  }
132
132
  let humanIntervention = false;
133
- 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;
134
136
  // Resolved here, before the lease, because this is the process that shares
135
137
  // the agent's filesystem. A refusal completes the step the way a missing
136
138
  // CLI does — journaled as `worker_error` with the reason — rather than
@@ -147,7 +149,7 @@ export class AgentWorker extends EventEmitter {
147
149
  spec.transport === 'relay' ? 'relay' : 'direct',
148
150
  { runId: dispatch.run_id, stepId: dispatch.step_id, idempotencyKey: dispatch.idempotency_key,
149
151
  dataDir: this.options.dataDir, resultSchema: spec.verification?.json_schema },
150
- this.options.environment)
152
+ this.options.environment, spec.cli_identity)
151
153
  : Promise.resolve({ exit_code: null, stdout_tail: '', stderr_tail: 'agent step has no declared CLI' }));
152
154
  const { result, usage } = workerSpend(completed, effectiveModel);
153
155
  const cost = reportedCost(completed, effectiveModel);