@relayflows/sdk 2.0.26 → 2.0.28

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 (138) hide show
  1. package/dist/authored-flow-executor.d.ts +8 -1
  2. package/dist/authored-flow-executor.d.ts.map +1 -1
  3. package/dist/authored-flow-executor.js +16 -7
  4. package/dist/authored-flow-executor.js.map +1 -1
  5. package/dist/authored-flow-lifecycle.d.ts +68 -1
  6. package/dist/authored-flow-lifecycle.d.ts.map +1 -1
  7. package/dist/authored-flow-lifecycle.js +108 -10
  8. package/dist/authored-flow-lifecycle.js.map +1 -1
  9. package/dist/authored-flow-operation.d.ts +9 -1
  10. package/dist/authored-flow-operation.d.ts.map +1 -1
  11. package/dist/authored-flow-operation.js +7 -1
  12. package/dist/authored-flow-operation.js.map +1 -1
  13. package/dist/authored-node-entry.js +4 -0
  14. package/dist/authored-node-entry.js.map +1 -1
  15. package/dist/authored-node-runner.d.ts.map +1 -1
  16. package/dist/authored-node-runner.js +2 -1
  17. package/dist/authored-node-runner.js.map +1 -1
  18. package/dist/authored-promise-graph.d.ts +19 -4
  19. package/dist/authored-promise-graph.d.ts.map +1 -1
  20. package/dist/authored-promise-graph.js +44 -19
  21. package/dist/authored-promise-graph.js.map +1 -1
  22. package/dist/authored-root.d.ts +2 -0
  23. package/dist/authored-root.d.ts.map +1 -1
  24. package/dist/authored-root.js +2 -0
  25. package/dist/authored-root.js.map +1 -1
  26. package/dist/authored-step-graph.d.ts +83 -0
  27. package/dist/authored-step-graph.d.ts.map +1 -0
  28. package/dist/authored-step-graph.js +217 -0
  29. package/dist/authored-step-graph.js.map +1 -0
  30. package/dist/authored-step-index.d.ts +30 -0
  31. package/dist/authored-step-index.d.ts.map +1 -1
  32. package/dist/authored-step-index.js +26 -1
  33. package/dist/authored-step-index.js.map +1 -1
  34. package/dist/authored-step-output.d.ts +3 -0
  35. package/dist/authored-step-output.d.ts.map +1 -1
  36. package/dist/authored-step-output.js +4 -2
  37. package/dist/authored-step-output.js.map +1 -1
  38. package/dist/authored-worker-step.d.ts +3 -1
  39. package/dist/authored-worker-step.d.ts.map +1 -1
  40. package/dist/authored-worker-step.js +25 -6
  41. package/dist/authored-worker-step.js.map +1 -1
  42. package/dist/cli/direct-run.d.ts.map +1 -1
  43. package/dist/cli/direct-run.js +5 -2
  44. package/dist/cli/direct-run.js.map +1 -1
  45. package/dist/cli/run.d.ts +2 -0
  46. package/dist/cli/run.d.ts.map +1 -1
  47. package/dist/cli/run.js +9 -5
  48. package/dist/cli/run.js.map +1 -1
  49. package/dist/cli/step-excerpt.d.ts +48 -0
  50. package/dist/cli/step-excerpt.d.ts.map +1 -0
  51. package/dist/cli/step-excerpt.js +302 -0
  52. package/dist/cli/step-excerpt.js.map +1 -0
  53. package/dist/cli/step-failure.d.ts.map +1 -1
  54. package/dist/cli/step-failure.js +9 -20
  55. package/dist/cli/step-failure.js.map +1 -1
  56. package/dist/cli-commands.d.ts +8 -0
  57. package/dist/cli-commands.d.ts.map +1 -1
  58. package/dist/cli-commands.js +6 -0
  59. package/dist/cli-commands.js.map +1 -1
  60. package/dist/cli.d.ts +2 -0
  61. package/dist/cli.d.ts.map +1 -1
  62. package/dist/cli.js +38 -7
  63. package/dist/cli.js.map +1 -1
  64. package/dist/cloud-transcript-codex.d.ts +42 -0
  65. package/dist/cloud-transcript-codex.d.ts.map +1 -0
  66. package/dist/cloud-transcript-codex.js +411 -0
  67. package/dist/cloud-transcript-codex.js.map +1 -0
  68. package/dist/cloud-transcript-types.d.ts +161 -0
  69. package/dist/cloud-transcript-types.d.ts.map +1 -0
  70. package/dist/cloud-transcript-types.js +47 -0
  71. package/dist/cloud-transcript-types.js.map +1 -0
  72. package/dist/cloud-transcript.d.ts +2 -78
  73. package/dist/cloud-transcript.d.ts.map +1 -1
  74. package/dist/cloud-transcript.js +36 -38
  75. package/dist/cloud-transcript.js.map +1 -1
  76. package/dist/failure-kinds.d.ts +10 -2
  77. package/dist/failure-kinds.d.ts.map +1 -1
  78. package/dist/failure-kinds.js.map +1 -1
  79. package/dist/journal-client.d.ts +8 -2
  80. package/dist/journal-client.d.ts.map +1 -1
  81. package/dist/journal-client.js +22 -6
  82. package/dist/journal-client.js.map +1 -1
  83. package/dist/llm-worker.d.ts +2 -1
  84. package/dist/llm-worker.d.ts.map +1 -1
  85. package/dist/llm-worker.js +5 -2
  86. package/dist/llm-worker.js.map +1 -1
  87. package/dist/local-agent.d.ts +1 -1
  88. package/dist/local-agent.d.ts.map +1 -1
  89. package/dist/local-agent.js +8 -3
  90. package/dist/local-agent.js.map +1 -1
  91. package/dist/preflight.d.ts.map +1 -1
  92. package/dist/preflight.js +29 -3
  93. package/dist/preflight.js.map +1 -1
  94. package/dist/promise-ancestry.d.ts +20 -0
  95. package/dist/promise-ancestry.d.ts.map +1 -0
  96. package/dist/promise-ancestry.js +104 -0
  97. package/dist/promise-ancestry.js.map +1 -0
  98. package/dist/validate.d.ts.map +1 -1
  99. package/dist/validate.js +7 -0
  100. package/dist/validate.js.map +1 -1
  101. package/dist/worker-cli.d.ts +7 -0
  102. package/dist/worker-cli.d.ts.map +1 -1
  103. package/dist/worker-cli.js +42 -10
  104. package/dist/worker-cli.js.map +1 -1
  105. package/dist/worker-slots.d.ts +42 -0
  106. package/dist/worker-slots.d.ts.map +1 -0
  107. package/dist/worker-slots.js +94 -0
  108. package/dist/worker-slots.js.map +1 -0
  109. package/package.json +2 -2
  110. package/src/authored-flow-executor.ts +22 -3
  111. package/src/authored-flow-lifecycle.ts +111 -16
  112. package/src/authored-flow-operation.ts +10 -0
  113. package/src/authored-node-entry.ts +4 -1
  114. package/src/authored-node-runner.ts +2 -1
  115. package/src/authored-promise-graph.ts +46 -19
  116. package/src/authored-root.ts +4 -0
  117. package/src/authored-step-graph.ts +246 -0
  118. package/src/authored-step-index.ts +44 -1
  119. package/src/authored-step-output.ts +7 -3
  120. package/src/authored-worker-step.ts +25 -6
  121. package/src/cli/direct-run.ts +5 -2
  122. package/src/cli/run.ts +11 -4
  123. package/src/cli/step-excerpt.ts +332 -0
  124. package/src/cli/step-failure.ts +9 -20
  125. package/src/cli-commands.ts +6 -0
  126. package/src/cli.ts +40 -9
  127. package/src/cloud-transcript-codex.ts +429 -0
  128. package/src/cloud-transcript-types.ts +214 -0
  129. package/src/cloud-transcript.ts +44 -136
  130. package/src/failure-kinds.ts +10 -2
  131. package/src/journal-client.ts +25 -6
  132. package/src/llm-worker.ts +7 -2
  133. package/src/local-agent.ts +8 -2
  134. package/src/preflight.ts +37 -2
  135. package/src/promise-ancestry.ts +98 -0
  136. package/src/validate.ts +7 -0
  137. package/src/worker-cli.ts +41 -9
  138. package/src/worker-slots.ts +92 -0
@@ -1,3 +1,4 @@
1
+ import { resolve } from 'node:path';
1
2
  import type { AuthoredBudget } from './authored-budget.js';
2
3
  import { parseBudget } from './budget.js';
3
4
  import type { AgentOptions, AgentResult, LlmOptions, NamedGate } from '@relayflows/surface';
@@ -14,6 +15,7 @@ import { snapshotJsonValue } from './json-value.js';
14
15
  import { authoredChildAdmissionKey } from './authored-admission.js';
15
16
  import { alsoRecord, recordAuthoredChild } from './authored-step-index.js';
16
17
  import type { StepFailedDetails } from './failure-kinds.js';
18
+ import { WorkerSlots } from './worker-slots.js';
17
19
 
18
20
  const WORKSPACE_PERMISSION_ANNOTATION = /:\s*(readonly|readwrite)\s*$/i;
19
21
 
@@ -22,11 +24,16 @@ export function authoredWorkerRunner(
22
24
  definition: { name: string }, journal: JournalClient, flowPath: string,
23
25
  journalSteps: AuthoredFlowJournalStep[], waitOptions: RunLifecycleOptions,
24
26
  localAgentStream?: string, budget?: AuthoredBudget, headerBudget?: unknown,
25
- rootRunId?: string,
27
+ rootRunId?: string, workerCapacity?: number, stepEdges?: AuthoredStepContext['stepEdges'],
26
28
  ) {
29
+ // Sized to the attached local workers, so concurrent calls wait here for a
30
+ // slot instead of being admitted and parked for want of a free worker.
31
+ const slots = workerCapacity === undefined ? undefined
32
+ : { agent: new WorkerSlots(workerCapacity), llm: new WorkerSlots(workerCapacity) };
27
33
  const context: AuthoredStepContext = {
28
34
  ...(rootRunId === undefined ? {} : { rootRunId }),
29
35
  ...(waitOptions.dataDir === undefined ? {} : { dataDir: waitOptions.dataDir }),
36
+ ...(stepEdges === undefined ? {} : { stepEdges }),
30
37
  };
31
38
  async function run(step: StepSpec): Promise<unknown> {
32
39
  const id = step.id;
@@ -55,7 +62,9 @@ export function authoredWorkerRunner(
55
62
  // Written BEFORE the wait, not after it. An agent runs for as long as its
56
63
  // lease allows; if this process dies mid-step, this record is the only
57
64
  // thing that still names the child run holding the evidence.
58
- await recordAuthoredChild(journal, rootRunId, { step: id, runId: outcome.run_id, state: 'admitted' });
65
+ await recordAuthoredChild(journal, rootRunId, {
66
+ step: id, runId: outcome.run_id, state: 'admitted', ...stepEdges?.(id),
67
+ });
59
68
  // Reuse the declarative CLI's own wait/classification (cli/run.ts) rather
60
69
  // than a hand-rolled poll: `step.completed` and the run's own terminal
61
70
  // state are appended as two SEPARATE actions (kernel/relayflowd-core/src/machine.rs
@@ -95,12 +104,13 @@ export function authoredWorkerRunner(
95
104
  // a manufactured completion.
96
105
  if (isSurfaceCompletionReason(details?.completionReason)) {
97
106
  journalSteps.push(Object.freeze({
98
- id, runId: outcome.run_id, completionReason: details.completionReason,
107
+ id, runId: outcome.run_id, completionReason: details.completionReason, ...stepEdges?.(id),
99
108
  }));
100
109
  message += await alsoRecord(journal, rootRunId, {
101
110
  step: id, runId: outcome.run_id, state: 'completed',
102
111
  completionReason: details.completionReason,
103
112
  ...(details.stepId === undefined ? {} : { kernelStep: details.stepId }),
113
+ ...stepEdges?.(id),
104
114
  });
105
115
  }
106
116
  throw new AuthoredFlowExecutionError(
@@ -113,12 +123,18 @@ export function authoredWorkerRunner(
113
123
  return readCompletedStepOutput(journal, outcome.run_id, id, journalSteps, context);
114
124
  };
115
125
  const admissionKey = authoredChildAdmissionKey(rootRunId, id);
116
- return budget === undefined
126
+ const admit = async () => budget === undefined
117
127
  ? consume(await journal.runStart(spec, undefined, admissionKey))
118
128
  : budget.execute(journal, spec, consume, admissionKey);
129
+ return slots === undefined ? admit() : slots[step.type === 'llm' ? 'llm' : 'agent'].run(admit);
119
130
  }
120
131
 
121
132
  return {
133
+ /** Refuse every agent/LLM call still waiting for a worker slot (body teardown). */
134
+ stop(reason: unknown): void {
135
+ slots?.agent.close(reason);
136
+ slots?.llm.close(reason);
137
+ },
122
138
  async agent(id: string, options: AgentOptions, verification?: NamedGate): Promise<AgentResult> {
123
139
  if (options.workspace !== undefined && localAgentStream !== undefined) {
124
140
  throw new AuthoredFlowExecutionError('unsupported_workspace_permission',
@@ -167,7 +183,8 @@ export function authoredWorkerRunner(
167
183
  ...(options.workspace === undefined ? {} : { surfaces: { workspace: [{ surface: options.workspace }] } }),
168
184
  ...(options.cli === undefined ? {} : { cli: options.cli }),
169
185
  ...(options.model === undefined ? {} : { model: options.model }),
170
- ...(options.cwd === undefined ? {} : { cwd: options.cwd }),
186
+ // The kernel takes only an absolute cwd; a relative one means the runner's own directory.
187
+ ...(options.cwd === undefined ? {} : { cwd: resolve(options.cwd) }),
171
188
  ...(options.transport === undefined ? {} : { transport: options.transport }),
172
189
  ...(verification === undefined ? {} : { verification }),
173
190
  });
@@ -229,7 +246,9 @@ export function authoredDeterministicRunner(
229
246
  }));
230
247
  const admissionKey = authoredChildAdmissionKey(rootRunId, id);
231
248
  const consume = async (outcome: import('./protocol.js').RunOutcome): Promise<string> => {
232
- await recordAuthoredChild(journal, rootRunId, { step: id, runId: outcome.run_id, state: 'admitted' });
249
+ await recordAuthoredChild(journal, rootRunId, {
250
+ step: id, runId: outcome.run_id, state: 'admitted', ...context.stepEdges?.(id),
251
+ });
233
252
  return readSuccessfulOutput(journal, outcome, id, journalSteps, context);
234
253
  };
235
254
  if (terminal) return consume(await journal.runStart(spec, undefined, admissionKey));
@@ -4,6 +4,7 @@ import { authoredLocalAgentStream } from '../authored-admission.js';
4
4
  import { McpPreflightError } from './check-typescript.js';
5
5
  import { attachLocalAgent } from '../local-agent.js';
6
6
  import { LlmWorker } from '../llm-worker.js';
7
+ import { DEFAULT_LOCAL_AGENT_CAPACITY } from '../worker-slots.js';
7
8
  import {
8
9
  AuthoredFlowExecutionError,
9
10
  } from '../authored-flow-executor.js';
@@ -67,19 +68,20 @@ export async function runDirectFlow(
67
68
  let localLlm: LlmWorker | undefined;
68
69
  let llmClient: JournalClient | undefined;
69
70
  let llmFailure: unknown;
71
+ const workerCapacity = options.agentCapacity ?? DEFAULT_LOCAL_AGENT_CAPACITY;
70
72
  const admissionIdentity = options.authoredAdmissionKey
71
73
  ?? process.env['RELAYFLOW_AUTHORED_ADMISSION_KEY'] ?? randomUUID();
72
74
  try {
73
75
  if (options.localAgent) {
74
76
  localAgent = await attachLocalAgent(
75
- client, dataDir, options.onPtyReady, authoredLocalAgentStream(admissionIdentity),
77
+ client, dataDir, options.onPtyReady, authoredLocalAgentStream(admissionIdentity), workerCapacity,
76
78
  );
77
79
  // A session owns one worker registration. Keep the workspace-free LLM
78
80
  // worker on its own connection so it cannot replace the agent worker.
79
81
  llmClient = new JournalClient(socketPath);
80
82
  await llmClient.connect();
81
83
  await llmClient.hello('flows-local-llm');
82
- localLlm = new LlmWorker(llmClient, `${localAgent.stream}-llm`);
84
+ localLlm = new LlmWorker(llmClient, `${localAgent.stream}-llm`, workerCapacity);
83
85
  localLlm.on('error', error => { llmFailure = error; client.close(); });
84
86
  await localLlm.attach();
85
87
  }
@@ -89,6 +91,7 @@ export async function runDirectFlow(
89
91
  dataDir,
90
92
  admissionKey: admissionIdentity,
91
93
  localAgentStream: localAgent?.stream,
94
+ ...(localAgent === undefined ? {} : { workerCapacity }),
92
95
  lifecycle: {
93
96
  onProgress: options.onProgress,
94
97
  ...(options.signal !== undefined ? { signal: options.signal } : {}),
package/src/cli/run.ts CHANGED
@@ -26,6 +26,7 @@ import type {
26
26
  RunStatus,
27
27
  } from '../protocol.js';
28
28
  import type { StepType } from '../spec.js';
29
+ import { DEFAULT_LOCAL_AGENT_CAPACITY } from '../worker-slots.js';
29
30
  import type { LoweredCompletionReason } from '../authored-flow-executor.js';
30
31
  import {
31
32
  checkFlow,
@@ -91,6 +92,8 @@ export interface RunLifecycleOptions {
91
92
  reuseFromRunId?: string;
92
93
  onProgress?: (event: ProgressEvent) => void;
93
94
  localAgent?: boolean;
95
+ /** `--agent-capacity`: the local workers' concurrency; the default is `DEFAULT_LOCAL_AGENT_CAPACITY`. */
96
+ agentCapacity?: number;
94
97
  signal?: AbortSignal;
95
98
  onWait?: (progress: RunProgress) => void;
96
99
  /**
@@ -148,7 +151,9 @@ async function executeCheckedFlow(
148
151
  const spec = toKernelSpec(checked.flow!);
149
152
  // Use the checked CLI/model and declared surfaces unchanged. The worker
150
153
  // advertises its existing pins; the daemon still owns surface matching.
151
- if (options.localAgent) localAgent = await attachLocalAgent(client, dataDir, options.onPtyReady);
154
+ if (options.localAgent) {
155
+ localAgent = await attachLocalAgent(client, dataDir, options.onPtyReady, undefined, options.agentCapacity);
156
+ }
152
157
  if (options.localAgent && spec.steps.some(step => step.type === 'agent' && communicationInstruction(step.instruction))) {
153
158
  const { attachCommunicationWorkers } = await import('../communication/local.js');
154
159
  communicationWorkers = await attachCommunicationWorkers(spec, socketPath, dataDir);
@@ -192,6 +197,7 @@ export async function resumeFlow(
192
197
  let authoredAgent: Awaited<ReturnType<typeof attachLocalAgent>> | undefined;
193
198
  let authoredLlm: LlmWorker | undefined;
194
199
  let authoredLlmClient: JournalClient | undefined;
200
+ const workerCapacity = options.agentCapacity ?? DEFAULT_LOCAL_AGENT_CAPACITY;
195
201
  try {
196
202
  const authoredRoot = await readAuthoredRootMetadata(client, runId);
197
203
  if (authoredRoot !== undefined) {
@@ -203,17 +209,18 @@ export async function resumeFlow(
203
209
  }
204
210
  if (options.localAgent) {
205
211
  authoredAgent = await attachLocalAgent(
206
- client, dataDir, options.onPtyReady, authoredRoot.localAgentStream,
212
+ client, dataDir, options.onPtyReady, authoredRoot.localAgentStream, workerCapacity,
207
213
  );
208
214
  authoredLlmClient = new JournalClient(socketPath);
209
215
  await authoredLlmClient.connect();
210
216
  await authoredLlmClient.hello('flows-authored-resume-llm');
211
- authoredLlm = new LlmWorker(authoredLlmClient, `${authoredAgent.stream}-llm`);
217
+ authoredLlm = new LlmWorker(authoredLlmClient, `${authoredAgent.stream}-llm`, workerCapacity);
212
218
  await authoredLlm.attach();
213
219
  }
214
220
  const result = await resumeDurableAuthoredFlow(runId, client, {
215
221
  dataDir,
216
222
  localAgentStream: authoredAgent?.stream,
223
+ ...(authoredAgent === undefined ? {} : { workerCapacity }),
217
224
  lifecycle: options,
218
225
  });
219
226
  if (result === undefined) throw new Error('authored root disappeared during resume');
@@ -224,7 +231,7 @@ export async function resumeFlow(
224
231
  // second call the earlier rebase left is a stale reference from before
225
232
  // the helper fanout renamed the API.
226
233
  if (options.localAgent) {
227
- authoredAgent = await attachLocalAgent(client, dataDir, options.onPtyReady);
234
+ authoredAgent = await attachLocalAgent(client, dataDir, options.onPtyReady, undefined, options.agentCapacity);
228
235
  const entries = (await client.journalRead(runId, 1)).entries as Array<{ entry_type: string; payload?: { spec?: import('../spec.js').KernelRunSpec } }>;
229
236
  const spec = entries.find(entry => entry.entry_type === 'run.spawned')?.payload?.spec;
230
237
  if (spec?.steps.some(step => step.type === 'agent' && communicationInstruction(step.instruction))) {
@@ -0,0 +1,332 @@
1
+ /**
2
+ * One captured stream, cut to something a terminal can print WITHOUT throwing
3
+ * away the line that says what failed.
4
+ *
5
+ * The render this replaces kept the last 1,024 bytes. For any runner that
6
+ * streams results and prints its summary last — TAP (`node --test`), cargo,
7
+ * vitest, pytest — the last kilobyte is by construction the totals and the
8
+ * final few passing cases, so the one `not ok` was always the part that got
9
+ * cut. A report that proves a failure happened and withholds which one costs
10
+ * the reader a local re-run, which is the loop the gate exists to avoid.
11
+ *
12
+ * So the excerpt is three disjoint ranges of the same source, in source order:
13
+ * a head, the lines in the elided middle that look like a failing case, and a
14
+ * tail. The elision is stated in band, in bytes, so the reader is never left
15
+ * guessing whether what they are looking at is all of it.
16
+ *
17
+ * What this can and cannot promise:
18
+ *
19
+ * - It selects from the field the journal carried, which for a deterministic
20
+ * step is the last 64 KiB the kernel captured (`OUTPUT_TAIL_BYTES`,
21
+ * relayflowd/src/exec_det.rs). A failure printed before that window is not
22
+ * here to be found, and no marker can say so — hence `captured excerpt`
23
+ * rather than a claim about the command's whole output.
24
+ * - The absence of an elision marker means the supplied field fit whole. It
25
+ * does not mean the command printed nothing more.
26
+ * - The markers are hints, not a parser. They find the common runners' failing
27
+ * lines; ordinary prose can match one, and an unknown format matches none
28
+ * and still gets head-and-tail context.
29
+ */
30
+
31
+ /** Default excerpt budget, in UTF-8 bytes, for one captured stream. */
32
+ export const EXCERPT_BYTES = 4_096;
33
+
34
+ /**
35
+ * Below this there is no room to both elide and say so, and an excerpt that
36
+ * silently drops the claim it makes about itself is the bug being fixed. The
37
+ * budget is a constant of this module, not configuration, so a caller that
38
+ * asks for less has made a programming error and is told at the call.
39
+ */
40
+ export const MIN_EXCERPT_BYTES = 256;
41
+
42
+ /** How many matched lines the elided middle may contribute. */
43
+ const MAX_HIGHLIGHT_LINES = 20;
44
+
45
+ /** A highlight cut short is only worth showing if some of the line survives. */
46
+ const MIN_HIGHLIGHT_BYTES = 24;
47
+
48
+ /** Newlines this module inserts between ranges and markers. */
49
+ const SEPARATOR_RESERVE = 16;
50
+
51
+ const END_MARKER = '… end of elided region …';
52
+ const TRUNCATED_MARKER = '… line truncated …';
53
+
54
+ /**
55
+ * Lines that name a failing case, for the runners this repo actually runs.
56
+ *
57
+ * Each is anchored after optional indentation (nested TAP indents its
58
+ * subtests) and is matched against a comparison view of the line with
59
+ * terminal escapes removed, so a coloured `FAIL` header still matches. They
60
+ * are constants rather than user input, which is why `RegExp` is correct
61
+ * here; a spec-supplied pattern would be untrusted and would have to go
62
+ * through `re2js`.
63
+ */
64
+ const FAILURE_MARKERS: readonly RegExp[] = [
65
+ /^\s*not ok\b/u, // TAP: node --test, prove
66
+ /^\s*FAILED\b/u, // pytest short test summary: `FAILED tests/x.py::y - ...`
67
+ /^\s*FAIL\b/u, // vitest / jest file header: `FAIL src/x.test.ts`
68
+ /^\s*[✖✗×]\s/u, // vitest / jest failure glyph
69
+ /\.\.\.\s*FAILED\b/u, // cargo: `test pty::exit ... FAILED`
70
+ /^\s*---- .* ----\s*$/u, // cargo: `---- pty::exit stdout ----`
71
+ /\bpanicked at\b/u, // rust panic
72
+ ];
73
+
74
+ interface Range { start: number; end: number }
75
+
76
+ /**
77
+ * Cut `value` to at most `budget` UTF-8 bytes, markers included.
78
+ *
79
+ * A stream that already fits is returned whole (sanitised) with no marker. One
80
+ * that does not keeps a head of whole lines, up to `MAX_HIGHLIGHT_LINES`
81
+ * matched lines from the elided middle, and a tail that fills what is left.
82
+ * Every cut is at a code-point boundary, so slicing never invents a U+FFFD.
83
+ */
84
+ export function formatStepExcerpt(value: string, budget: number = EXCERPT_BYTES): string {
85
+ if (!Number.isSafeInteger(budget) || budget < MIN_EXCERPT_BYTES) {
86
+ throw new Error(`step excerpt budget must be an integer of at least ${MIN_EXCERPT_BYTES} bytes`);
87
+ }
88
+ const source = Buffer.from(value, 'utf8');
89
+ // Sanitised from the original string, not from the round trip: a lone
90
+ // surrogate the wire delivered is the caller's to keep, and re-encoding it
91
+ // would be this module inventing a replacement character.
92
+ if (source.length <= budget) return sanitise(value);
93
+
94
+ const lines = lineRanges(source);
95
+ const matches = lines.filter(isFailureLine(source));
96
+ // Content space is what is left after the markers can be afforded at their
97
+ // widest, so the excerpt can always state its own elision.
98
+ const content = Math.max(0, budget - markerReserve(source.length, matches.length));
99
+ const tailShare = Math.floor(content / 2);
100
+ const headShare = Math.floor(content / 4);
101
+ const highlightShare = content - tailShare - headShare;
102
+
103
+ const head = headRange(source, lines, headShare);
104
+ // Provisional tail, which with the head defines the middle the highlights
105
+ // are drawn from.
106
+ const highlights = selectHighlights(source, matches, highlightShare,
107
+ head.end, tailRange(source, tailShare, head.end).start);
108
+
109
+ // Every byte the head and the highlights did not need goes to the tail,
110
+ // which is where a runner puts the summary. The tail may not cross the last
111
+ // highlight, so the ranges stay disjoint and in source order.
112
+ const spare = headShare - size(head)
113
+ + highlightShare - highlights.reduce((sum, highlight) => sum + size(highlight.range), 0);
114
+ let tail = tailRange(source, tailShare + spare, highlights.at(-1)?.range.end ?? head.end);
115
+ // A highlight the tail resumes at the very byte it ends is not a range of
116
+ // its own: the two are one unbroken run of source, so the tail starts at
117
+ // the highlight instead. The same bytes either way, with no marker between
118
+ // them claiming a cut the reader can see did not happen.
119
+ while (highlights.length > 0 && highlights.at(-1)!.range.end === tail.start) {
120
+ tail = { start: highlights.pop()!.range.start, end: tail.end };
121
+ }
122
+
123
+ const selected = [head, ...highlights.map(highlight => highlight.range), tail]
124
+ .filter(range => size(range) > 0);
125
+ const elided = source.length - selected.reduce((sum, range) => sum + size(range), 0);
126
+ // Positional, not textual: two identical failing lines at two positions are
127
+ // two observations, and one of them may be visible while the other is not.
128
+ // Shown means the line's own start is on screen, because the start is what
129
+ // names the failing case: a highlight cut short still reports it, while a
130
+ // range that begins in the middle of the line — a tail whose cut landed
131
+ // there — shows only the part after the name and reports nothing.
132
+ const omitted = matches.filter(match =>
133
+ !selected.some(range => range.start <= match.start && range.end > match.start)).length;
134
+
135
+ const parts: string[] = [];
136
+ const terminated = (chunk: string): string => chunk.endsWith('\n') ? chunk : `${chunk}\n`;
137
+ if (size(head) > 0) parts.push(terminated(text(source, head)));
138
+ parts.push(`${openMarker(elided, highlights.length, omitted)}\n`);
139
+ for (const highlight of highlights) {
140
+ parts.push(terminated(text(source, highlight.range) + (highlight.truncated ? ` ${TRUNCATED_MARKER}` : '')));
141
+ }
142
+ if (highlights.length > 0) {
143
+ if (omitted > 0) parts.push(`${moreMarker(omitted)}\n`);
144
+ parts.push(`${END_MARKER}\n`);
145
+ }
146
+ if (size(tail) > 0) parts.push(text(source, tail));
147
+ // Sanitising replaces, never expands, so it cannot push the result over the
148
+ // budget; the clamp is where the byte bound is enforced, for every path.
149
+ return clampBytes(sanitise(parts.join('')), budget);
150
+ }
151
+
152
+ /**
153
+ * `N bytes elided` counts raw source bytes represented by no selected range,
154
+ * including the omitted portion of a line shown only in part. Source bytes
155
+ * shown plus source bytes elided is the input, exactly; the markers this
156
+ * module inserts are not source bytes, and sanitising can shrink what is
157
+ * displayed without changing what was selected.
158
+ */
159
+ function openMarker(elided: number, shown: number, omitted: number): string {
160
+ const matched = shown > 0
161
+ ? `; ${plural(shown, 'line')} matched a failure marker`
162
+ : omitted > 0 ? `; ${plural(omitted, 'line')} matched a failure marker, none shown` : '';
163
+ return `… ${group(elided)} bytes elided${matched} …`;
164
+ }
165
+
166
+ function moreMarker(omitted: number): string {
167
+ return `… +${group(omitted)} more matching ${omitted === 1 ? 'line' : 'lines'} …`;
168
+ }
169
+
170
+ /** Every marker at its widest, so content space can be assigned once. */
171
+ function markerReserve(sourceBytes: number, matches: number): number {
172
+ const open = Math.max(
173
+ byteLength(openMarker(sourceBytes, matches, matches)),
174
+ byteLength(openMarker(sourceBytes, 0, matches)),
175
+ byteLength(openMarker(sourceBytes, 0, 0)),
176
+ );
177
+ const highlighted = matches === 0 ? 0
178
+ : byteLength(moreMarker(matches)) + byteLength(END_MARKER) + byteLength(TRUNCATED_MARKER) + 1;
179
+ return open + highlighted + SEPARATOR_RESERVE;
180
+ }
181
+
182
+ /**
183
+ * Matched lines from the middle, in source order, bounded by both the line cap
184
+ * and the byte share. A whole line is preferred; when the first candidate does
185
+ * not fit at all, a code-point-safe prefix of it is shown under a truncation
186
+ * marker, because a cut failing line still names the failing case and no
187
+ * highlight at all does not.
188
+ *
189
+ * The middle ends where the tail begins, but a line the tail begins in the
190
+ * middle of belongs to the middle: the tail shows its tail, and the marker
191
+ * that names the failing case is ahead of the cut. Such a line is a candidate
192
+ * like any other, and the caller keeps the ranges disjoint by starting the
193
+ * tail after whatever prefix is kept.
194
+ */
195
+ function selectHighlights(
196
+ source: Buffer, matches: readonly Range[], share: number, from: number, to: number,
197
+ ): Array<{ range: Range; truncated: boolean }> {
198
+ const selected: Array<{ range: Range; truncated: boolean }> = [];
199
+ let remaining = share;
200
+ for (const match of matches) {
201
+ // Reported once, at its own position: a line whose own start is already
202
+ // on screen — inside the head, or at or after the start of the tail —
203
+ // needs no highlight.
204
+ if (match.start < from || match.start >= to) continue;
205
+ if (selected.length >= MAX_HIGHLIGHT_LINES) break;
206
+ if (size(match) <= remaining) {
207
+ selected.push({ range: match, truncated: false });
208
+ remaining -= size(match);
209
+ continue;
210
+ }
211
+ if (selected.length === 0 && remaining >= MIN_HIGHLIGHT_BYTES) {
212
+ const end = floorBoundary(source, match.start + remaining);
213
+ if (end > match.start) selected.push({ range: { start: match.start, end }, truncated: true });
214
+ }
215
+ break;
216
+ }
217
+ return selected;
218
+ }
219
+
220
+ /**
221
+ * Whole lines from the start, or a bounded prefix when the first line alone is
222
+ * longer than the share — a long first line must not consume the excerpt.
223
+ * Whatever whole lines leave unspent is returned to the tail by the caller.
224
+ */
225
+ function headRange(source: Buffer, lines: readonly Range[], limit: number): Range {
226
+ let end = 0;
227
+ for (const line of lines) {
228
+ if (line.end > limit) break;
229
+ end = line.end;
230
+ }
231
+ return { start: 0, end: end === 0 ? floorBoundary(source, limit) : end };
232
+ }
233
+
234
+ /**
235
+ * The last `limit` bytes, cut at a code-point boundary and never crossing
236
+ * `stop` (the end of the head or of the last highlight).
237
+ *
238
+ * Unlike the head, the tail fills its share rather than stopping at a line
239
+ * boundary. A stream can be one enormous line followed by a one-byte last
240
+ * line; taking whole lines only would spend one byte of a two-kilobyte share
241
+ * and drop the summary that the old last-1,024-bytes render always kept.
242
+ */
243
+ function tailRange(source: Buffer, limit: number, stop: number): Range {
244
+ return { start: Math.max(ceilBoundary(source, source.length - limit), stop), end: source.length };
245
+ }
246
+
247
+ function isFailureLine(source: Buffer): (line: Range) => boolean {
248
+ return line => {
249
+ const comparable = comparableLine(text(source, line));
250
+ return FAILURE_MARKERS.some(marker => marker.test(comparable));
251
+ };
252
+ }
253
+
254
+ /**
255
+ * The view a marker is matched against: terminal escape sequences and control
256
+ * characters removed so a coloured `not ok` or `FAIL` header still matches.
257
+ * This never touches what is rendered — journal data is not mutated, and the
258
+ * display sanitiser is a separate, later step.
259
+ */
260
+ function comparableLine(line: string): string {
261
+ return line
262
+ .replace(/\u001b\[[0-9;:?]*[ -/]*[@-~]|\u001b[@-Z\\-_]|\u009b[0-9;:?]*[ -/]*[@-~]/gu, '')
263
+ .replace(/[\p{Cc}\p{Cf}]/gu, character => character === '\t' ? ' ' : '');
264
+ }
265
+
266
+ /** Line ranges over the raw bytes; each end includes its own `\n` when it has one. */
267
+ function lineRanges(source: Buffer): Range[] {
268
+ const ranges: Range[] = [];
269
+ let start = 0;
270
+ for (let index = 0; index < source.length; index++) {
271
+ if (source[index] === 0x0a) {
272
+ ranges.push({ start, end: index + 1 });
273
+ start = index + 1;
274
+ }
275
+ }
276
+ if (start < source.length) ranges.push({ start, end: source.length });
277
+ return ranges;
278
+ }
279
+
280
+ /**
281
+ * Preserve tabs and newlines; replace binary controls (including ESC and CR),
282
+ * C1 controls and Unicode formatting controls without growing the excerpt.
283
+ * Applied on every path, short inputs included: a control sequence in a
284
+ * two-line stderr is as able to rewrite the terminal as one in a long stream.
285
+ */
286
+ function sanitise(value: string): string {
287
+ return value.replace(/[\p{Cc}\p{Cf}]/gu, character =>
288
+ character === '\n' || character === '\t' ? character : '?');
289
+ }
290
+
291
+ function clampBytes(value: string, budget: number): string {
292
+ const bytes = Buffer.from(value, 'utf8');
293
+ if (bytes.length <= budget) return value;
294
+ return bytes.subarray(0, floorBoundary(bytes, budget)).toString('utf8');
295
+ }
296
+
297
+ function text(source: Buffer, range: Range): string {
298
+ return source.subarray(range.start, range.end).toString('utf8');
299
+ }
300
+
301
+ function size(range: Range): number {
302
+ return range.end - range.start;
303
+ }
304
+
305
+ function isBoundary(source: Buffer, index: number): boolean {
306
+ return index >= source.length || (source[index]! & 0xc0) !== 0x80;
307
+ }
308
+
309
+ function floorBoundary(source: Buffer, index: number): number {
310
+ let at = Math.max(0, Math.min(index, source.length));
311
+ while (at > 0 && !isBoundary(source, at)) at -= 1;
312
+ return at;
313
+ }
314
+
315
+ function ceilBoundary(source: Buffer, index: number): number {
316
+ let at = Math.max(0, Math.min(index, source.length));
317
+ while (at < source.length && !isBoundary(source, at)) at += 1;
318
+ return at;
319
+ }
320
+
321
+ function byteLength(value: string): number {
322
+ return Buffer.byteLength(value, 'utf8');
323
+ }
324
+
325
+ function plural(count: number, noun: string): string {
326
+ return `${group(count)} ${noun}${count === 1 ? '' : 's'}`;
327
+ }
328
+
329
+ /** Locale-independent digit grouping: the same excerpt everywhere it is read. */
330
+ function group(value: number): string {
331
+ return String(value).replace(/\B(?=(\d{3})+(?!\d))/gu, ',');
332
+ }
@@ -2,8 +2,7 @@ import { join } from 'node:path';
2
2
  import { DEFAULT_DATA_DIR } from '../daemon-connection.js';
3
3
  import type { StepFailedDetails } from '../failure-kinds.js';
4
4
  import type { JournalClient } from '../journal-client.js';
5
-
6
- const TAIL_BYTES = 1_024;
5
+ import { formatStepExcerpt } from './step-excerpt.js';
7
6
 
8
7
  /**
9
8
  * Read what a failed step left in the journal — for any step type.
@@ -117,8 +116,8 @@ export function renderStepEvidence(details: StepFailedDetails): string {
117
116
  + (details.exitCode === undefined ? '' : ` exit=${details.exitCode}`)
118
117
  + '.'
119
118
  + (details.detail === undefined ? '' : `\nDetail: ${details.detail}`)
120
- + (details.stdoutTail ? `\nStdout (last 1,024 bytes):\n${details.stdoutTail}` : '')
121
- + (details.stderrTail ? `\nStderr (last 1,024 bytes):\n${details.stderrTail}` : '')
119
+ + (details.stdoutTail ? `\nStdout (captured excerpt):\n${details.stdoutTail}` : '')
120
+ + (details.stderrTail ? `\nStderr (captured excerpt):\n${details.stderrTail}` : '')
122
121
  + (details.transcriptPath === undefined ? '' : `\nTranscript: ${details.transcriptPath}`);
123
122
  }
124
123
 
@@ -195,22 +194,22 @@ function evidence(payload: Record<string, unknown>): Partial<StepFailedDetails>
195
194
  const structuredShape = structured !== undefined && processShaped(structured);
196
195
  const transcript = record(record(payload['trajectory_tail'])?.['transcript']);
197
196
  const failure = record(transcript?.['failure']);
198
- const excerpt = failure?.['excerpt'];
197
+ const failureExcerpt = failure?.['excerpt'];
199
198
  const transcriptPath = record(transcript?.['file'])?.['path'];
200
199
  // The excerpt is the worker's own pick of the failure; a `stderr` excerpt
201
200
  // is the same bytes as `stderrTail`, so it is not printed twice.
202
- const excerptDetail = typeof excerpt === 'string' && excerpt.length > 0
201
+ const excerptDetail = typeof failureExcerpt === 'string' && failureExcerpt.length > 0
203
202
  && !(failure?.['kind'] === 'stderr' && typeof stderr === 'string')
204
- ? tail(excerpt) : undefined;
203
+ ? formatStepExcerpt(failureExcerpt) : undefined;
205
204
  return {
206
205
  ...(typeof exitCode === 'number' && Number.isSafeInteger(exitCode) ? { exitCode } : {}),
207
- ...(typeof stdout === 'string' && stdout.length > 0 ? { stdoutTail: tail(stdout) } : {}),
208
- ...(typeof stderr === 'string' ? { stderrTail: tail(stderr) } : {}),
206
+ ...(typeof stdout === 'string' && stdout.length > 0 ? { stdoutTail: formatStepExcerpt(stdout) } : {}),
207
+ ...(typeof stderr === 'string' ? { stderrTail: formatStepExcerpt(stderr) } : {}),
209
208
  // Keep the daemon's account only when it was NOT just a render of the
210
209
  // fields above — otherwise the same bytes print twice.
211
210
  ...(excerptDetail !== undefined ? { detail: excerptDetail }
212
211
  : typeof detail === 'string' && detail.length > 0 && !structuredShape
213
- ? { detail: tail(detail) } : {}),
212
+ ? { detail: formatStepExcerpt(detail) } : {}),
214
213
  ...(typeof transcriptPath === 'string' && transcriptPath.length > 0 ? { transcriptPath } : {}),
215
214
  };
216
215
  }
@@ -233,16 +232,6 @@ function record(value: unknown): Record<string, unknown> | undefined {
233
232
  ? value as Record<string, unknown> : undefined;
234
233
  }
235
234
 
236
- function tail(value: string): string {
237
- const bytes = Buffer.from(value, 'utf8');
238
- let start = Math.max(0, bytes.length - TAIL_BYTES);
239
- // Drop a partial leading code point, avoiding replacement-byte expansion.
240
- while (start < bytes.length && (bytes[start]! & 0xc0) === 0x80) start += 1;
241
- // Preserve tabs/newlines; replace binary controls (including ESC and CR),
242
- // C1 controls and Unicode formatting controls without growing the excerpt.
243
- return bytes.subarray(start).toString('utf8')
244
- .replace(/[\p{Cc}\p{Cf}]/gu, character => character === '\n' || character === '\t' ? character : '?');
245
- }
246
235
 
247
236
  function shellQuote(value: string): string {
248
237
  return /^[A-Za-z0-9_-]+$/.test(value) && !value.startsWith('-')
@@ -1,5 +1,6 @@
1
1
  import { DEFAULT_DATA_DIR } from './daemon-connection.js';
2
2
  import { DEFAULT_RUN_LIMIT } from './cli/cloud-read.js';
3
+ import { DEFAULT_LOCAL_AGENT_CAPACITY, MAX_LOCAL_AGENT_CAPACITY } from './worker-slots.js';
3
4
  import type { ParsedArgs } from './cli.js';
4
5
 
5
6
  /**
@@ -88,6 +89,11 @@ const LOCAL_EXECUTION_OPTIONS = [
88
89
  JSON_OPTION,
89
90
  DATA_DIR_OPTION,
90
91
  { flags: '--local-agent', description: 'Run agent steps in this process instead of a worker' },
92
+ {
93
+ flags: '--agent-capacity <n>',
94
+ description: `With --local-agent, how many agent steps (and, separately, LLM steps) run at once (1-${MAX_LOCAL_AGENT_CAPACITY})`,
95
+ defaultValue: String(DEFAULT_LOCAL_AGENT_CAPACITY),
96
+ },
91
97
  { flags: '--no-spawn', description: 'Require a running relayflowd rather than starting one' },
92
98
  { flags: '--no-observer-link', description: 'Do not mint an observer link for this run' },
93
99
  {