@relayflows/sdk 2.0.39 → 2.0.40

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 (80) 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/step-evidence.d.ts +5 -0
  14. package/dist/cli/step-evidence.d.ts.map +1 -1
  15. package/dist/cli/step-evidence.js +45 -7
  16. package/dist/cli/step-evidence.js.map +1 -1
  17. package/dist/cli/step-failure.d.ts.map +1 -1
  18. package/dist/cli/step-failure.js +13 -0
  19. package/dist/cli/step-failure.js.map +1 -1
  20. package/dist/cli-transport-evidence.d.ts +30 -0
  21. package/dist/cli-transport-evidence.d.ts.map +1 -0
  22. package/dist/cli-transport-evidence.js +31 -0
  23. package/dist/cli-transport-evidence.js.map +1 -0
  24. package/dist/compile.d.ts +3 -1
  25. package/dist/compile.d.ts.map +1 -1
  26. package/dist/compile.js +37 -13
  27. package/dist/compile.js.map +1 -1
  28. package/dist/failure-kinds.d.ts +13 -0
  29. package/dist/failure-kinds.d.ts.map +1 -1
  30. package/dist/failure-kinds.js.map +1 -1
  31. package/dist/pty-sidechannel.d.ts +17 -0
  32. package/dist/pty-sidechannel.d.ts.map +1 -1
  33. package/dist/pty-sidechannel.js +41 -2
  34. package/dist/pty-sidechannel.js.map +1 -1
  35. package/dist/spec.d.ts +6 -1
  36. package/dist/spec.d.ts.map +1 -1
  37. package/dist/spec.js.map +1 -1
  38. package/dist/step-fields.d.ts +2 -2
  39. package/dist/step-fields.d.ts.map +1 -1
  40. package/dist/step-fields.js +2 -1
  41. package/dist/step-fields.js.map +1 -1
  42. package/dist/validate.d.ts +2 -0
  43. package/dist/validate.d.ts.map +1 -1
  44. package/dist/validate.js +18 -0
  45. package/dist/validate.js.map +1 -1
  46. package/dist/worker-cli.d.ts +10 -1
  47. package/dist/worker-cli.d.ts.map +1 -1
  48. package/dist/worker-cli.js +116 -44
  49. package/dist/worker-cli.js.map +1 -1
  50. package/dist/worker-spend.d.ts.map +1 -1
  51. package/dist/worker-spend.js +4 -1
  52. package/dist/worker-spend.js.map +1 -1
  53. package/dist/worker.d.ts.map +1 -1
  54. package/dist/worker.js +15 -4
  55. package/dist/worker.js.map +1 -1
  56. package/dist/wrapper-session.d.ts +2 -0
  57. package/dist/wrapper-session.d.ts.map +1 -1
  58. package/dist/wrapper-session.js +29 -10
  59. package/dist/wrapper-session.js.map +1 -1
  60. package/dist/yaml-helpers.js +1 -1
  61. package/dist/yaml-helpers.js.map +1 -1
  62. package/package.json +2 -2
  63. package/src/agent-transcript.ts +12 -0
  64. package/src/authored-node-runner.ts +23 -5
  65. package/src/authored-root.ts +5 -6
  66. package/src/authored-worker-step.ts +58 -3
  67. package/src/cli/step-evidence.ts +51 -7
  68. package/src/cli/step-failure.ts +13 -0
  69. package/src/cli-transport-evidence.ts +70 -0
  70. package/src/compile.ts +39 -13
  71. package/src/failure-kinds.ts +13 -0
  72. package/src/pty-sidechannel.ts +38 -2
  73. package/src/spec.ts +6 -1
  74. package/src/step-fields.ts +2 -1
  75. package/src/validate.ts +18 -0
  76. package/src/worker-cli.ts +140 -42
  77. package/src/worker-spend.ts +4 -1
  78. package/src/worker.ts +21 -4
  79. package/src/wrapper-session.ts +32 -10
  80. package/src/yaml-helpers.ts +1 -1
@@ -17,6 +17,13 @@ export const ATTEMPT_TAIL_BYTES = 256;
17
17
  */
18
18
  const PRODUCER_TRUNCATED = /…\s*\((?:render bounded|[\d,]+ bytes truncated)\)$/u;
19
19
 
20
+ /**
21
+ * What the worker puts in front of a stderr it cut to its tail (`boundedTail`
22
+ * in worker.ts and cli-transport-evidence.ts). The same rule applies: equal
23
+ * surviving tails cannot establish equal failures.
24
+ */
25
+ const STDERR_TRUNCATED = /^\[(?:worker|transport) stderr: [\d,]+ bytes truncated\]…/u;
26
+
20
27
  /**
21
28
  * The evidence fields a step failure reports, as the journal held them.
22
29
  *
@@ -29,6 +36,11 @@ const PRODUCER_TRUNCATED = /…\s*\((?:render bounded|[\d,]+ bytes truncated)\)$
29
36
  */
30
37
  export interface SelectedEvidence {
31
38
  exitCode?: number;
39
+ transportPhase?: string;
40
+ transportCause?: string;
41
+ signal?: string;
42
+ errorCode?: string;
43
+ retryableTransport?: boolean;
32
44
  /** Present only when nonempty. */
33
45
  stdoutTail?: string;
34
46
  /** Present whenever the journal carried one, including the empty string. */
@@ -57,16 +69,18 @@ export interface SelectedEvidence {
57
69
  export function selectEvidence(payload: Record<string, unknown>): SelectedEvidence {
58
70
  const detail = record(payload['verification'])?.['detail'];
59
71
  const rendered = typeof detail === 'string' ? parsed(detail) : undefined;
72
+ const trajectory = record(payload['trajectory_tail']);
73
+ const transport = record(trajectory?.['transport']);
60
74
  const candidates = [
61
75
  record(payload['output']),
62
- record(payload['trajectory_tail']),
76
+ trajectory,
63
77
  rendered,
64
78
  ].filter((candidate): candidate is Record<string, unknown> => candidate !== undefined);
65
79
  const structured = candidates.find(processShaped) ?? candidates[0];
66
- const exitCode = structured?.['exit_code'];
80
+ const exitCode = structured?.['exit_code'] ?? transport?.['exit_code'];
67
81
  const stdout = structured?.['stdout_tail'];
68
- const stderr = structured?.['stderr_tail'];
69
- const transcript = record(record(payload['trajectory_tail'])?.['transcript']);
82
+ const stderr = structured?.['stderr_tail'] ?? transport?.['stderr_tail'];
83
+ const transcript = record(trajectory?.['transcript']);
70
84
  const failure = record(transcript?.['failure']);
71
85
  const excerpt = failure?.['excerpt'];
72
86
  const transcriptPath = record(transcript?.['file'])?.['path'];
@@ -77,6 +91,11 @@ export function selectEvidence(payload: Record<string, unknown>): SelectedEviden
77
91
  ? excerpt : undefined;
78
92
  return {
79
93
  ...(typeof exitCode === 'number' && Number.isSafeInteger(exitCode) ? { exitCode } : {}),
94
+ ...(typeof transport?.['phase'] === 'string' ? { transportPhase: transport['phase'] } : {}),
95
+ ...(typeof transport?.['cause'] === 'string' ? { transportCause: transport['cause'] } : {}),
96
+ ...(typeof transport?.['signal'] === 'string' ? { signal: transport['signal'] } : {}),
97
+ ...(typeof transport?.['error_code'] === 'string' ? { errorCode: transport['error_code'] } : {}),
98
+ ...(typeof transport?.['retryable'] === 'boolean' ? { retryableTransport: transport['retryable'] } : {}),
80
99
  ...(typeof stdout === 'string' && stdout.length > 0 ? { stdoutTail: stdout } : {}),
81
100
  ...(typeof stderr === 'string' ? { stderrTail: stderr } : {}),
82
101
  ...(excerptDetail !== undefined ? { detail: excerptDetail }
@@ -117,6 +136,11 @@ export function terminalEvidence(payload: Record<string, unknown>): Partial<Step
117
136
  const selected = selectEvidence(payload);
118
137
  return {
119
138
  ...(selected.exitCode === undefined ? {} : { exitCode: selected.exitCode }),
139
+ ...(selected.transportPhase === undefined ? {} : { transportPhase: formatStepExcerpt(selected.transportPhase) }),
140
+ ...(selected.transportCause === undefined ? {} : { transportCause: formatStepExcerpt(selected.transportCause) }),
141
+ ...(selected.signal === undefined ? {} : { signal: formatStepExcerpt(selected.signal) }),
142
+ ...(selected.errorCode === undefined ? {} : { errorCode: formatStepExcerpt(selected.errorCode) }),
143
+ ...(selected.retryableTransport === undefined ? {} : { retryableTransport: selected.retryableTransport }),
120
144
  ...(selected.stdoutTail === undefined ? {} : { stdoutTail: formatStepExcerpt(selected.stdoutTail) }),
121
145
  ...(selected.stderrTail === undefined ? {} : { stderrTail: formatStepExcerpt(selected.stderrTail) }),
122
146
  ...(selected.detail === undefined ? {} : { detail: formatStepExcerpt(selected.detail) }),
@@ -151,6 +175,13 @@ export function attemptFailure(
151
175
  completionReason,
152
176
  ...(typeof disposition === 'string' && disposition.length > 0 ? { disposition } : {}),
153
177
  ...(selected.exitCode === undefined ? {} : { exitCode: selected.exitCode }),
178
+ // Closed-vocabulary scalars, so a crash that left no stderr still says how
179
+ // it died (`signal=SIGKILL`) rather than reading as no evidence at all.
180
+ ...(selected.signal === undefined ? {} : { signal: bound(selected.signal) }),
181
+ ...(selected.transportCause === undefined ? {} : { transportCause: bound(selected.transportCause) }),
182
+ ...(selected.transportPhase === undefined ? {} : { transportPhase: bound(selected.transportPhase) }),
183
+ ...(selected.errorCode === undefined ? {} : { errorCode: bound(selected.errorCode) }),
184
+ ...(selected.retryableTransport === undefined ? {} : { retryableTransport: selected.retryableTransport }),
154
185
  ...(selected.stdoutTail === undefined ? {} : { stdoutTail: bound(selected.stdoutTail) }),
155
186
  ...(selected.stderrTail === undefined ? {} : { stderrTail: bound(selected.stderrTail) }),
156
187
  ...(selected.detail === undefined ? {} : { detail: bound(selected.detail) }),
@@ -177,15 +208,18 @@ export function failureCause(
177
208
  const verification = record(payload['verification']);
178
209
  const output = record(payload['output']);
179
210
  const trajectory = record(payload['trajectory_tail']);
211
+ const transport = record(trajectory?.['transport']);
180
212
  const failure = record(record(trajectory?.['transcript'])?.['failure']);
181
213
  const verificationDetail = text(verification?.['detail']);
182
214
  const accounts = [
183
215
  verificationDetail,
184
216
  text(output?.['stdout_tail']), text(output?.['stderr_tail']),
185
217
  text(trajectory?.['stdout_tail']), text(trajectory?.['stderr_tail']),
218
+ text(transport?.['phase']), text(transport?.['cause']), text(transport?.['signal']),
219
+ text(transport?.['error_code']), text(transport?.['stderr_tail']),
186
220
  text(failure?.['excerpt']),
187
221
  ];
188
- const exitCodes = [output?.['exit_code'], trajectory?.['exit_code']]
222
+ const exitCodes = [output?.['exit_code'], trajectory?.['exit_code'], transport?.['exit_code']]
189
223
  .filter(value => typeof value === 'number');
190
224
  return {
191
225
  key: JSON.stringify([
@@ -197,13 +231,15 @@ export function failureCause(
197
231
  completionReason === 'verification_failed' || completionReason === 'retries_exhausted'
198
232
  ? 'kernel_rejected' : completionReason,
199
233
  text(verification?.['gate']), text(verification?.['verdict']), verificationDetail,
200
- ...exitCodes, ...accounts, text(failure?.['kind']),
234
+ ...exitCodes, ...accounts, transport?.['retryable'], text(failure?.['kind']),
201
235
  ]),
202
236
  // An attempt that journaled nothing about itself cannot agree with
203
237
  // another one; it can only fail to disagree.
204
238
  recorded: exitCodes.length > 0 || accounts.some(account => account !== undefined),
205
239
  producerTruncated: (verificationDetail !== undefined
206
240
  && PRODUCER_TRUNCATED.test(verificationDetail))
241
+ || [output?.['stderr_tail'], trajectory?.['stderr_tail'], transport?.['stderr_tail']]
242
+ .some(stderr => typeof stderr === 'string' && STDERR_TRUNCATED.test(stderr))
207
243
  // The transcript digest's own flag (`boundTranscriptDigest`,
208
244
  // agent-transcript.ts): the worker already cut this excerpt, so equal
209
245
  // survivors cannot establish identical failures either.
@@ -255,8 +291,16 @@ export function renderAttemptHistory(details: StepFailedDetails): string {
255
291
  }
256
292
 
257
293
  function renderAttempt(attempt: StepAttemptFailure): string {
294
+ const transport = [
295
+ attempt.signal === undefined ? '' : ` signal=${attempt.signal}`,
296
+ attempt.transportCause === undefined ? '' : ` transport=${attempt.transportCause}`,
297
+ attempt.transportPhase === undefined ? '' : ` phase=${attempt.transportPhase}`,
298
+ attempt.errorCode === undefined ? '' : ` error_code=${attempt.errorCode}`,
299
+ attempt.retryableTransport === undefined ? '' : ` retryable=${attempt.retryableTransport}`,
300
+ ].join('');
258
301
  const head = ` attempt ${attempt.attempt ?? '?'}: ${attempt.completionReason ?? 'unknown'}`
259
302
  + (attempt.exitCode === undefined ? '' : ` exit=${attempt.exitCode}`)
303
+ + transport
260
304
  + (attempt.truncated ? ' (excerpt truncated)' : '');
261
305
  // An empty `stderrTail` is a journaled fact but not an account of anything,
262
306
  // so it never displaces useful stdout the way `detail ?? stderr ?? stdout`
@@ -264,7 +308,7 @@ function renderAttempt(attempt: StepAttemptFailure): string {
264
308
  const accounts: Array<[string, string]> = [
265
309
  ['detail', attempt.detail], ['stderr', attempt.stderrTail], ['stdout', attempt.stdoutTail],
266
310
  ].filter((pair): pair is [string, string] => typeof pair[1] === 'string' && pair[1].length > 0);
267
- if (accounts.length === 0) return `${head} — no failure evidence recorded`;
311
+ if (accounts.length === 0) return transport === '' ? `${head} — no failure evidence recorded` : head;
268
312
  const [only] = accounts;
269
313
  if (accounts.length === 1 && !only![1].includes('\n')) return `${head} — ${only![0]}: ${only![1]}`;
270
314
  return [head, ...accounts.map(([label, value]) => indent(`${label}: ${value}`))].join('\n');
@@ -60,6 +60,7 @@ export async function stepFailureDetails(
60
60
  // enforcing (relayflowd-core/src/entry.rs `AttemptStartedPayload`). They are
61
61
  // collected on the same walk and reported only when the journal held them.
62
62
  const budgets = new Map<string, number>();
63
+ const transportBudgets = new Map<string, number>();
63
64
  // Keyed by step, so interleaved steps never pool their attempts and a page
64
65
  // boundary never splits one step's history.
65
66
  const histories = new Map<string, AttemptHistory>();
@@ -82,6 +83,10 @@ export async function stepFailureDetails(
82
83
  if (typeof maxIterations === 'number' && Number.isSafeInteger(maxIterations) && maxIterations > 0) {
83
84
  budgets.set(stepId, maxIterations);
84
85
  }
86
+ const transportRetries = record(entry['payload'])?.['max_transport_retries'];
87
+ if (typeof transportRetries === 'number' && Number.isSafeInteger(transportRetries) && transportRetries >= 0) {
88
+ transportBudgets.set(stepId, transportRetries);
89
+ }
85
90
  continue;
86
91
  }
87
92
  if (entry['entry_type'] !== 'step.completed') continue;
@@ -117,12 +122,14 @@ export async function stepFailureDetails(
117
122
  const stepType = snapshot.steps[stepId]?.type;
118
123
  const attempt = entry['attempt'];
119
124
  const maxIterations = budgets.get(stepId);
125
+ const transportRetries = transportBudgets.get(stepId);
120
126
  failures.set(stepId, {
121
127
  stepId,
122
128
  completionReason,
123
129
  ...(stepType === undefined ? {} : { stepType }),
124
130
  ...(typeof attempt === 'number' && Number.isSafeInteger(attempt) && attempt > 0 ? { attempt } : {}),
125
131
  ...(maxIterations === undefined ? {} : { maxIterations }),
132
+ ...(transportRetries === undefined ? {} : { transportRetries }),
126
133
  ...((uncharged.get(stepId) ?? 0) > 0 ? { unchargedAttempts: uncharged.get(stepId) } : {}),
127
134
  ...terminalEvidence(payload),
128
135
  // A single failed attempt is already fully described by the scalars
@@ -166,7 +173,13 @@ export function renderStepEvidence(details: StepFailedDetails): string {
166
173
  + (details.stepType === undefined ? '' : ` (${details.stepType})`)
167
174
  + ` completionReason: ${details.completionReason}`
168
175
  + renderAttempt(details)
176
+ + (details.transportRetries === undefined ? '' : ` transportRetries=${details.transportRetries}`)
169
177
  + (details.exitCode === undefined ? '' : ` exit=${details.exitCode}`)
178
+ + (details.signal === undefined ? '' : ` signal=${details.signal}`)
179
+ + (details.transportCause === undefined ? '' : ` transport=${details.transportCause}`)
180
+ + (details.transportPhase === undefined ? '' : ` phase=${details.transportPhase}`)
181
+ + (details.errorCode === undefined ? '' : ` error_code=${details.errorCode}`)
182
+ + (details.retryableTransport === undefined ? '' : ` retryable=${details.retryableTransport}`)
170
183
  + '.'
171
184
  + renderAttemptHistory(details)
172
185
  + (details.detail === undefined ? '' : `\nDetail: ${details.detail}`)
@@ -0,0 +1,70 @@
1
+ import { boundedTail, redactText } from './agent-transcript.js';
2
+
3
+ export type CliTransportPhase = 'spawn' | 'close' | 'abort' | 'timeout' | 'result_exit_grace';
4
+ export type CliTransportCause =
5
+ | 'exited'
6
+ | 'nonzero_exit'
7
+ | 'signal'
8
+ | 'close_without_status'
9
+ | 'spawn_error'
10
+ | 'process_error'
11
+ | 'lease_lost'
12
+ | 'timeout'
13
+ | 'result_exit_timeout'
14
+ | 'codex_stdin_lifecycle';
15
+
16
+ export interface CliTransportEvidence {
17
+ phase: CliTransportPhase;
18
+ cause: CliTransportCause;
19
+ exit_code: number | null;
20
+ signal: NodeJS.Signals | null;
21
+ /** OS/libuv error code, never an unbounded error message. */
22
+ error_code?: string;
23
+ /** True only for a closed set of infrastructure failures. */
24
+ retryable: boolean;
25
+ /** Redacted UTF-8 tail with its truncation made explicit. */
26
+ stderr_tail: string;
27
+ }
28
+
29
+ /** Infrastructure classification is deliberately narrower than "nonzero". */
30
+ export function agentCompletionReason(
31
+ result: { exit_code: number | null; transport?: CliTransportEvidence },
32
+ ): 'success' | 'crashed' | 'timeout' | 'worker_error' {
33
+ if (result.exit_code === 0) return 'success';
34
+ if (result.transport?.retryable === true) return 'crashed';
35
+ if (result.transport?.cause === 'timeout') return 'timeout';
36
+ return 'worker_error';
37
+ }
38
+
39
+ const TRANSPORT_STDERR_MAX_BYTES = 2 * 1024;
40
+ const RETRYABLE_SPAWN_ERROR_CODES = new Set(['EAGAIN', 'EMFILE', 'ENFILE', 'ENOMEM', 'ETXTBSY']);
41
+
42
+ export function isRetryableSpawnError(code: string | undefined): boolean {
43
+ return code !== undefined && RETRYABLE_SPAWN_ERROR_CODES.has(code);
44
+ }
45
+
46
+ export function transportEvidence(
47
+ value: {
48
+ phase: CliTransportPhase;
49
+ cause: CliTransportCause;
50
+ exitCode: number | null;
51
+ signal: NodeJS.Signals | null;
52
+ stderr: string;
53
+ errorCode?: string;
54
+ retryable: boolean;
55
+ },
56
+ env: NodeJS.ProcessEnv,
57
+ ): CliTransportEvidence {
58
+ const bounded = boundedTail(redactText(value.stderr, env), TRANSPORT_STDERR_MAX_BYTES, 'transport stderr: ');
59
+ const errorCode = value.errorCode !== undefined && /^[A-Z0-9_-]{1,64}$/.test(value.errorCode)
60
+ ? value.errorCode : undefined;
61
+ return {
62
+ phase: value.phase,
63
+ cause: value.cause,
64
+ exit_code: value.exitCode,
65
+ signal: value.signal,
66
+ ...(errorCode === undefined ? {} : { error_code: errorCode }),
67
+ retryable: value.retryable,
68
+ stderr_tail: bounded.text,
69
+ };
70
+ }
package/src/compile.ts CHANGED
@@ -38,7 +38,7 @@ import type {
38
38
  import { SPEC_SCHEMA_VERSION } from './spec.js';
39
39
  import { canonicalize, specHash } from './canonical.js';
40
40
  import { validateOutputDeclaration } from './output-schema.js';
41
- import { validateSpec, type ValidationResult } from './validate.js';
41
+ import { isTransportRetries, validateSpec, type ValidationResult } from './validate.js';
42
42
  import { snapshotJsonValue } from './json-value.js';
43
43
  import { expandYamlHelpers } from './yaml-helpers.js';
44
44
  import { expandCommunication, validateCommunicationTopology } from './communication/spec.js';
@@ -74,7 +74,12 @@ export class CompileError extends Error {
74
74
  * survives float-precision (`1.1 * 1000 = 1100.0000000000002`) rather than
75
75
  * being rejected by `isSafeInteger`.
76
76
  */
77
- export function parseStepTimeout(timeout: unknown): number {
77
+ export const AGENT_STEP_TIMEOUT_MAX_MS = 60 * 60_000;
78
+ export function parseAgentStepTimeout(timeout: unknown): number {
79
+ return parseStepTimeout(timeout, true);
80
+ }
81
+
82
+ export function parseStepTimeout(timeout: unknown, agent = false): number {
78
83
  const unitToMs: Record<'ms' | 's' | 'm', number> = { ms: 1, s: 1000, m: 60_000 };
79
84
  let milliseconds: number;
80
85
  if (typeof timeout === 'number') {
@@ -96,10 +101,10 @@ export function parseStepTimeout(timeout: unknown): number {
96
101
  milliseconds = NaN;
97
102
  }
98
103
  if (!Number.isSafeInteger(milliseconds) || milliseconds <= 0) {
99
- throw new CompileError(['f.run timeout must be a positive whole number of milliseconds or a duration such as "10s" or "5m".'], 'timeout_invalid');
104
+ throw new CompileError([`${agent ? 'f.agent' : 'f.run'} timeout must be a positive whole number of milliseconds or a duration such as "10s" or "5m".`], 'timeout_invalid');
100
105
  }
101
- if (milliseconds > 15 * 60_000) {
102
- throw new CompileError(['f.run timeout exceeds the maximum of 15 minutes declared in SURFACE.md.'], 'lease_exceeded');
106
+ if (milliseconds > (agent ? AGENT_STEP_TIMEOUT_MAX_MS : 15 * 60_000)) {
107
+ throw new CompileError([`${agent ? 'f.agent' : 'f.run'} timeout exceeds the maximum of ${agent ? 60 : 15} minutes declared in SURFACE.md.`], 'lease_exceeded');
103
108
  }
104
109
  return milliseconds;
105
110
  }
@@ -190,6 +195,8 @@ function compileStep(step: StepSpec): StepSpec {
190
195
  ? { dependsOn: step.input === undefined ? step.dependsOn : [...new Set([...(step.dependsOn ?? []), ...bindingDependencies(step.input)])] } : {}),
191
196
  ...(step.input !== undefined ? { input: step.input } : {}),
192
197
  maxIterations,
198
+ ...(step.transportRetries !== undefined && step.transportRetries !== DEFAULT_TRANSPORT_RETRIES
199
+ ? { transportRetries: step.transportRetries } : {}),
193
200
  ...(step.memory !== undefined ? { memory: step.memory } : {}),
194
201
  ...(step.requirements !== undefined ? { requirements: step.requirements } : {}),
195
202
  };
@@ -209,7 +216,7 @@ function compileStep(step: StepSpec): StepSpec {
209
216
  type: 'deterministic',
210
217
  command: s.command,
211
218
  verification: verification ?? { type: 'exit_code' as const },
212
- // #138: `timeoutMs` is deterministic-only — worker-backed verbs own
219
+ // Timeout fields stay per-verb — worker-backed verbs own
213
220
  // their dispatch timeout. It must be spread HERE and nowhere in `base`.
214
221
  ...(s.timeoutMs !== undefined ? { timeoutMs: s.timeoutMs } : {}),
215
222
  ...(s.lease_ms !== undefined ? { lease_ms: parseStepTimeout(s.lease_ms) } : {}),
@@ -240,6 +247,7 @@ function compileStep(step: StepSpec): StepSpec {
240
247
  ...base,
241
248
  type: 'agent',
242
249
  instruction: s.instruction,
250
+ ...(s.timeoutMs === undefined ? {} : { timeoutMs: s.timeoutMs }),
243
251
  ...(verification !== undefined ? { verification: outputGate(verification, s.id) } : {}),
244
252
  ...(s.agent !== undefined ? { agent: s.agent } : {}),
245
253
  ...(s.cli !== undefined ? { cli: s.cli } : {}),
@@ -320,6 +328,10 @@ const KERNEL_RETRY_DEFAULTS = {
320
328
  jitter_percent: 20,
321
329
  } as const;
322
330
 
331
+ // spec.rs `default_max_transport_retries`. The kernel serializes this value as
332
+ // absent, so the SDK must too, or an explicit default changes the spec hash.
333
+ const DEFAULT_TRANSPORT_RETRIES = 1;
334
+
323
335
  /**
324
336
  * Map an authoring `FlowSpec` to the kernel spec dialect — the single shape at
325
337
  * the SDK↔kernel boundary (`kernel/relayflowd-core/src/spec.rs`). Authoring
@@ -452,7 +464,7 @@ function kernelStepToAuthoring(value: unknown, at: string): unknown {
452
464
  const unionKeys = [
453
465
  'id', 'type', 'depends_on', 'max_iterations', 'retry', 'verification', 'memory', 'requirements', 'input',
454
466
  'command', 'timeout_ms', 'lease_ms', 'on_non_zero', 'prompt', 'model', 'cli', 'instruction',
455
- 'cwd', 'recovery_mode', 'surfaces', 'permissions', 'cli_identity',
467
+ 'cwd', 'recovery_mode', 'surfaces', 'permissions', 'transport', 'cli_identity',
456
468
  ] as const;
457
469
  const step = requireKernelObject(value, unionKeys, at);
458
470
  if (step['cli_identity'] !== undefined) {
@@ -467,10 +479,11 @@ function kernelStepToAuthoring(value: unknown, at: string): unknown {
467
479
  : type === 'llm'
468
480
  ? ['prompt', 'model', 'cli', 'cli_identity'] as const
469
481
  : type === 'agent'
470
- ? ['instruction', 'cli', 'model', 'cwd', 'recovery_mode', 'surfaces', 'permissions', 'cli_identity'] as const
482
+ ? ['timeout_ms', 'instruction', 'cli', 'model', 'recovery_mode', 'surfaces', 'permissions', 'cwd', 'transport', 'cli_identity'] as const
471
483
  : [];
472
484
  assertKernelKeys(step, [...commonKeys, ...typeKeys], at);
473
- if (step['retry'] !== undefined) validateAuthoringRetryDefaults(step['retry'], `${at}.retry`);
485
+ const transportRetries = step['retry'] === undefined
486
+ ? undefined : validateAuthoringRetryDefaults(step['retry'], `${at}.retry`);
474
487
  const dependsOn = step['depends_on'];
475
488
  const common = {
476
489
  id: step['id'],
@@ -481,6 +494,7 @@ function kernelStepToAuthoring(value: unknown, at: string): unknown {
481
494
  ? { dependsOn }
482
495
  : {}),
483
496
  ...(step['max_iterations'] !== undefined ? { maxIterations: step['max_iterations'] } : {}),
497
+ ...(transportRetries === undefined ? {} : { transportRetries }),
484
498
  ...kernelVerificationToAuthoring(type, step['verification'], `${at}.verification`),
485
499
  ...(step['memory'] !== undefined ? { memory: kernelMemoryToAuthoring(step['memory'], `${at}.memory`) } : {}),
486
500
  };
@@ -500,8 +514,9 @@ function kernelStepToAuthoring(value: unknown, at: string): unknown {
500
514
  return {
501
515
  ...common,
502
516
  instruction: step['instruction'],
517
+ ...(step['timeout_ms'] === undefined ? {} : { timeoutMs: step['timeout_ms'] }),
503
518
  ...(step['recovery_mode'] !== undefined ? { recoveryMode: step['recovery_mode'] } : {}),
504
- ...copyDefined(step, ['cli', 'model', 'cwd', 'surfaces']),
519
+ ...copyDefined(step, ['cli', 'model', 'surfaces', 'cwd', 'transport']),
505
520
  ...(step['permissions'] !== undefined
506
521
  ? { permissions: kernelPermissionsToAuthoring(step['permissions'], `${at}.permissions`) }
507
522
  : {}),
@@ -510,9 +525,9 @@ function kernelStepToAuthoring(value: unknown, at: string): unknown {
510
525
  return common;
511
526
  }
512
527
 
513
- function validateAuthoringRetryDefaults(value: unknown, at: string): void {
528
+ function validateAuthoringRetryDefaults(value: unknown, at: string): number | undefined {
514
529
  const retry = requireKernelObject(value, [
515
- 'initial_backoff_ms', 'max_backoff_ms', 'multiplier', 'jitter_percent',
530
+ 'initial_backoff_ms', 'max_backoff_ms', 'multiplier', 'jitter_percent', 'max_transport_retries',
516
531
  ], at);
517
532
  for (const [field, expected] of Object.entries(KERNEL_RETRY_DEFAULTS)) {
518
533
  if (retry[field] !== expected) {
@@ -521,6 +536,12 @@ function validateAuthoringRetryDefaults(value: unknown, at: string): void {
521
536
  ]);
522
537
  }
523
538
  }
539
+ const transportRetries = retry['max_transport_retries'];
540
+ if (transportRetries === undefined) return undefined;
541
+ if (!isTransportRetries(transportRetries)) {
542
+ throw new CompileError([`${at}.max_transport_retries must be a non-negative u32 integer`]);
543
+ }
544
+ return transportRetries === DEFAULT_TRANSPORT_RETRIES ? undefined : transportRetries;
524
545
  }
525
546
 
526
547
  function kernelVerificationToAuthoring(
@@ -617,7 +638,11 @@ function toKernelStep(step: StepSpec, cliIdentity?: string): ResolvedKernelStepS
617
638
  : [...new Set([...(step.dependsOn ?? []), ...bindingDependencies(step.input)])],
618
639
  ...(step.input !== undefined ? { input: step.input } : {}),
619
640
  max_iterations: step.maxIterations ?? 1,
620
- retry: { ...KERNEL_RETRY_DEFAULTS },
641
+ retry: {
642
+ ...KERNEL_RETRY_DEFAULTS,
643
+ ...(step.transportRetries !== undefined && step.transportRetries !== DEFAULT_TRANSPORT_RETRIES
644
+ ? { max_transport_retries: step.transportRetries } : {}),
645
+ },
621
646
  verification: toKernelVerification(step),
622
647
  ...(cliIdentity !== undefined ? { cli_identity: cliIdentity } : {}),
623
648
  ...(step.requirements !== undefined ? { requirements: {
@@ -662,6 +687,7 @@ function toKernelStep(step: StepSpec, cliIdentity?: string): ResolvedKernelStepS
662
687
  ...common,
663
688
  type: 'agent',
664
689
  instruction: step.instruction,
690
+ ...(step.timeoutMs === undefined ? {} : { timeout_ms: step.timeoutMs }),
665
691
  ...(step.cli !== undefined ? { cli: step.cli } : {}),
666
692
  ...(step.model !== undefined ? { model: step.model } : {}),
667
693
  ...(step.cwd !== undefined ? { cwd: step.cwd } : {}),
@@ -207,6 +207,12 @@ export interface StepAttemptFailure {
207
207
  /** `retry`, `step_done` or `park`: what the kernel did next, not why it failed. */
208
208
  disposition?: string;
209
209
  exitCode?: number;
210
+ /** This attempt's own transport evidence; see `StepFailedDetails`. */
211
+ transportPhase?: string;
212
+ transportCause?: string;
213
+ signal?: string;
214
+ errorCode?: string;
215
+ retryableTransport?: boolean;
210
216
  /** Redacted, terminal-safe UTF-8 excerpt, at most 256 bytes. */
211
217
  stdoutTail?: string;
212
218
  /** Redacted, terminal-safe UTF-8 excerpt, at most 256 bytes. */
@@ -277,6 +283,8 @@ export interface StepFailedDetails {
277
283
  * `retries_exhausted`.
278
284
  */
279
285
  maxIterations?: number;
286
+ /** Additional classified infrastructure retries declared for the step. */
287
+ transportRetries?: number;
280
288
  /**
281
289
  * Attempts before this one that died without a result -- the kernel
282
290
  * journaled them `crashed` or `lease_expired` itself -- and so, by design,
@@ -286,6 +294,11 @@ export interface StepFailedDetails {
286
294
  */
287
295
  unchargedAttempts?: number;
288
296
  exitCode?: number;
297
+ transportPhase?: string;
298
+ transportCause?: string;
299
+ signal?: string;
300
+ errorCode?: string;
301
+ retryableTransport?: boolean;
289
302
  /**
290
303
  * Terminal-safe UTF-8 excerpt of the captured stdout, at most
291
304
  * `EXCERPT_BYTES` (cli/step-excerpt.ts). The field name is kept for
@@ -31,6 +31,8 @@ export async function openSidechannel(
31
31
  canDrive: () => boolean = () => true,
32
32
  ) {
33
33
  const peers = new Map<Socket, boolean>();
34
+ const drivePeers = new Set<Socket>();
35
+ const driveIdle: Array<() => void> = [];
34
36
  let closed = false;
35
37
  const server = createServer(socket => {
36
38
  if (peers.size >= 16) { socket.destroy(); return; }
@@ -39,7 +41,12 @@ export async function openSidechannel(
39
41
  let mode: string | undefined;
40
42
  socket.setTimeout(2_000, () => socket.destroy());
41
43
  socket.on('error', () => socket.destroy());
42
- socket.on('close', () => peers.delete(socket));
44
+ socket.on('close', () => {
45
+ peers.delete(socket);
46
+ if (drivePeers.delete(socket) && drivePeers.size === 0) {
47
+ for (const listener of driveIdle.splice(0)) listener();
48
+ }
49
+ });
43
50
  socket.on('data', (bytes: Buffer) => {
44
51
  if (mode === undefined) {
45
52
  hello = Buffer.concat([hello, bytes]);
@@ -52,7 +59,10 @@ export async function openSidechannel(
52
59
  socket.setTimeout(0);
53
60
  peers.set(socket, true);
54
61
  // Passthrough is a passive raw-byte view in this initial slice.
55
- if (mode === 'drive') context.onDrive();
62
+ if (mode === 'drive') {
63
+ drivePeers.add(socket);
64
+ context.onDrive();
65
+ }
56
66
  bytes = hello.subarray(end + 1);
57
67
  hello = Buffer.alloc(0);
58
68
  }
@@ -89,6 +99,32 @@ export async function openSidechannel(
89
99
  if (ready && !peer.write(bytes)) peer.destroy();
90
100
  }
91
101
  },
102
+ /**
103
+ * Wait for a drive peer before the CLI is spawned.
104
+ *
105
+ * A direct headless CLI must not be handed a pipe merely because a
106
+ * sidechannel exists: Codex treats any non-TTY stdin as an additional
107
+ * prompt source and waits for its lifecycle. The caller uses this bounded
108
+ * enrollment window to choose `pipe` only when a driver actually joined;
109
+ * view and passthrough peers never change the child's stdin contract.
110
+ */
111
+ async waitForDrive(timeoutMs: number): Promise<boolean> {
112
+ // Enrollment is a live state, not a sticky historical event. Hold the
113
+ // whole bounded window so a peer that greets and then disconnects before
114
+ // spawn cannot leave the child with piped stdin and nobody to close it.
115
+ if (timeoutMs > 0) await new Promise<void>(resolve => setTimeout(resolve, timeoutMs));
116
+ return drivePeers.size > 0;
117
+ },
118
+ /**
119
+ * Run `listener` once no drive peer is live: now, or when the last one
120
+ * disconnects. `waitForDrive` is a snapshot, and a peer can leave the
121
+ * moment after it is taken; the caller that chose `pipe` on it closes the
122
+ * child's stdin here, so the child never waits on a pipe nobody holds.
123
+ */
124
+ whenDriveIdle(listener: () => void): void {
125
+ if (drivePeers.size === 0) listener();
126
+ else driveIdle.push(listener);
127
+ },
92
128
  close() {
93
129
  if (closed) return;
94
130
  closed = true;
package/src/spec.ts CHANGED
@@ -228,6 +228,8 @@ export interface BaseStepSpec {
228
228
  dependsOn?: string[];
229
229
  /** Semantic retry bound (kernel DESIGN.md §1.2 `max_iterations`). Default 1. */
230
230
  maxIterations?: number;
231
+ /** Additional attempts after classified infrastructure loss. Default 1. */
232
+ transportRetries?: number;
231
233
  }
232
234
 
233
235
  export interface OutputBinding {
@@ -288,6 +290,7 @@ export interface LlmStepSpec extends BaseStepSpec {
288
290
  */
289
291
  export interface AgentStepSpec extends BaseStepSpec {
290
292
  type: 'agent';
293
+ timeoutMs?: number;
291
294
  instruction: string;
292
295
  verification?: OutputVerificationSpec;
293
296
  /** Named authoring declaration selected from `FlowSpec.agents`. Compiled away. */
@@ -352,7 +355,7 @@ export interface YamlHelperParams {
352
355
  type OneKey<T> = { [K in keyof T]: Pick<T, K> & Partial<Record<Exclude<keyof T, K>, never>> }[keyof T];
353
356
 
354
357
  /** Helper sugar is removed before validation of the three kernel step types. */
355
- export type YamlHelperStepSpec = Pick<BaseStepSpec, 'id' | 'dependsOn' | 'maxIterations'> & {
358
+ export type YamlHelperStepSpec = Pick<BaseStepSpec, 'id' | 'dependsOn' | 'maxIterations' | 'transportRetries'> & {
356
359
  verification?: OutputVerificationSpec;
357
360
  output?: JsonOutputSchema;
358
361
  } & OneKey<{ [P in keyof YamlHelperParams]: OneKey<YamlHelperParams[P]> }>;
@@ -453,6 +456,7 @@ export interface KernelRetryPolicy {
453
456
  max_backoff_ms: number;
454
457
  multiplier: number;
455
458
  jitter_percent: number;
459
+ max_transport_retries?: number;
456
460
  }
457
461
 
458
462
  /**
@@ -510,6 +514,7 @@ export interface KernelPermissionsSpec {
510
514
 
511
515
  export interface KernelAgentStep extends KernelStepCommon {
512
516
  type: 'agent';
517
+ timeout_ms?: number;
513
518
  instruction: string;
514
519
  cli?: string;
515
520
  model?: string;
@@ -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
  }