@relayflows/sdk 2.0.39 → 2.0.41

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 (108) hide show
  1. package/dist/agent-transcript.d.ts +9 -0
  2. package/dist/agent-transcript.d.ts.map +1 -1
  3. package/dist/agent-transcript.js +12 -0
  4. package/dist/agent-transcript.js.map +1 -1
  5. package/dist/authored-node-runner.d.ts.map +1 -1
  6. package/dist/authored-node-runner.js +22 -4
  7. package/dist/authored-node-runner.js.map +1 -1
  8. package/dist/authored-root.js +5 -6
  9. package/dist/authored-root.js.map +1 -1
  10. package/dist/authored-worker-step.d.ts.map +1 -1
  11. package/dist/authored-worker-step.js +52 -3
  12. package/dist/authored-worker-step.js.map +1 -1
  13. package/dist/cli/cloud-deploy.d.ts +1 -1
  14. package/dist/cli/cloud-deploy.d.ts.map +1 -1
  15. package/dist/cli/cloud-deploy.js +6 -3
  16. package/dist/cli/cloud-deploy.js.map +1 -1
  17. package/dist/cli/step-evidence.d.ts +5 -0
  18. package/dist/cli/step-evidence.d.ts.map +1 -1
  19. package/dist/cli/step-evidence.js +45 -7
  20. package/dist/cli/step-evidence.js.map +1 -1
  21. package/dist/cli/step-failure.d.ts.map +1 -1
  22. package/dist/cli/step-failure.js +13 -0
  23. package/dist/cli/step-failure.js.map +1 -1
  24. package/dist/cli-commands.d.ts +2 -2
  25. package/dist/cli-commands.js +1 -1
  26. package/dist/cli-commands.js.map +1 -1
  27. package/dist/cli-transport-evidence.d.ts +30 -0
  28. package/dist/cli-transport-evidence.d.ts.map +1 -0
  29. package/dist/cli-transport-evidence.js +31 -0
  30. package/dist/cli-transport-evidence.js.map +1 -0
  31. package/dist/cli.js +1 -1
  32. package/dist/cli.js.map +1 -1
  33. package/dist/cloud-deploy.d.ts +14 -20
  34. package/dist/cloud-deploy.d.ts.map +1 -1
  35. package/dist/cloud-deploy.js +49 -16
  36. package/dist/cloud-deploy.js.map +1 -1
  37. package/dist/cloud-versions.d.ts +2 -6
  38. package/dist/cloud-versions.d.ts.map +1 -1
  39. package/dist/compile.d.ts +3 -1
  40. package/dist/compile.d.ts.map +1 -1
  41. package/dist/compile.js +37 -13
  42. package/dist/compile.js.map +1 -1
  43. package/dist/failure-kinds.d.ts +13 -0
  44. package/dist/failure-kinds.d.ts.map +1 -1
  45. package/dist/failure-kinds.js.map +1 -1
  46. package/dist/flow-requirements.d.ts +2 -1
  47. package/dist/flow-requirements.d.ts.map +1 -1
  48. package/dist/flow-requirements.js +4 -2
  49. package/dist/flow-requirements.js.map +1 -1
  50. package/dist/index.d.ts +1 -1
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/pty-sidechannel.d.ts +17 -0
  53. package/dist/pty-sidechannel.d.ts.map +1 -1
  54. package/dist/pty-sidechannel.js +41 -2
  55. package/dist/pty-sidechannel.js.map +1 -1
  56. package/dist/spec.d.ts +6 -1
  57. package/dist/spec.d.ts.map +1 -1
  58. package/dist/spec.js.map +1 -1
  59. package/dist/step-fields.d.ts +2 -2
  60. package/dist/step-fields.d.ts.map +1 -1
  61. package/dist/step-fields.js +2 -1
  62. package/dist/step-fields.js.map +1 -1
  63. package/dist/validate.d.ts +2 -0
  64. package/dist/validate.d.ts.map +1 -1
  65. package/dist/validate.js +18 -0
  66. package/dist/validate.js.map +1 -1
  67. package/dist/worker-cli.d.ts +10 -1
  68. package/dist/worker-cli.d.ts.map +1 -1
  69. package/dist/worker-cli.js +116 -44
  70. package/dist/worker-cli.js.map +1 -1
  71. package/dist/worker-spend.d.ts.map +1 -1
  72. package/dist/worker-spend.js +4 -1
  73. package/dist/worker-spend.js.map +1 -1
  74. package/dist/worker.d.ts.map +1 -1
  75. package/dist/worker.js +15 -4
  76. package/dist/worker.js.map +1 -1
  77. package/dist/wrapper-session.d.ts +2 -0
  78. package/dist/wrapper-session.d.ts.map +1 -1
  79. package/dist/wrapper-session.js +29 -10
  80. package/dist/wrapper-session.js.map +1 -1
  81. package/dist/yaml-helpers.js +1 -1
  82. package/dist/yaml-helpers.js.map +1 -1
  83. package/package.json +4 -3
  84. package/src/agent-transcript.ts +12 -0
  85. package/src/authored-node-runner.ts +23 -5
  86. package/src/authored-root.ts +5 -6
  87. package/src/authored-worker-step.ts +58 -3
  88. package/src/cli/cloud-deploy.ts +8 -4
  89. package/src/cli/step-evidence.ts +51 -7
  90. package/src/cli/step-failure.ts +13 -0
  91. package/src/cli-commands.ts +1 -1
  92. package/src/cli-transport-evidence.ts +70 -0
  93. package/src/cli.ts +1 -1
  94. package/src/cloud-deploy.ts +62 -20
  95. package/src/cloud-versions.ts +2 -2
  96. package/src/compile.ts +39 -13
  97. package/src/failure-kinds.ts +13 -0
  98. package/src/flow-requirements.ts +6 -3
  99. package/src/index.ts +1 -1
  100. package/src/pty-sidechannel.ts +38 -2
  101. package/src/spec.ts +6 -1
  102. package/src/step-fields.ts +2 -1
  103. package/src/validate.ts +18 -0
  104. package/src/worker-cli.ts +140 -42
  105. package/src/worker-spend.ts +4 -1
  106. package/src/worker.ts +21 -4
  107. package/src/wrapper-session.ts +32 -10
  108. package/src/yaml-helpers.ts +1 -1
@@ -25,6 +25,7 @@ export const STEP_COMMON_FIELDS = [
25
25
  'input',
26
26
  'verification',
27
27
  'maxIterations',
28
+ 'transportRetries',
28
29
  'memory',
29
30
  'requirements',
30
31
  ] as const;
@@ -37,5 +38,5 @@ export const STEP_COMMON_FIELDS = [
37
38
  export const STEP_FIELDS_BY_TYPE = {
38
39
  deterministic: ['command', 'timeoutMs', 'lease_ms', 'onNonZero'],
39
40
  llm: ['prompt', 'model', 'cli', 'output'],
40
- agent: ['instruction', 'agent', 'cli', 'model', 'cwd', 'transport', 'surfaces', 'recoveryMode', 'permissions', 'output'],
41
+ agent: ['timeoutMs', 'instruction', 'agent', 'cli', 'model', 'cwd', 'transport', 'surfaces', 'recoveryMode', 'permissions', 'output'],
41
42
  } as const satisfies Record<StepType, readonly string[]>;
package/src/validate.ts CHANGED
@@ -403,6 +403,9 @@ class Validator {
403
403
  if (st['maxIterations'] !== undefined && !isPosInt(st['maxIterations'])) {
404
404
  this.fail(`${at}.maxIterations: expected a positive integer`);
405
405
  }
406
+ if (st['transportRetries'] !== undefined && !isTransportRetries(st['transportRetries'])) {
407
+ this.fail(`${at}.transportRetries: expected a non-negative integer no greater than ${KERNEL_U32_MAX}`);
408
+ }
406
409
 
407
410
  if (type === 'deterministic') {
408
411
  this.validateDeterministic(st as unknown as DeterministicStepSpec, at);
@@ -487,6 +490,13 @@ class Validator {
487
490
  }
488
491
 
489
492
  private validateAgent(st: AgentStepSpec, at: string): void {
493
+ if (st.timeoutMs !== undefined) {
494
+ if (!isPosInt(st.timeoutMs) || st.timeoutMs > 60 * 60_000) {
495
+ this.fail(`${at}.timeoutMs: expected a positive integer at most 3600000 (60 minutes)`);
496
+ }
497
+ if (st.transport === 'relay') this.fail(`${at}.timeoutMs: unsupported with relay transport`);
498
+ if ((st.maxIterations ?? 1) > 1) this.fail(`${at}.timeoutMs: requires maxIterations 1`);
499
+ }
490
500
  if (!isNonEmptyString(st.instruction)) {
491
501
  this.fail(`${at}.instruction: expected a non-empty string`);
492
502
  }
@@ -605,6 +615,14 @@ export function validateSpec(spec: unknown): ValidationResult {
605
615
 
606
616
  // --- predicates -------------------------------------------------------------
607
617
 
618
+ /** spec.rs `max_transport_retries: u32`. */
619
+ const KERNEL_U32_MAX = 4_294_967_295;
620
+
621
+ /** A transport retry budget the kernel can deserialize. */
622
+ export function isTransportRetries(v: unknown): v is number {
623
+ return isNonNegInt(v) && v <= KERNEL_U32_MAX;
624
+ }
625
+
608
626
  function isObject(v: unknown): v is Record<string, unknown> {
609
627
  return typeof v === 'object' && v !== null && !Array.isArray(v);
610
628
  }
package/src/worker-cli.ts CHANGED
@@ -3,8 +3,16 @@ import { basename, dirname, join, resolve, sep } from 'node:path';
3
3
  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
- import { openTranscriptWriter, transcriptPath, type TranscriptDigest, type TranscriptFile, type TranscriptWriter } from './agent-transcript.js';
7
- import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
6
+ import {
7
+ openTranscriptWriter,
8
+ redactText,
9
+ transcriptPath,
10
+ type TranscriptDigest,
11
+ type TranscriptFile,
12
+ type TranscriptWriter,
13
+ } from './agent-transcript.js';
14
+ import { spawn, type ChildProcess } from 'node:child_process';
15
+ import { redactRelayError } from './redact.js';
8
16
  import { StringDecoder } from 'node:string_decoder';
9
17
  import { childStop } from './child-stop.js';
10
18
  import { reapOnExit } from './agent-reaper.js';
@@ -21,9 +29,14 @@ import {
21
29
  type WrapperSessionLimits,
22
30
  } from './wrapper-session.js';
23
31
  import { wrapperEnvironment } from './wrapper-runtime.js';
24
- import { redactRelayError } from './redact.js';
25
32
  import { applyStepEnvironment } from './step-env.js';
26
33
  import { TAIL_CLOSE_TIMEOUT_MS, openTranscriptTail, type TranscriptTailWriter } from './transcript-tail.js';
34
+ import {
35
+ isRetryableSpawnError,
36
+ transportEvidence,
37
+ type CliTransportCause,
38
+ type CliTransportEvidence,
39
+ } from './cli-transport-evidence.js';
27
40
  import {
28
41
  runAgentRelayTask,
29
42
  AgentRelayTransportError,
@@ -31,6 +44,9 @@ import {
31
44
  } from './agent-relay-transport.js';
32
45
  import { pinCliAlias } from './cli/pinned-cli-alias.js';
33
46
 
47
+ export type { CliTransportCause, CliTransportEvidence, CliTransportPhase } from './cli-transport-evidence.js';
48
+ export { agentCompletionReason } from './cli-transport-evidence.js';
49
+
34
50
  /** Present only when a dispatched agent step carries a journaled wake context. */
35
51
  export const WAKE_CONTEXT_ENV = 'RELAYFLOW_WAKE_CONTEXT';
36
52
 
@@ -53,6 +69,12 @@ export interface WorkerCliResult {
53
69
  exit_code: number | null;
54
70
  stdout_tail: string;
55
71
  stderr_tail: string;
72
+ /**
73
+ * Bounded, redacted process-lifecycle evidence for a direct CLI spawn.
74
+ * This is journaled in `trajectory_tail`, including when parsed JSON output
75
+ * would otherwise discard the process wrapper.
76
+ */
77
+ transport?: CliTransportEvidence;
56
78
  /**
57
79
  * Files the agent created or changed under its working directory,
58
80
  * cwd-relative POSIX paths, sorted. Measured by the worker that spawned the
@@ -99,6 +121,8 @@ export async function runAgentCli(
99
121
  relayContext?: AgentRelayContext,
100
122
  processEnvironment: NodeJS.ProcessEnv = process.env,
101
123
  cliIdentity?: string,
124
+ // Trailing parameter preserves existing positional callers.
125
+ stepTimeoutMs?: number,
102
126
  ): Promise<WorkerCliResult> {
103
127
  signal?.throwIfAborted();
104
128
  if (signal !== undefined && process.platform === 'win32') {
@@ -145,17 +169,21 @@ export async function runAgentCli(
145
169
  // discovery names are set from this dispatch, exactly as for a direct spawn.
146
170
  const wrapperEnv = wrapperEnvironment(processEnvironment);
147
171
  applyStepEnvironment(wrapperEnv, sidechannel);
148
- return requirePricedUsage(decodeWrapperResult(await runWrapperSession(
172
+ const session = await runWrapperSession(
149
173
  cli,
150
174
  instruction,
151
175
  wakeContext,
152
176
  effectiveModel,
153
177
  wrapperEnv,
154
- wrapperLimits,
178
+ stepTimeoutMs === undefined ? wrapperLimits : { ...wrapperLimits, executionTimeoutMs: stepTimeoutMs },
155
179
  signal,
156
180
  cwd,
157
181
  argv0,
158
- )), effectiveModel);
182
+ );
183
+ const decoded = decodeWrapperResult(session);
184
+ // A wrapper that hit its deadline after reporting usage is still charged
185
+ // for it; what it wrote is not the step's output.
186
+ return requirePricedUsage(session.transport?.cause === 'timeout' ? { ...decoded, stdout_tail: '' } : decoded, effectiveModel);
159
187
  }
160
188
 
161
189
  const env: NodeJS.ProcessEnv = { ...processEnvironment };
@@ -166,6 +194,8 @@ export async function runAgentCli(
166
194
  applyStepEnvironment(env, sidechannel);
167
195
  const invocation = mode === 'llm' ? llmExecution(kind, instruction, effectiveModel) : agentExecution(kind, instruction, effectiveModel);
168
196
 
197
+ if (stepTimeoutMs !== undefined) invocation.timeoutMs = stepTimeoutMs;
198
+
169
199
  if (wakeContext !== undefined) {
170
200
  try {
171
201
  env[WAKE_CONTEXT_ENV] = JSON.stringify(wakeContext);
@@ -185,7 +215,9 @@ export async function runAgentCli(
185
215
  const args = [...invocation.args];
186
216
  args.splice(args.length - 1, 0, ...(kind === 'claude' ? ['--output-format', 'stream-json', '--verbose'] : ['--json']));
187
217
  const completion = kind === 'claude' ? claudeResultOutcome : undefined;
188
- return requirePricedUsage(decodeProviderResult(await spawnInvocation(cli, { ...invocation, args }, env, signal, sidechannel, cwd, completion, argv0), kind, env), effectiveModel);
218
+ return requirePricedUsage(decodeProviderResult(await spawnInvocation(
219
+ cli, { ...invocation, args }, env, signal, sidechannel, cwd, completion, argv0, kind,
220
+ ), kind, env), effectiveModel);
189
221
  }
190
222
  }
191
223
 
@@ -325,9 +357,15 @@ async function spawnInvocation(
325
357
  cwd?: string,
326
358
  completion?: (line: string) => { failed: boolean } | undefined,
327
359
  argv0?: string,
360
+ kind?: CliAdapterKind,
328
361
  ): Promise<WorkerCliResult> {
329
- let writeInput: (bytes: Buffer) => Promise<boolean> = async () => false;
330
- let canDrive = () => false;
362
+ const pendingInput: Buffer[] = [];
363
+ let acceptingDrive = true;
364
+ let writeInput: (bytes: Buffer) => Promise<boolean> = async bytes => {
365
+ pendingInput.push(Buffer.from(bytes));
366
+ return true;
367
+ };
368
+ let canDrive = () => acceptingDrive;
331
369
  let driven = false;
332
370
  // Prove and pin the authored identity before opening any per-attempt
333
371
  // resources. If pinning fails there is then nothing else to unwind.
@@ -362,6 +400,12 @@ async function spawnInvocation(
362
400
  ...sidechannel,
363
401
  onDrive() { driven = true; sidechannel.onDrive(); },
364
402
  }, bytes => writeInput(bytes), () => canDrive());
403
+ // Decide the child's stdin shape before spawn. An unattended Codex process
404
+ // gets `/dev/null` from `ignore`, so it never enters the "additional input
405
+ // from stdin" path. Only an already-enrolled drive peer gets a writable
406
+ // pipe. View/passthrough retain live output without changing stdin.
407
+ driven = channel === undefined ? false : await channel.waitForDrive(100);
408
+ acceptingDrive = false;
365
409
  // Tee the transcript into bounded tail files beside the socket. Evidence
366
410
  // for `flows status --tail`, never the record; a failure here is a warning.
367
411
  tails = sidechannel === undefined ? undefined : openTails(sidechannel);
@@ -376,10 +420,10 @@ async function spawnInvocation(
376
420
  // Always a group of its own off Windows, so every stop — and
377
421
  // `reapOnExit` — reaches the whole agent tree, lease-bound or not.
378
422
  const ownsGroup = process.platform !== 'win32';
379
- let child: ChildProcessWithoutNullStreams;
423
+ let child: ChildProcess;
380
424
  try {
381
425
  child = spawn(pinned.executable, invocation.args, {
382
- stdio: ['pipe', 'pipe', 'pipe'], env,
426
+ stdio: [driven ? 'pipe' : 'ignore', 'pipe', 'pipe'], env,
383
427
  detached: ownsGroup,
384
428
  ...(argv0 === undefined ? {} : { argv0 }),
385
429
  ...(cwd === undefined ? {} : { cwd }),
@@ -389,20 +433,32 @@ async function spawnInvocation(
389
433
  throw error;
390
434
  }
391
435
  return new Promise((resolve) => {
392
- child.stdin.on('error', () => {});
393
- if (channel === undefined) child.stdin.end();
394
- canDrive = () => !child.stdin.destroyed && !child.stdin.writableEnded;
395
- // A pipe cannot be reopened after EOF. Give startup subscribers a bounded
396
- // chance to opt into drive, then let unattended/view-only CLIs read EOF.
397
- const inputTimer = channel === undefined ? undefined : setTimeout(() => {
398
- if (!driven) child.stdin.end();
399
- }, 100);
400
- writeInput = bytes => new Promise(resolve => {
401
- if (!canDrive()) { resolve(false); return; }
402
- // write(false) still accepts the bytes. The completion callback waits
403
- // until they flush; the sidechannel pauses its reader in the meantime.
404
- child.stdin.write(bytes, error => resolve(!error));
405
- });
436
+ const stdin = child.stdin;
437
+ stdin?.on('error', () => {});
438
+ canDrive = () => stdin !== null && !stdin.destroyed && !stdin.writableEnded;
439
+ let inputQueue = Promise.resolve(true);
440
+ writeInput = bytes => {
441
+ inputQueue = inputQueue.then(previousAccepted => {
442
+ if (!previousAccepted || !canDrive()) return false;
443
+ return new Promise<boolean>(resolve => {
444
+ // write(false) still accepts the bytes. The completion callback
445
+ // waits until they flush; the sidechannel pauses its reader.
446
+ stdin!.write(bytes, error => resolve(!error));
447
+ });
448
+ });
449
+ return inputQueue;
450
+ };
451
+ for (const bytes of pendingInput.splice(0)) void writeInput(bytes);
452
+ // The last driver leaving is the end of input: flush what it sent, then
453
+ // EOF. Without it a driven Codex waits on its stdin lifecycle forever.
454
+ if (driven) {
455
+ channel?.whenDriveIdle(() => {
456
+ inputQueue = inputQueue.then(() => {
457
+ if (canDrive()) stdin!.end();
458
+ return false;
459
+ });
460
+ });
461
+ }
406
462
  let release = () => {};
407
463
  const stdout: Buffer[] = [];
408
464
  const stderr: Buffer[] = [];
@@ -414,7 +470,6 @@ async function spawnInvocation(
414
470
  const finish = (result: WorkerCliResult, discardTranscript = false): void => {
415
471
  if (settled) return;
416
472
  settled = true;
417
- if (inputTimer !== undefined) clearTimeout(inputTimer);
418
473
  channel?.close();
419
474
  if (timer !== undefined) clearTimeout(timer);
420
475
  if (graceTimer !== undefined) clearTimeout(graceTimer);
@@ -498,8 +553,12 @@ async function spawnInvocation(
498
553
  timer = undefined;
499
554
  if (graceTimer !== undefined) clearTimeout(graceTimer);
500
555
  graceTimer = undefined;
556
+ const transport = transportEvidence({
557
+ phase: 'abort', cause: 'lease_lost', exitCode: null, signal: null,
558
+ stderr: 'Agent execution aborted: lease ownership lost.', retryable: false,
559
+ }, env);
501
560
  finishAfterStop(
502
- { exit_code: null, stdout_tail: '', stderr_tail: 'Agent execution aborted: lease ownership lost.' },
561
+ { exit_code: null, stdout_tail: '', stderr_tail: 'Agent execution aborted: lease ownership lost.', transport },
503
562
  'kill',
504
563
  true,
505
564
  'abort',
@@ -529,14 +588,22 @@ async function spawnInvocation(
529
588
  const onResult = (outcome: { failed: boolean }): void => {
530
589
  if (graceTimer !== undefined || settled) return;
531
590
  graceTimer = setTimeout(() => {
591
+ const exitCode = outcome.failed ? 1 : 0;
592
+ const stderrText = `${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();
593
+ const transport = transportEvidence({
594
+ phase: 'result_exit_grace', cause: 'result_exit_timeout', exitCode, signal: null,
595
+ stderr: stderrText,
596
+ retryable: false,
597
+ }, env);
532
598
  finishAfterStop({
533
- exit_code: outcome.failed ? 1 : 0,
599
+ exit_code: exitCode,
534
600
  stdout_tail: Buffer.concat(stdout).toString('utf8'),
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(),
601
+ stderr_tail: redactText(stderrText, env),
602
+ transport,
536
603
  }, 'terminate');
537
604
  }, RESULT_EXIT_GRACE_MS);
538
605
  };
539
- child.stdout.on('data', (chunk: Buffer) => {
606
+ child.stdout!.on('data', (chunk: Buffer) => {
540
607
  stdout.push(chunk);
541
608
  channel?.publish(chunk);
542
609
  // The tails take raw bytes, so they are fed before any line splitting
@@ -555,28 +622,59 @@ async function spawnInvocation(
555
622
  if (outcome !== undefined) onResult(outcome);
556
623
  }
557
624
  });
558
- child.stderr.on('data', (chunk: Buffer) => { stderr.push(chunk); channel?.publish(chunk); tails?.stderr.append(chunk); });
559
- child.once('error', (error) => finishOnChildExit({
560
- exit_code: null,
561
- stdout_tail: Buffer.concat(stdout).toString('utf8'),
562
- stderr_tail: error.message,
563
- }));
625
+ child.stderr!.on('data', (chunk: Buffer) => { stderr.push(chunk); channel?.publish(chunk); tails?.stderr.append(chunk); });
626
+ child.once('error', (error) => {
627
+ const code = typeof (error as NodeJS.ErrnoException).code === 'string'
628
+ ? (error as NodeJS.ErrnoException).code : undefined;
629
+ const retryable = isRetryableSpawnError(code);
630
+ const transport = transportEvidence({
631
+ phase: 'spawn', cause: child.pid === undefined ? 'spawn_error' : 'process_error',
632
+ exitCode: null, signal: null, stderr: error.message, errorCode: code, retryable,
633
+ }, env);
634
+ finishOnChildExit({
635
+ exit_code: null,
636
+ stdout_tail: Buffer.concat(stdout).toString('utf8'),
637
+ stderr_tail: redactText(error.message, env),
638
+ transport,
639
+ });
640
+ });
564
641
  child.once('error', () => { if (child.pid === undefined) release(); });
565
642
  child.once('close', release);
566
- child.once('close', (code) => finishOnChildExit({
567
- exit_code: code,
568
- stdout_tail: Buffer.concat(stdout).toString('utf8'),
569
- stderr_tail: Buffer.concat(stderr).toString('utf8'),
570
- }));
643
+ child.once('close', (code, signalName) => {
644
+ const stderrText = Buffer.concat(stderr).toString('utf8');
645
+ const codexStdinLifecycle = kind === 'codex' && code === 1
646
+ && stderrText.trim() === 'Reading additional input from stdin...';
647
+ const cause: CliTransportCause = codexStdinLifecycle ? 'codex_stdin_lifecycle'
648
+ : signalName !== null ? 'signal'
649
+ : code === null ? 'close_without_status'
650
+ : code === 0 ? 'exited' : 'nonzero_exit';
651
+ const retryable = codexStdinLifecycle || signalName !== null || code === null;
652
+ const transport = transportEvidence({
653
+ phase: 'close', cause, exitCode: code, signal: signalName, stderr: stderrText, retryable,
654
+ }, env);
655
+ finishOnChildExit({
656
+ exit_code: code,
657
+ stdout_tail: Buffer.concat(stdout).toString('utf8'),
658
+ stderr_tail: redactText(stderrText, env),
659
+ transport,
660
+ });
661
+ });
571
662
  if (invocation.timeoutMs > 0) {
572
663
  timer = setTimeout(() => {
573
664
  // The stop owns this settle: a direct-child event is not proof that its
574
665
  // group is gone, and an unprovable forced stop must fail the step rather
575
666
  // than arriving after a successful result has already won the race.
667
+ const timeoutMessage = `CLI invocation timed out after ${invocation.timeoutMs}ms.`;
668
+ const transport = transportEvidence({
669
+ phase: 'timeout', cause: 'timeout', exitCode: null, signal: null,
670
+ stderr: `${Buffer.concat(stderr).toString('utf8')}\n${timeoutMessage}`,
671
+ retryable: false,
672
+ }, env);
576
673
  finishAfterStop({
577
674
  exit_code: null,
578
675
  stdout_tail: Buffer.concat(stdout).toString('utf8'),
579
- stderr_tail: `CLI invocation timed out after ${invocation.timeoutMs}ms.`,
676
+ stderr_tail: timeoutMessage,
677
+ transport,
580
678
  }, 'terminate');
581
679
  }, invocation.timeoutMs);
582
680
  }
@@ -35,7 +35,10 @@ function unmeteredUsage(input: number | undefined, output: number | undefined):
35
35
  */
36
36
  export function workerSpend(result: WorkerCliResult, model?: string): { result: WorkerCliResult; usage: StepUsage | undefined } {
37
37
  try {
38
- const usage = pricedUsage(model, result.tokens_input, result.tokens_output)
38
+ // Usage that was never reported is unknown, not a measured $0, even for a
39
+ // priced model (a step stopped before its CLI reported, for one).
40
+ const reported = result.tokens_input !== undefined && result.tokens_output !== undefined;
41
+ const usage = (reported ? pricedUsage(model, result.tokens_input, result.tokens_output) : undefined)
39
42
  ?? unmeteredUsage(result.tokens_input, result.tokens_output);
40
43
  return { result, usage };
41
44
  }
package/src/worker.ts CHANGED
@@ -7,13 +7,20 @@ import type { JournalClient } from './journal-client.js';
7
7
  import type { Pins, StepDispatchEvent } from './protocol.js';
8
8
  import type { ResolvedKernelAgentStep } from './resolved-cli-identity.js';
9
9
  import { runAgentCli } from './worker-cli.js';
10
+ import { agentCompletionReason } from './cli-transport-evidence.js';
10
11
  import { agentStepCwd } from './agent-cwd.js';
11
12
  import { resolveCliModel } from './cli-adapter.js';
12
13
  import { withWorkerLease } from './worker-lease.js';
13
14
  import { workerInstruction } from './worker-input.js';
14
15
  import { helperCall } from './yaml-helpers.js';
15
16
  import { completeHelperDispatch } from './yaml-helper-effect.js';
16
- import { ARTIFACT_PATHS_MAX, boundTranscriptDigest, type TranscriptDigest } from './agent-transcript.js';
17
+ import {
18
+ ARTIFACT_PATHS_MAX,
19
+ boundTranscriptDigest,
20
+ boundedTail,
21
+ redactText,
22
+ type TranscriptDigest,
23
+ } from './agent-transcript.js';
17
24
 
18
25
  export { MODEL_ENV, WAKE_CONTEXT_ENV } from './worker-cli.js';
19
26
 
@@ -149,11 +156,11 @@ export class AgentWorker extends EventEmitter {
149
156
  spec.transport === 'relay' ? 'relay' : 'direct',
150
157
  { runId: dispatch.run_id, stepId: dispatch.step_id, idempotencyKey: dispatch.idempotency_key,
151
158
  dataDir: this.options.dataDir, resultSchema: spec.verification?.json_schema },
152
- this.options.environment, spec.cli_identity)
159
+ this.options.environment, spec.cli_identity, spec.timeout_ms)
153
160
  : Promise.resolve({ exit_code: null, stdout_tail: '', stderr_tail: 'agent step has no declared CLI' }));
154
161
  const { result, usage } = workerSpend(completed, effectiveModel);
155
162
  const cost = reportedCost(completed, effectiveModel);
156
- const completionReason = result.exit_code === 0 ? 'success' : 'worker_error';
163
+ const completionReason = agentCompletionReason(result);
157
164
 
158
165
  // Output shape: if the CLI's stdout parses as JSON, promote THAT
159
166
  // as the step's `output` value so `json_schema` verification
@@ -181,7 +188,16 @@ export class AgentWorker extends EventEmitter {
181
188
  // `transcript` is not part of the wrapper either: it is evidence about the
182
189
  // attempt, journaled in `trajectory_tail` below on success and failure
183
190
  // alike, where the kernel already accepts and bounds it (16 KiB).
184
- const { transcript, ...wrapper } = result;
191
+ const { transcript, transport, ...rawWrapper } = result;
192
+ // `stderr_tail` is evidence, not authored output. Redact and bound it
193
+ // before it reaches either the worker-failure render or the journal --
194
+ // against the environment the CLI ran with as well as this host's, since a
195
+ // secret supplied only to the CLI is the one most likely to be echoed.
196
+ const redacted = redactText(redactText(rawWrapper.stderr_tail), this.options.environment ?? process.env);
197
+ const wrapper = {
198
+ ...rawWrapper,
199
+ stderr_tail: boundedTail(redacted, 2 * 1024, 'worker stderr: ').text,
200
+ };
185
201
  const output = result.relay_task?.status === 'completed' && result.exit_code === 0
186
202
  ? result.relay_task.output : parseJsonOutput(result.stdout_tail) ?? wrapper;
187
203
  const trajectoryTail = {
@@ -190,6 +206,7 @@ export class AgentWorker extends EventEmitter {
190
206
  task_execution: result.relay_task.task_execution, error: result.relay_task.error,
191
207
  } }),
192
208
  ...(transcript === undefined ? {} : { transcript: transcriptDigest(transcript, dispatch.attempt, result) }),
209
+ ...(transport === undefined ? {} : { transport }),
193
210
  };
194
211
 
195
212
  await this.client.stepComplete(
@@ -1,3 +1,4 @@
1
+ import { transportEvidence, type CliTransportEvidence } from './cli-transport-evidence.js';
1
2
  import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
2
3
  import { childStop, ownsProcessGroup } from './child-stop.js';
3
4
  import { reapOnExit } from './agent-reaper.js';
@@ -29,6 +30,7 @@ export interface WrapperSessionLimits {
29
30
  }
30
31
 
31
32
  export interface WrapperSessionResult {
33
+ transport?: CliTransportEvidence;
32
34
  exit_code: number | null;
33
35
  stdout_tail: string;
34
36
  stderr_tail: string;
@@ -157,7 +159,7 @@ async function executePinnedWrapper(
157
159
  let exitCode: number | null = null;
158
160
  /** The handshake deadline, then the execution deadline if there is one. */
159
161
  let lifecycleTimer: NodeJS.Timeout | undefined;
160
- /** The post-exit drain grace, armed only when execution is unlimited. */
162
+ /** The post-exit drain grace, armed once the wrapper has exited and acknowledged. */
161
163
  let drainTimer: NodeJS.Timeout | undefined;
162
164
 
163
165
  const finish = (result: WrapperSessionResult): void => {
@@ -231,7 +233,7 @@ async function executePinnedWrapper(
231
233
  };
232
234
  signal?.addEventListener('abort', onAbort, { once: true });
233
235
  if (signal?.aborted) { onAbort(); return; }
234
- const terminate = (message: string): void => {
236
+ const terminate = (message: string, transport?: WrapperSessionResult['transport'], captured = ''): void => {
235
237
  if (protocolError !== undefined) return;
236
238
  protocolError = message;
237
239
  if (lifecycleTimer !== undefined) clearTimeout(lifecycleTimer);
@@ -247,7 +249,7 @@ async function executePinnedWrapper(
247
249
  // The shared stop owns both the liveness bound and settlement. Its
248
250
  // callback preserves this refusal once group death is proved, or
249
251
  // replaces it with a fail-closed confirmation error when it is not.
250
- finishAfterStop(failure(message), 'terminate');
252
+ finishAfterStop({ ...failure(message), stdout_tail: captured, ...(transport === undefined ? {} : { transport }) }, 'terminate');
251
253
  };
252
254
  const startExecutionTimer = (): void => {
253
255
  if (lifecycleTimer !== undefined) clearTimeout(lifecycleTimer);
@@ -258,9 +260,29 @@ async function executePinnedWrapper(
258
260
  // and what bounds the work from here is the lease abort — the same thing
259
261
  // that bounds a `claude` or `codex` step.
260
262
  if (limits.executionTimeoutMs <= 0) return;
261
- lifecycleTimer = setTimeout(() => terminate(
263
+ lifecycleTimer = setTimeout(() => {
264
+ lifecycleTimer = undefined;
265
+ // The deadline bounds a wrapper that is still running. One that has
266
+ // already exited did not time out, whatever it left holding stdio:
267
+ // its settle belongs to the post-exit drain, which reports the
268
+ // wrapper's own exit. Re-arming here is idempotent.
269
+ if (childExited) {
270
+ startDrainGrace();
271
+ return;
272
+ }
273
+ onExecutionDeadline();
274
+ }, limits.executionTimeoutMs);
275
+ };
276
+ const onExecutionDeadline = (): void => {
277
+ terminate(
262
278
  `CLI ${JSON.stringify(cli)} execution timed out after ${limits.executionTimeoutMs}ms.`,
263
- ), limits.executionTimeoutMs);
279
+ transportEvidence({ phase: 'timeout', cause: 'timeout', retryable: false, exitCode: null, signal: null,
280
+ stderr: `CLI execution timed out after ${limits.executionTimeoutMs}ms.` }, env),
281
+ // What the wrapper wrote before its deadline, so a result envelope it
282
+ // already emitted still reports its usage. Only the caller decides
283
+ // what of it, if anything, is output.
284
+ stdout.join('') + executionPending,
285
+ );
264
286
  };
265
287
  const exceedsOutputLimit = (additionalBytes: number): boolean => {
266
288
  const pendingBytes = Buffer.byteLength(executionPending);
@@ -382,13 +404,13 @@ async function executePinnedWrapper(
382
404
  finishOnChildExit(result);
383
405
  };
384
406
  /**
385
- * Arm the post-exit drain, for an unlimited session that has both seen the
386
- * wrapper exit and consumed its acknowledgement. A session with an explicit
387
- * execution deadline keeps its existing behaviour: that deadline is what
388
- * bounds a withheld `'close'` there, and nothing about it changes.
407
+ * Arm the post-exit drain, for a session that has both seen the wrapper
408
+ * exit and consumed its acknowledgement. This holds with or without an
409
+ * execution deadline: the deadline bounds a wrapper still running, and a
410
+ * wrapper that exited — even with a descendant holding stdio — settles as
411
+ * its own exit here rather than waiting out the deadline as a timeout.
389
412
  */
390
413
  const startDrainGrace = (): void => {
391
- if (limits.executionTimeoutMs > 0) return;
392
414
  if (!childExited || phase !== 'execute') return;
393
415
  if (settled || drainTimer !== undefined || protocolError !== undefined) return;
394
416
  drainTimer = setTimeout(drainExpired, DRAIN_AFTER_EXIT_MS);
@@ -63,7 +63,7 @@ export type HelperCall = { [P in keyof YamlHelperParams]: { [V in keyof YamlHelp
63
63
  }[keyof YamlHelperParams[P]] }[keyof YamlHelperParams];
64
64
 
65
65
  const prefix = HELPER_INSTRUCTION_PREFIX;
66
- const commonFields = ['id', 'dependsOn', 'maxIterations', 'verification', 'output'] as const;
66
+ const commonFields = ['id', 'dependsOn', 'maxIterations', 'transportRetries', 'verification', 'output'] as const;
67
67
  const isObject = (value: unknown): value is Record<string, unknown> =>
68
68
  value !== null && typeof value === 'object' && !Array.isArray(value);
69
69