@relayflows/sdk 2.0.22 → 2.0.23

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 (193) hide show
  1. package/dist/agent-artifacts.d.ts +18 -9
  2. package/dist/agent-artifacts.d.ts.map +1 -1
  3. package/dist/agent-artifacts.js +27 -11
  4. package/dist/agent-artifacts.js.map +1 -1
  5. package/dist/authored-flow-error.d.ts +33 -1
  6. package/dist/authored-flow-error.d.ts.map +1 -1
  7. package/dist/authored-flow-error.js +20 -1
  8. package/dist/authored-flow-error.js.map +1 -1
  9. package/dist/authored-flow-executor.d.ts.map +1 -1
  10. package/dist/authored-flow-executor.js +4 -1
  11. package/dist/authored-flow-executor.js.map +1 -1
  12. package/dist/authored-node-entry.js +5 -1
  13. package/dist/authored-node-entry.js.map +1 -1
  14. package/dist/authored-node-runner.d.ts +11 -0
  15. package/dist/authored-node-runner.d.ts.map +1 -1
  16. package/dist/authored-node-runner.js +30 -1
  17. package/dist/authored-node-runner.js.map +1 -1
  18. package/dist/authored-root.js +3 -1
  19. package/dist/authored-root.js.map +1 -1
  20. package/dist/authored-source-authority.js +13 -0
  21. package/dist/authored-source-authority.js.map +1 -1
  22. package/dist/authored-step-index.d.ts +82 -0
  23. package/dist/authored-step-index.d.ts.map +1 -0
  24. package/dist/authored-step-index.js +120 -0
  25. package/dist/authored-step-index.js.map +1 -0
  26. package/dist/authored-step-output.d.ts +23 -2
  27. package/dist/authored-step-output.d.ts.map +1 -1
  28. package/dist/authored-step-output.js +47 -5
  29. package/dist/authored-step-output.js.map +1 -1
  30. package/dist/authored-worker-step.d.ts +10 -2
  31. package/dist/authored-worker-step.d.ts.map +1 -1
  32. package/dist/authored-worker-step.js +81 -7
  33. package/dist/authored-worker-step.js.map +1 -1
  34. package/dist/cli/check.d.ts.map +1 -1
  35. package/dist/cli/check.js +67 -12
  36. package/dist/cli/check.js.map +1 -1
  37. package/dist/cli/cloud-read.d.ts +31 -0
  38. package/dist/cli/cloud-read.d.ts.map +1 -0
  39. package/dist/cli/cloud-read.js +482 -0
  40. package/dist/cli/cloud-read.js.map +1 -0
  41. package/dist/cli/run.d.ts +2 -0
  42. package/dist/cli/run.d.ts.map +1 -1
  43. package/dist/cli/run.js +53 -25
  44. package/dist/cli/run.js.map +1 -1
  45. package/dist/cli/status.d.ts +9 -0
  46. package/dist/cli/status.d.ts.map +1 -1
  47. package/dist/cli/status.js +14 -0
  48. package/dist/cli/status.js.map +1 -1
  49. package/dist/cli/step-failure.d.ts +26 -0
  50. package/dist/cli/step-failure.d.ts.map +1 -1
  51. package/dist/cli/step-failure.js +68 -3
  52. package/dist/cli/step-failure.js.map +1 -1
  53. package/dist/cli-commands.d.ts +29 -1
  54. package/dist/cli-commands.d.ts.map +1 -1
  55. package/dist/cli-commands.js +23 -1
  56. package/dist/cli-commands.js.map +1 -1
  57. package/dist/cli.d.ts +2 -1
  58. package/dist/cli.d.ts.map +1 -1
  59. package/dist/cli.js +19 -2
  60. package/dist/cli.js.map +1 -1
  61. package/dist/cloud-http.d.ts +12 -0
  62. package/dist/cloud-http.d.ts.map +1 -1
  63. package/dist/cloud-http.js +14 -6
  64. package/dist/cloud-http.js.map +1 -1
  65. package/dist/cloud-read.d.ts +154 -0
  66. package/dist/cloud-read.d.ts.map +1 -0
  67. package/dist/cloud-read.js +373 -0
  68. package/dist/cloud-read.js.map +1 -0
  69. package/dist/cloud-transcript.d.ts +94 -0
  70. package/dist/cloud-transcript.d.ts.map +1 -0
  71. package/dist/cloud-transcript.js +342 -0
  72. package/dist/cloud-transcript.js.map +1 -0
  73. package/dist/communication/environment.d.ts +3 -0
  74. package/dist/communication/environment.d.ts.map +1 -0
  75. package/dist/communication/environment.js +34 -0
  76. package/dist/communication/environment.js.map +1 -0
  77. package/dist/communication/history.d.ts +6 -0
  78. package/dist/communication/history.d.ts.map +1 -0
  79. package/dist/communication/history.js +38 -0
  80. package/dist/communication/history.js.map +1 -0
  81. package/dist/communication/local.d.ts +6 -0
  82. package/dist/communication/local.d.ts.map +1 -0
  83. package/dist/communication/local.js +56 -0
  84. package/dist/communication/local.js.map +1 -0
  85. package/dist/communication/preflight.d.ts +6 -0
  86. package/dist/communication/preflight.d.ts.map +1 -0
  87. package/dist/communication/preflight.js +37 -0
  88. package/dist/communication/preflight.js.map +1 -0
  89. package/dist/communication/projection.d.ts +8 -0
  90. package/dist/communication/projection.d.ts.map +1 -0
  91. package/dist/communication/projection.js +23 -0
  92. package/dist/communication/projection.js.map +1 -0
  93. package/dist/communication/relay.d.ts +78 -0
  94. package/dist/communication/relay.d.ts.map +1 -0
  95. package/dist/communication/relay.js +89 -0
  96. package/dist/communication/relay.js.map +1 -0
  97. package/dist/communication/session.d.ts +24 -0
  98. package/dist/communication/session.d.ts.map +1 -0
  99. package/dist/communication/session.js +100 -0
  100. package/dist/communication/session.js.map +1 -0
  101. package/dist/communication/spec.d.ts +20 -0
  102. package/dist/communication/spec.d.ts.map +1 -0
  103. package/dist/communication/spec.js +113 -0
  104. package/dist/communication/spec.js.map +1 -0
  105. package/dist/communication/tool.d.ts +3 -0
  106. package/dist/communication/tool.d.ts.map +1 -0
  107. package/dist/communication/tool.js +23 -0
  108. package/dist/communication/tool.js.map +1 -0
  109. package/dist/communication/tools.d.ts +11 -0
  110. package/dist/communication/tools.d.ts.map +1 -0
  111. package/dist/communication/tools.js +65 -0
  112. package/dist/communication/tools.js.map +1 -0
  113. package/dist/communication/worker.d.ts +6 -0
  114. package/dist/communication/worker.d.ts.map +1 -0
  115. package/dist/communication/worker.js +120 -0
  116. package/dist/communication/worker.js.map +1 -0
  117. package/dist/compile.d.ts.map +1 -1
  118. package/dist/compile.js +8 -1
  119. package/dist/compile.js.map +1 -1
  120. package/dist/failure-kinds.d.ts +16 -1
  121. package/dist/failure-kinds.d.ts.map +1 -1
  122. package/dist/failure-kinds.js +1 -0
  123. package/dist/failure-kinds.js.map +1 -1
  124. package/dist/index.d.ts +3 -1
  125. package/dist/index.d.ts.map +1 -1
  126. package/dist/index.js +2 -0
  127. package/dist/index.js.map +1 -1
  128. package/dist/journal-client.d.ts +4 -1
  129. package/dist/journal-client.d.ts.map +1 -1
  130. package/dist/journal-client.js +11 -2
  131. package/dist/journal-client.js.map +1 -1
  132. package/dist/preflight.d.ts +13 -2
  133. package/dist/preflight.d.ts.map +1 -1
  134. package/dist/preflight.js +23 -6
  135. package/dist/preflight.js.map +1 -1
  136. package/dist/protocol.d.ts +39 -1
  137. package/dist/protocol.d.ts.map +1 -1
  138. package/dist/spec.d.ts +8 -0
  139. package/dist/spec.d.ts.map +1 -1
  140. package/dist/spec.js.map +1 -1
  141. package/dist/worker-cli.d.ts.map +1 -1
  142. package/dist/worker-cli.js +29 -50
  143. package/dist/worker-cli.js.map +1 -1
  144. package/dist/worker.d.ts +1 -0
  145. package/dist/worker.d.ts.map +1 -1
  146. package/dist/worker.js +10 -1
  147. package/dist/worker.js.map +1 -1
  148. package/dist/wrapper-session.d.ts +8 -0
  149. package/dist/wrapper-session.d.ts.map +1 -1
  150. package/dist/wrapper-session.js +156 -23
  151. package/dist/wrapper-session.js.map +1 -1
  152. package/package.json +14 -2
  153. package/src/agent-artifacts.ts +27 -11
  154. package/src/authored-flow-error.ts +17 -0
  155. package/src/authored-flow-executor.ts +4 -1
  156. package/src/authored-node-entry.ts +5 -1
  157. package/src/authored-node-runner.ts +33 -1
  158. package/src/authored-root.ts +2 -1
  159. package/src/authored-source-authority.ts +13 -0
  160. package/src/authored-step-index.ts +157 -0
  161. package/src/authored-step-output.ts +66 -14
  162. package/src/authored-worker-step.ts +88 -15
  163. package/src/cli/check.ts +69 -11
  164. package/src/cli/cloud-read.ts +518 -0
  165. package/src/cli/run.ts +43 -25
  166. package/src/cli/status.ts +20 -0
  167. package/src/cli/step-failure.ts +68 -3
  168. package/src/cli-commands.ts +23 -1
  169. package/src/cli.ts +20 -1
  170. package/src/cloud-http.ts +32 -6
  171. package/src/cloud-read.ts +515 -0
  172. package/src/cloud-transcript.ts +422 -0
  173. package/src/communication/environment.ts +33 -0
  174. package/src/communication/history.ts +38 -0
  175. package/src/communication/local.ts +41 -0
  176. package/src/communication/preflight.ts +34 -0
  177. package/src/communication/projection.ts +23 -0
  178. package/src/communication/relay.ts +102 -0
  179. package/src/communication/session.ts +84 -0
  180. package/src/communication/spec.ts +106 -0
  181. package/src/communication/tool.ts +22 -0
  182. package/src/communication/tools.ts +50 -0
  183. package/src/communication/worker.ts +95 -0
  184. package/src/compile.ts +4 -1
  185. package/src/failure-kinds.ts +16 -0
  186. package/src/index.ts +10 -1
  187. package/src/journal-client.ts +12 -2
  188. package/src/preflight.ts +36 -7
  189. package/src/protocol.ts +15 -0
  190. package/src/spec.ts +2 -0
  191. package/src/worker-cli.ts +28 -47
  192. package/src/worker.ts +10 -0
  193. package/src/wrapper-session.ts +159 -24
package/src/preflight.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { communicationInstruction } from './communication/spec.js';
1
2
  import { loadPlugins, type LoadedPlugin } from './plugin-loader.js';
2
3
  import { PluginError } from './plugin-manifest.js';
3
4
  import type { FlowSpec, StepSpec, TriggerSpec, McpServerConfig } from './spec.js';
@@ -32,13 +33,24 @@ export interface CliResolution {
32
33
 
33
34
  export interface CliProbeResult {
34
35
  exists: boolean;
35
- authenticated: boolean;
36
+ authenticated: boolean | 'unverified';
36
37
  /** False when a custom executable did not identify as a wrapper adapter. */
37
38
  supported?: boolean;
38
39
  /** Exact declared model passed the CLI's model-scoped readiness probe. */
39
40
  modelAvailable?: boolean;
40
41
  authCommand?: string;
41
42
  modelCommand?: string;
43
+ /** Exit code of the authentication probe, when it failed. */
44
+ authExitCode?: number | null;
45
+ /**
46
+ * Redacted output of a FAILED authentication probe.
47
+ *
48
+ * Present only on failure. Without it a `cli_unauthenticated` refusal can
49
+ * state that a probe exited non-zero and never what it said, which makes an
50
+ * intermittent probe failure impossible to tell apart from a genuinely
51
+ * unauthenticated CLI.
52
+ */
53
+ authFailureDetail?: string;
42
54
  }
43
55
 
44
56
  export type CliProbeFailureDetail =
@@ -70,7 +82,7 @@ export interface PreflightProbes {
70
82
  * model in scope, so readiness answers "can this CLI use THIS model" rather
71
83
  * than the weaker "is this CLI authenticated at all".
72
84
  */
73
- cli(cli: string, source: CliResolutionSource, model?: string): CliProbeResult;
85
+ cli(cli: string, source: CliResolutionSource, model?: string, execution?: 'managed'): CliProbeResult;
74
86
  executor(trigger: TriggerSpec): boolean;
75
87
  command(binary: string): boolean;
76
88
  }
@@ -292,7 +304,8 @@ function preflightSync(flow: unknown, options: PreflightOptions): PreflightResul
292
304
  continue;
293
305
  }
294
306
  const resolution = resolutionByStep.get(step.id)!;
295
- probeResolvedCli(resolution, options.probes, cliProbeResults, diagnostics);
307
+ probeResolvedCli(resolution, options.probes, cliProbeResults, diagnostics,
308
+ step.type === 'agent' && communicationInstruction(step.instruction) !== undefined);
296
309
  }
297
310
 
298
311
  for (const trigger of compiled.triggers ?? []) {
@@ -453,6 +466,7 @@ function probeResolvedCli(
453
466
  probes: PreflightProbes,
454
467
  cache: Map<string, CliProbeOutcome>,
455
468
  diagnostics: PreflightDiagnostic[],
469
+ managed = false,
456
470
  ): void {
457
471
  // Source is load-bearing: the same relative CLI string resolves from the
458
472
  // flow directory for step/named/flow declarations and the config directory for
@@ -460,11 +474,13 @@ function probeResolvedCli(
460
474
  // Model is part of the key: the same CLI probed with two different models
461
475
  // is two different questions, and caching on the CLI alone would let a
462
476
  // model that the CLI cannot resolve inherit an earlier model's pass.
463
- const cacheKey = JSON.stringify([resolution.cli, resolution.source, resolution.model ?? null]);
477
+ const cacheKey = JSON.stringify([resolution.cli, resolution.source, resolution.model ?? null, managed]);
464
478
  let outcome = cache.get(cacheKey);
465
479
  if (outcome === undefined) {
466
480
  try {
467
- outcome = { result: probes.cli(resolution.cli, resolution.source, resolution.model) };
481
+ outcome = { result: managed
482
+ ? probes.cli(resolution.cli, resolution.source, resolution.model, 'managed')
483
+ : probes.cli(resolution.cli, resolution.source, resolution.model) };
468
484
  } catch (error) {
469
485
  const detail = error instanceof CliProbeError ? error.detail : undefined;
470
486
  outcome = { failure: detail ?? null };
@@ -500,14 +516,27 @@ function probeResolvedCli(
500
516
  cli: resolution.cli,
501
517
  message: `Step "${resolution.stepId}" declares CLI "${resolution.cli}", but it is neither a supported raw Claude/Codex executable nor a conforming Relayflows wrapper; custom wrappers must identify with the relayflows-agent-cli-v1 contract.`,
502
518
  });
503
- } else if (!result.authenticated) {
519
+ } else if (managed && result.authenticated === 'unverified') {
520
+ diagnostics.push({ severity: 'warning', kind: 'managed_cli_unverified', stepId: resolution.stepId,
521
+ message: `Step "${resolution.stepId}" uses Relay's interactive CLI transport. The executable exists, but authentication${resolution.model ? ' and access to model "' + resolution.model + '"' : ''} could not be verified before startup. The managed session must execute its task and report completion; startup or task failure fails the step.` });
522
+ } else if (result.authenticated !== true) {
504
523
  const command = result.authCommand ?? `${resolution.cli} auth status`;
524
+ // Say what the probe reported. A refusal that names only the command turns
525
+ // a transient provider rejection and a genuinely unauthenticated CLI into
526
+ // the same message, and the difference decides whether retrying is
527
+ // correct.
528
+ const exitCode = result.authExitCode === undefined || result.authExitCode === null
529
+ ? ''
530
+ : ` (exit ${result.authExitCode})`;
531
+ const detail = result.authFailureDetail !== undefined && result.authFailureDetail.length > 0
532
+ ? ` It reported: ${result.authFailureDetail}`
533
+ : ' It produced no output, so the reason is unavailable.';
505
534
  diagnostics.push({
506
535
  severity: 'refusal',
507
536
  kind: 'cli_unauthenticated',
508
537
  stepId: resolution.stepId,
509
538
  cli: resolution.cli,
510
- message: `Step "${resolution.stepId}" declares CLI "${resolution.cli}", but "${command}" exited non-zero; authenticate it or repair that adapter's authentication probe.`,
539
+ message: `Step "${resolution.stepId}" declares CLI "${resolution.cli}", but "${command}" exited non-zero${exitCode}; authenticate it or repair that adapter's authentication probe.${detail}`,
511
540
  });
512
541
  } else if (resolution.model !== undefined && result.modelAvailable !== true) {
513
542
  diagnostics.push({
package/src/protocol.ts CHANGED
@@ -55,6 +55,9 @@ export type Verb =
55
55
  | 'step.wait'
56
56
  | 'event.emit'
57
57
  | 'event.submit'
58
+ | 'channel.append'
59
+ | 'channel.receive'
60
+ | 'channel.ack'
58
61
  | 'stream.append'
59
62
  | 'stream.read'
60
63
  | 'journal.read';
@@ -160,6 +163,8 @@ export interface WorkerAttachParams {
160
163
  step_types: StepType[];
161
164
  /** Maximum concurrent assignments. Omitted means the conservative default 1. */
162
165
  capacity?: number;
166
+ /** Accept only agent steps declaring every named stream (must also be pinned). */
167
+ required_streams?: string[];
163
168
  /**
164
169
  * The surfaces this worker holds, as opaque revisions/offsets. Required when
165
170
  * `step_types` includes `agent` — an agent attempt's start pins come from
@@ -411,7 +416,17 @@ export interface JournalReadResult {
411
416
  }
412
417
 
413
418
  /** Typed map of verb -> { params, result }. Used by the client for type-safety. */
419
+ export interface ChannelIdentity {
420
+ run_id: string; step_id: string; attempt: number; idempotency_key: string; channel: string;
421
+ }
422
+ export interface ChannelEntry {
423
+ seq: number;
424
+ payload: { channel: string; offset: number; message?: unknown; producer?: string; consumer?: string; message_id?: string; delivery_seq?: number };
425
+ }
414
426
  export interface VerbContract {
427
+ 'channel.append': { params: ChannelIdentity & { message_id: string; message: unknown }; result: ChannelEntry };
428
+ 'channel.receive': { params: ChannelIdentity; result: ChannelEntry | null };
429
+ 'channel.ack': { params: ChannelIdentity & { delivery_seq: number }; result: ChannelEntry };
415
430
  hello: { params: HelloParams; result: HelloResult };
416
431
  'run.start': { params: RunStartParams; result: RunStartResult };
417
432
  'run.resume': { params: RunResumeParams; result: RunResumeResult };
package/src/spec.ts CHANGED
@@ -374,6 +374,8 @@ export interface TriggerSpec {
374
374
  * is this spec mapped to the kernel dialect (`toKernelSpec`).
375
375
  */
376
376
  export interface FlowSpec {
377
+ /** Opt-in live messaging between independent agent steps; compiled into journal channels. */
378
+ communication?: { links: Array<{ from: string; to: string }>; timeoutMs?: number };
377
379
  /** Spec schema semver (RFC §7). Compilers always emit latest. */
378
380
  version: string;
379
381
  name?: string;
package/src/worker-cli.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { realpathSync } from 'node:fs';
2
- import { resolve } from 'node:path';
2
+ import { 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';
@@ -23,7 +23,7 @@ import {
23
23
  import { wrapperEnvironment } from './wrapper-runtime.js';
24
24
  import { redactRelayError } from './redact.js';
25
25
  import { applyStepEnvironment } from './step-env.js';
26
- import { TAIL_CLOSE_TIMEOUT_MS, openTranscriptTail, transcriptTailPath, type TranscriptTailWriter } from './transcript-tail.js';
26
+ import { TAIL_CLOSE_TIMEOUT_MS, openTranscriptTail, type TranscriptTailWriter } from './transcript-tail.js';
27
27
  import {
28
28
  runAgentRelayTask,
29
29
  AgentRelayTransportError,
@@ -110,28 +110,23 @@ export async function runAgentCli(
110
110
  // serialized around their snapshot-spawn-snapshot interval, so one agent's
111
111
  // writes are never attributed to a concurrent one in the same directory.
112
112
  //
113
- // This process writes its own evidence during that interval — the attempt's
114
- // transcript file and the two `flows status --tail` tails — and when the data
115
- // dir sits under the cwd (a local `--data-dir` inside the project) they all
116
- // land inside the scanned tree. None of them is agent-authored content, so
117
- // every one is dropped from the diff by path. Missing any of them reports
118
- // our own bookkeeping back to the kernel as the step's artifacts.
113
+ // Bookkeeping written during that interval is not agent-authored content,
114
+ // and when the data dir sits under the cwd (a local `--data-dir` inside the
115
+ // project) all of it lands inside the scanned tree: this process's own
116
+ // transcript file and two `flows status --tail` tails, and the run journals
117
+ // the daemon appends to while the agent runs. So the whole data dir is
118
+ // dropped from the diff. Anything left in reports the kernel's own state
119
+ // back to the kernel as the step's artifacts, where an `artifact_exists`
120
+ // gate then reads it.
119
121
  const artifactRoot = mode === 'agent' ? resolve(cwd ?? process.cwd()) : undefined;
120
122
  return artifactRoot === undefined
121
123
  ? execute()
122
124
  : serializedByDirectory(artifactRoot, async () => {
123
125
  const before = await snapshotWorkspaceFiles(artifactRoot);
124
126
  const result = await execute();
125
- const ours = new Set<string>();
126
- for (const path of ownEvidencePaths(sidechannel)) {
127
- const real = realPath(path);
128
- if (real !== undefined) ours.add(real);
129
- }
127
+ const kernelData = sidechannel === undefined ? undefined : realPath(resolve(sidechannel.dataDir));
130
128
  const artifacts = diffWorkspaceFiles(before, await snapshotWorkspaceFiles(artifactRoot))
131
- .filter(path => {
132
- const real = realPath(resolve(artifactRoot, path));
133
- return real === undefined || !ours.has(real);
134
- });
129
+ .filter(path => !under(realPath(resolve(artifactRoot, path)), kernelData));
135
130
  return { ...result, artifacts };
136
131
  });
137
132
 
@@ -198,39 +193,25 @@ function openTails(context: SidechannelContext): { stdout: TranscriptTailWriter;
198
193
  }
199
194
 
200
195
  /**
201
- * Every path this process may write under the workspace during an attempt, so
202
- * the artifact diff can drop all of them.
196
+ * Whether `path` is `directory` or anything beneath it, both symlink-free.
203
197
  *
204
- * Derived from the attempt identity — the same input `openTranscriptWriter` and
205
- * `openTails` use to decide where to write — and never from the result. The
206
- * transcript pointer on the result is optional: `finish` drops it when the
207
- * close outruns `TAIL_CLOSE_TIMEOUT_MS` and `discardTranscript` drops it on
208
- * abort, and on both paths the file is already on disk. An exclusion built from
209
- * that pointer therefore lost the transcript exactly when the close was slow,
210
- * which is the moment the file is most likely to still be there.
198
+ * The artifact exclusion is a subtree, not a list of names, because the files
199
+ * to exclude are not all knowable in advance. The attempt's own transcript and
200
+ * tails are (they derive from the attempt identity), but the daemon decides on
201
+ * its own when to write which run journal, and a journal appended to inside
202
+ * the snapshot interval is indistinguishable in a diff from a file the agent
203
+ * wrote. Excluding the directory covers both, and covers them without reading
204
+ * the result: the transcript pointer there is optional — `finish` drops it
205
+ * when the close outruns `TAIL_CLOSE_TIMEOUT_MS` and `discardTranscript` drops
206
+ * it on abort, while the file stays on disk — so an exclusion built from it
207
+ * lost the transcript exactly when the close was slow (flows#495).
211
208
  *
212
- * Empty when there is no sidechannel or no attempt to name, which is exactly
213
- * when nothing is written.
209
+ * `false` when there is no data dir to exclude, which is exactly when this
210
+ * process has nowhere to write.
214
211
  */
215
- function ownEvidencePaths(context: SidechannelContext | undefined): string[] {
216
- const attempt = context?.attempt;
217
- if (context === undefined || attempt === undefined) return [];
218
- const identity = { dataDir: context.dataDir, runId: context.runId, stepId: context.stepId, attempt };
219
- try {
220
- // For each tail, both the finished file and the `.tmp` the writer stages
221
- // and renames over (`transcript-tail.ts`): a close that timed out or a
222
- // flush that failed can leave the staging file behind, and it is no more
223
- // agent-authored than the file it was going to become.
224
- const paths = [transcriptPath(identity, attempt)];
225
- for (const stream of ['stdout', 'stderr'] as const) {
226
- const tail = transcriptTailPath(identity, stream);
227
- paths.push(tail, `${tail}.tmp`);
228
- }
229
- return paths;
230
- } catch {
231
- // An id the path cannot carry; nothing was written under it either.
232
- return [];
233
- }
212
+ function under(path: string | undefined, directory: string | undefined): boolean {
213
+ if (path === undefined || directory === undefined) return false;
214
+ return path === directory || path.startsWith(directory.endsWith(sep) ? directory : directory + sep);
234
215
  }
235
216
 
236
217
  /** Symlink-free form of a path, or the path itself when it cannot be resolved. */
package/src/worker.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { communicationInstruction } from './communication/spec.js';
1
2
  import { workerSpend } from './worker-spend.js';
2
3
  import type { WorkerCliResult } from './worker-cli.js';
3
4
  import { EventEmitter } from 'node:events';
@@ -18,6 +19,7 @@ export interface AgentWorkerOptions {
18
19
  workerId: string;
19
20
  pins: Pins;
20
21
  capacity?: number;
22
+ requiredStreams?: string[];
21
23
  dataDir?: string;
22
24
  onPtyReady?: (path: string) => void;
23
25
  }
@@ -60,6 +62,7 @@ export class AgentWorker extends EventEmitter {
60
62
  ['agent'],
61
63
  this.options.pins,
62
64
  this.options.capacity,
65
+ this.options.requiredStreams,
63
66
  );
64
67
  this.attached = true;
65
68
  } catch (error) {
@@ -106,6 +109,13 @@ export class AgentWorker extends EventEmitter {
106
109
  await completeHelperDispatch(this.client, dispatch, helper, this.options.dataDir);
107
110
  return;
108
111
  }
112
+ const communication = communicationInstruction(spec.instruction);
113
+ if (communication) {
114
+ if (!this.options.dataDir) throw new Error('Agent communication requires a worker data directory');
115
+ const { completeCommunicationDispatch } = await import('./communication/worker.js');
116
+ await completeCommunicationDispatch(this.client, dispatch, communication, this.options.dataDir);
117
+ return;
118
+ }
109
119
  let humanIntervention = false;
110
120
  const effectiveModel = typeof spec.cli === 'string' ? resolveCliModel(spec.cli, spec.model) : spec.model;
111
121
  const completed: WorkerCliResult = await withWorkerLease(this.client, dispatch, signal =>
@@ -13,6 +13,14 @@ import {
13
13
 
14
14
  export interface WrapperSessionLimits {
15
15
  handshakeTimeoutMs: number;
16
+ /**
17
+ * How long the wrapper may work after acknowledging the request, or `0` for
18
+ * no deadline at all — the same convention `CliInvocation.timeoutMs` already
19
+ * uses for a native CLI spawn, where `worker-cli.ts` arms its timer only
20
+ * `if (invocation.timeoutMs > 0)`. `0` is the default, so a wrapper-backed
21
+ * step is bounded by the lease abort exactly as a `claude` or `codex` step
22
+ * is, and not by a constant neither of those pays.
23
+ */
16
24
  executionTimeoutMs: number;
17
25
  maxOutputBytes: number;
18
26
  }
@@ -25,7 +33,7 @@ export interface WrapperSessionResult {
25
33
 
26
34
  const DEFAULT_LIMITS: WrapperSessionLimits = {
27
35
  handshakeTimeoutMs: 10_000,
28
- executionTimeoutMs: 300_000,
36
+ executionTimeoutMs: 0,
29
37
  maxOutputBytes: 1_048_576,
30
38
  };
31
39
  const HANDSHAKE_OUTPUT_LIMIT = 8_192;
@@ -36,6 +44,19 @@ const HANDSHAKE_OUTPUT_LIMIT = 8_192;
36
44
  * not depend on `'close'`: past this point the reader settles regardless.
37
45
  */
38
46
  const SETTLE_AFTER_KILL_MS = 250;
47
+ /**
48
+ * How long a session with NO execution deadline waits, after the wrapper
49
+ * process itself is gone, for its stdio to finish draining.
50
+ *
51
+ * The execution deadline used to be what settled a wrapper that exited while a
52
+ * descendant still held an inherited pipe, because `'close'` waits on every
53
+ * one of them. That job is real; keying it on elapsed time was not. It is
54
+ * keyed on the wrapper's own exit instead, which is the fact that actually
55
+ * matters. Reaching the end of this grace is not proof that a pipe was leaked
56
+ * — buffered output may simply still be in flight — so the grace is a policy
57
+ * and the stop that follows it is bounded, not a diagnosis.
58
+ */
59
+ const DRAIN_AFTER_EXIT_MS = 250;
39
60
 
40
61
  /**
41
62
  * Identify and execute a custom wrapper in one pinned process. No private
@@ -108,14 +129,29 @@ function executePinnedWrapper(
108
129
  let phase: 'identity' | 'ack' | 'execute' = 'identity';
109
130
  let protocolError: string | undefined;
110
131
  let settled = false;
132
+ let outputFinalized = false;
133
+ let childExited = false;
134
+ let exitCode: number | null = null;
135
+ /** The handshake deadline, then the execution deadline if there is one. */
111
136
  let lifecycleTimer: NodeJS.Timeout | undefined;
137
+ /** `terminate`'s own settle deadline. */
112
138
  let settleTimer: NodeJS.Timeout | undefined;
139
+ /** The post-exit drain grace, armed only when execution is unlimited. */
140
+ let drainTimer: NodeJS.Timeout | undefined;
141
+ /** The settle deadline the drain's own stop owes, after its escalation. */
142
+ let drainSettleTimer: NodeJS.Timeout | undefined;
143
+ // Each deadline owns its own handle. They can be armed at the same time —
144
+ // a drain grace can be running when an over-limit final line terminates
145
+ // the session — and a shared variable would drop the only reference to
146
+ // one of them and leave it pending past the settle.
113
147
 
114
148
  const finish = (result: WrapperSessionResult): void => {
115
149
  if (settled) return;
116
150
  settled = true;
117
151
  if (lifecycleTimer !== undefined) clearTimeout(lifecycleTimer);
118
152
  if (settleTimer !== undefined) clearTimeout(settleTimer);
153
+ if (drainTimer !== undefined) clearTimeout(drainTimer);
154
+ if (drainSettleTimer !== undefined) clearTimeout(drainSettleTimer);
119
155
  signal?.removeEventListener('abort', onAbort);
120
156
  resolve(result);
121
157
  };
@@ -152,6 +188,12 @@ function executePinnedWrapper(
152
188
  if (protocolError !== undefined) return;
153
189
  protocolError = message;
154
190
  if (lifecycleTimer !== undefined) clearTimeout(lifecycleTimer);
191
+ lifecycleTimer = undefined;
192
+ // A refusal wins over a drain that was going to report the wrapper's own
193
+ // exit as a success: from here this settle belongs to the deadline armed
194
+ // below, and a drain that still fired would stop the tree a second time.
195
+ if (drainTimer !== undefined) clearTimeout(drainTimer);
196
+ drainTimer = undefined;
155
197
  // Same reach as an abort, only gentler first: this stop must find the
156
198
  // whole group, or a descendant outlives the session still holding the
157
199
  // stdio it inherited.
@@ -171,6 +213,13 @@ function executePinnedWrapper(
171
213
  };
172
214
  const startExecutionTimer = (): void => {
173
215
  if (lifecycleTimer !== undefined) clearTimeout(lifecycleTimer);
216
+ lifecycleTimer = undefined;
217
+ // `0` is no deadline, exactly as `worker-cli.ts` reads
218
+ // `invocation.timeoutMs` for a native CLI. The handshake deadline it
219
+ // just replaced was a liveness check and is over; nothing else is armed,
220
+ // and what bounds the work from here is the lease abort — the same thing
221
+ // that bounds a `claude` or `codex` step.
222
+ if (limits.executionTimeoutMs <= 0) return;
174
223
  lifecycleTimer = setTimeout(() => terminate(
175
224
  `CLI ${JSON.stringify(cli)} execution timed out after ${limits.executionTimeoutMs}ms.`,
176
225
  ), limits.executionTimeoutMs);
@@ -210,6 +259,12 @@ function executePinnedWrapper(
210
259
  }
211
260
  phase = 'execute';
212
261
  startExecutionTimer();
262
+ // The wrapper may already be gone: `'exit'` is delivered as soon as the
263
+ // process dies, while the bytes it wrote are still being read out of
264
+ // the pipe. The exit is remembered rather than acted on, and the drain
265
+ // grace starts here, once the acknowledgement it waits behind has
266
+ // actually been consumed.
267
+ startDrainGrace();
213
268
  }
214
269
  };
215
270
  const acceptExecutionData = (chunk: string): void => {
@@ -228,6 +283,83 @@ function executePinnedWrapper(
228
283
  }
229
284
  };
230
285
  const handshakeComplete = (): boolean => phase === 'execute';
286
+ /**
287
+ * Fold the last un-terminated line into the captured output, exactly once.
288
+ *
289
+ * Two settles can now reach this — `'close'` and the drain grace — and the
290
+ * pending buffer may only be spent by one of them. A second finalization
291
+ * that appended it again would duplicate the wrapper's own result, and a
292
+ * buffer left in place would go on being counted against `maxOutputBytes`
293
+ * by any later chunk.
294
+ */
295
+ const finalizeOutput = (): void => {
296
+ if (outputFinalized) return;
297
+ outputFinalized = true;
298
+ if (protocolError === undefined && phase === 'execute' && executionPending.length > 0) {
299
+ if (normalizeLine(executionPending) === WRAPPER_EXECUTE_TOKEN) {
300
+ protocolError = `CLI ${JSON.stringify(cli)} emitted a duplicate execute protocol frame.`;
301
+ } else if (!exceedsOutputLimit(0)) {
302
+ appendStdout(executionPending);
303
+ }
304
+ }
305
+ executionPending = '';
306
+ };
307
+ /**
308
+ * The one result builder. A recorded protocol or output failure outranks
309
+ * the exit status: a wrapper that violated the contract is refused even
310
+ * when the process it ran in went on to exit 0.
311
+ */
312
+ const executionResult = (code: number | null): WrapperSessionResult => {
313
+ finalizeOutput();
314
+ if (protocolError !== undefined || phase !== 'execute') {
315
+ return failure(
316
+ protocolError
317
+ ?? `CLI ${JSON.stringify(cli)} exited before completing the ${WRAPPER_IDENTIFY_TOKEN} same-process handshake.`,
318
+ );
319
+ }
320
+ return {
321
+ exit_code: code,
322
+ stdout_tail: stdout.join(''),
323
+ stderr_tail: Buffer.concat(stderr).toString('utf8'),
324
+ };
325
+ };
326
+ const drainExpired = (): void => {
327
+ drainTimer = undefined;
328
+ if (settled) return;
329
+ const result = executionResult(exitCode);
330
+ // Finalizing can itself refuse the session — an over-limit final line —
331
+ // and `terminate` has then already stopped the tree and armed its own
332
+ // settle deadline. Stopping or arming a second time here would leave two
333
+ // deadlines racing for one settle.
334
+ if (settleTimer !== undefined) return;
335
+ // The wrapper is gone and `'close'` has still not arrived, so something
336
+ // it left behind is holding stdio it inherited. Stop what is reachable —
337
+ // a settle that releases no pipes lets the step complete while
338
+ // `flows run` never exits — and then own the settle regardless: the
339
+ // force kill has no callback, and a descendant that escaped into its own
340
+ // group before the wrapper died is not traceable to this spawn at all.
341
+ stop.terminate();
342
+ drainSettleTimer = setTimeout(
343
+ () => finish(executionResult(exitCode)),
344
+ FORCE_KILL_DELAY_MS + SETTLE_AFTER_KILL_MS,
345
+ );
346
+ // Settle now if the stop turned out to have nothing to reach; otherwise
347
+ // the deadline above owns it, and a `'close'` that arrives once the
348
+ // group is empty may still settle earlier.
349
+ finishOnChildExit(result);
350
+ };
351
+ /**
352
+ * Arm the post-exit drain, for an unlimited session that has both seen the
353
+ * wrapper exit and consumed its acknowledgement. A session with an explicit
354
+ * execution deadline keeps its existing behaviour: that deadline is what
355
+ * bounds a withheld `'close'` there, and nothing about it changes.
356
+ */
357
+ const startDrainGrace = (): void => {
358
+ if (limits.executionTimeoutMs > 0) return;
359
+ if (!childExited || phase !== 'execute') return;
360
+ if (settled || drainTimer !== undefined || protocolError !== undefined) return;
361
+ drainTimer = setTimeout(drainExpired, DRAIN_AFTER_EXIT_MS);
362
+ };
231
363
 
232
364
  lifecycleTimer = setTimeout(() => terminate(
233
365
  `CLI ${JSON.stringify(cli)} did not identify as ${WRAPPER_IDENTIFY_TOKEN} within ${limits.handshakeTimeoutMs}ms.`,
@@ -235,7 +367,11 @@ function executePinnedWrapper(
235
367
 
236
368
  child.stdout.setEncoding('utf8');
237
369
  child.stdout.on('data', (chunk: string) => {
238
- if (protocolError !== undefined) return;
370
+ // Once the output has been finalized the result is built and the
371
+ // captured bytes are spent. A late chunk — the drain grace has expired
372
+ // and a descendant still owns the pipe — may neither be appended to a
373
+ // result already reported nor re-judged against a limit already applied.
374
+ if (protocolError !== undefined || outputFinalized) return;
239
375
  if (phase === 'execute') {
240
376
  acceptExecutionData(chunk);
241
377
  return;
@@ -273,7 +409,7 @@ function executePinnedWrapper(
273
409
  }
274
410
  });
275
411
  child.stderr.on('data', (chunk: Buffer) => {
276
- if (protocolError !== undefined || exceedsOutputLimit(chunk.byteLength)) return;
412
+ if (protocolError !== undefined || outputFinalized || exceedsOutputLimit(chunk.byteLength)) return;
277
413
  capturedBytes += chunk.byteLength;
278
414
  stderr.push(chunk);
279
415
  });
@@ -281,42 +417,41 @@ function executePinnedWrapper(
281
417
  // A child that closes stdin before acknowledgement is classified on close.
282
418
  });
283
419
  child.once('error', (error) => finishOnChildExit(failure(error.message)));
284
- child.once('close', (code) => {
285
- if (protocolError === undefined && phase === 'execute' && executionPending.length > 0) {
286
- if (normalizeLine(executionPending) === WRAPPER_EXECUTE_TOKEN) {
287
- protocolError = `CLI ${JSON.stringify(cli)} emitted a duplicate execute protocol frame.`;
288
- } else if (!exceedsOutputLimit(0)) {
289
- appendStdout(executionPending);
290
- }
291
- }
292
- if (protocolError !== undefined || phase !== 'execute') {
293
- finishOnChildExit(failure(
294
- protocolError
295
- ?? `CLI ${JSON.stringify(cli)} exited before completing the ${WRAPPER_IDENTIFY_TOKEN} same-process handshake.`,
296
- ));
297
- return;
298
- }
299
- finishOnChildExit({
300
- exit_code: code,
301
- stdout_tail: stdout.join(''),
302
- stderr_tail: Buffer.concat(stderr).toString('utf8'),
303
- });
420
+ // `'exit'` is evidence about the DIRECT CHILD only: the wrapper process is
421
+ // gone, whether or not anything it spawned still holds the stdio it
422
+ // inherited. That is exactly the fact the drain grace waits on, and it is
423
+ // not evidence of a dead group — nothing here settles or stops.
424
+ child.once('exit', (code) => {
425
+ childExited = true;
426
+ exitCode = code;
427
+ startDrainGrace();
304
428
  });
429
+ child.once('close', (code) => finishOnChildExit(executionResult(code)));
305
430
  });
306
431
  }
307
432
 
308
433
  function sessionLimits(overrides: Partial<WrapperSessionLimits>): WrapperSessionLimits {
309
434
  return {
310
435
  handshakeTimeoutMs: positiveLimit(overrides.handshakeTimeoutMs, DEFAULT_LIMITS.handshakeTimeoutMs),
311
- executionTimeoutMs: positiveLimit(overrides.executionTimeoutMs, DEFAULT_LIMITS.executionTimeoutMs),
436
+ executionTimeoutMs: durationLimit(overrides.executionTimeoutMs, DEFAULT_LIMITS.executionTimeoutMs),
312
437
  maxOutputBytes: positiveLimit(overrides.maxOutputBytes, DEFAULT_LIMITS.maxOutputBytes),
313
438
  };
314
439
  }
315
440
 
441
+ /** A bound a wrapper may not switch off: `0` and below fall back. */
316
442
  function positiveLimit(value: number | undefined, fallback: number): number {
317
443
  return value !== undefined && Number.isSafeInteger(value) && value > 0 ? value : fallback;
318
444
  }
319
445
 
446
+ /**
447
+ * A duration that may be switched off, so `0` is a value and not a missing
448
+ * one. Reusing {@link positiveLimit} here would coerce an explicit "no
449
+ * deadline" back to the fallback, which is how the default became unreachable.
450
+ */
451
+ function durationLimit(value: number | undefined, fallback: number): number {
452
+ return value !== undefined && Number.isSafeInteger(value) && value >= 0 ? value : fallback;
453
+ }
454
+
320
455
  function normalizeLine(line: string): string {
321
456
  return line.endsWith('\r') ? line.slice(0, -1) : line;
322
457
  }