@relayflows/sdk 2.0.27 → 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 (123) 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/failure-kinds.d.ts +10 -2
  65. package/dist/failure-kinds.d.ts.map +1 -1
  66. package/dist/failure-kinds.js.map +1 -1
  67. package/dist/journal-client.d.ts +8 -2
  68. package/dist/journal-client.d.ts.map +1 -1
  69. package/dist/journal-client.js +22 -6
  70. package/dist/journal-client.js.map +1 -1
  71. package/dist/llm-worker.d.ts +2 -1
  72. package/dist/llm-worker.d.ts.map +1 -1
  73. package/dist/llm-worker.js +5 -2
  74. package/dist/llm-worker.js.map +1 -1
  75. package/dist/local-agent.d.ts +1 -1
  76. package/dist/local-agent.d.ts.map +1 -1
  77. package/dist/local-agent.js +8 -3
  78. package/dist/local-agent.js.map +1 -1
  79. package/dist/preflight.d.ts.map +1 -1
  80. package/dist/preflight.js +29 -3
  81. package/dist/preflight.js.map +1 -1
  82. package/dist/promise-ancestry.d.ts +20 -0
  83. package/dist/promise-ancestry.d.ts.map +1 -0
  84. package/dist/promise-ancestry.js +104 -0
  85. package/dist/promise-ancestry.js.map +1 -0
  86. package/dist/validate.d.ts.map +1 -1
  87. package/dist/validate.js +7 -0
  88. package/dist/validate.js.map +1 -1
  89. package/dist/worker-cli.d.ts +7 -0
  90. package/dist/worker-cli.d.ts.map +1 -1
  91. package/dist/worker-cli.js +42 -10
  92. package/dist/worker-cli.js.map +1 -1
  93. package/dist/worker-slots.d.ts +42 -0
  94. package/dist/worker-slots.d.ts.map +1 -0
  95. package/dist/worker-slots.js +94 -0
  96. package/dist/worker-slots.js.map +1 -0
  97. package/package.json +2 -2
  98. package/src/authored-flow-executor.ts +22 -3
  99. package/src/authored-flow-lifecycle.ts +111 -16
  100. package/src/authored-flow-operation.ts +10 -0
  101. package/src/authored-node-entry.ts +4 -1
  102. package/src/authored-node-runner.ts +2 -1
  103. package/src/authored-promise-graph.ts +46 -19
  104. package/src/authored-root.ts +4 -0
  105. package/src/authored-step-graph.ts +246 -0
  106. package/src/authored-step-index.ts +44 -1
  107. package/src/authored-step-output.ts +7 -3
  108. package/src/authored-worker-step.ts +25 -6
  109. package/src/cli/direct-run.ts +5 -2
  110. package/src/cli/run.ts +11 -4
  111. package/src/cli/step-excerpt.ts +332 -0
  112. package/src/cli/step-failure.ts +9 -20
  113. package/src/cli-commands.ts +6 -0
  114. package/src/cli.ts +40 -9
  115. package/src/failure-kinds.ts +10 -2
  116. package/src/journal-client.ts +25 -6
  117. package/src/llm-worker.ts +7 -2
  118. package/src/local-agent.ts +8 -2
  119. package/src/preflight.ts +37 -2
  120. package/src/promise-ancestry.ts +98 -0
  121. package/src/validate.ts +7 -0
  122. package/src/worker-cli.ts +41 -9
  123. package/src/worker-slots.ts +92 -0
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
  {
package/src/cli.ts CHANGED
@@ -4,6 +4,7 @@ import { parsePluginArgs, runPluginCommand, type PluginArgs } from './cli/plugin
4
4
  import { watchCheck } from './cli-watch.js';
5
5
  import { checkHelperBody } from './cli/check-helper-body.js';
6
6
  import { describeFlowRequirements } from './flow-requirements.js';
7
+ import type { CliModelSource } from './cli-adapter.js';
7
8
 
8
9
  import { renderProgress, type ProgressEvent } from './progress.js';
9
10
  import { realpathSync } from 'node:fs';
@@ -44,6 +45,7 @@ import { runHnMonitor } from './cli/hn-monitor.js';
44
45
  import { runTickRunner } from './cli/tick-runner.js';
45
46
  import { DEFAULT_DATA_DIR } from './daemon-connection.js';
46
47
  import { CLI_VERB_NAMES } from './cli-commands.js';
48
+ import { isAgentCapacity } from './worker-slots.js';
47
49
  import {
48
50
  mintObserverUrl,
49
51
  resolveObserverLinkEnv,
@@ -80,8 +82,8 @@ export type ParsedArgs =
80
82
  | { command: 'schedules'; json: boolean }
81
83
  | { command: 'unschedule'; scheduleId: string; json: boolean }
82
84
  | { command: 'check'; json: boolean; watch: boolean; value: string }
83
- | { command: 'run'; bucket: string | undefined; reuseFromRunId: string | undefined; localAgent: boolean; dataDir: string; input: string | undefined; json: boolean; spawn: boolean; noObserverLink: boolean; allowHumanInfluenced: boolean; value: string }
84
- | { command: 'resume'; localAgent: boolean; dataDir: string; json: boolean; spawn: boolean; noObserverLink: boolean; allowHumanInfluenced: boolean; value: string }
85
+ | { command: 'run'; bucket: string | undefined; reuseFromRunId: string | undefined; localAgent: boolean; agentCapacity: number | undefined; dataDir: string; input: string | undefined; json: boolean; spawn: boolean; noObserverLink: boolean; allowHumanInfluenced: boolean; value: string }
86
+ | { command: 'resume'; localAgent: boolean; agentCapacity: number | undefined; dataDir: string; json: boolean; spawn: boolean; noObserverLink: boolean; allowHumanInfluenced: boolean; value: string }
85
87
  | { command: 'answer'; dataDir: string; json: boolean; spawn: boolean; note: string | undefined; by: string | undefined; runId: string; waitId: string; answer: boolean }
86
88
  | RunsArgs
87
89
  | LogsArgs
@@ -111,13 +113,13 @@ const USAGE = [
111
113
  'flows run <flow>@sha256:<digest> [--bucket <file-bucket-uri>] [--data-dir <dir>] [--json]',
112
114
  'flows check [--watch] [--json] <flow.ts|flow.yaml|spec.json>',
113
115
  'flows serve-webhook --data-dir <dir> --port <p> [--allow <name>[,<name>]]',
114
- 'flows run [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent] [--reuse-from <run-id>] <flow.yaml|spec.json>',
116
+ 'flows run [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent [--agent-capacity <n>]] [--reuse-from <run-id>] <flow.yaml|spec.json>',
115
117
  'flows run --cloud [--json] [--wait] [--sync-code] [--no-connect] <flow.yaml|spec.json>',
116
118
  'flows run --cloud [--json] [--wait] [--sync-code] [--no-connect] <flow.ts> --input <inline-json-or-file>',
117
119
  'flows sync [--json] [--dry-run] [--dir <path>] <run-id>',
118
- 'flows run [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent] <flow.ts> --input <inline-json-or-file>',
120
+ 'flows run [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent [--agent-capacity <n>]] <flow.ts> --input <inline-json-or-file>',
119
121
  'flows tick start --schedule-id <id> --interval-ms <ms> [--epoch-ms <ms>] [--max-catch-up <n>] [--poll-interval-ms <ms>] [--data-dir <dir>] <spec.json>',
120
- 'flows resume [--allow-human-influenced] [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent] <run-id>',
122
+ 'flows resume [--allow-human-influenced] [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent [--agent-capacity <n>]] <run-id>',
121
123
  'flows answer [--json] [--no-spawn] [--data-dir <dir>] [--note <text>] [--by <identity>] <run-id> <wait-id> <yes|no>',
122
124
  'flows replay [--allow-human-influenced] [--json] [--data-dir <dir>] <run-id> [--at <step-id>]',
123
125
  'flows status [--json] [--data-dir <dir>] [--tail <n>] [<run-id>]',
@@ -295,6 +297,7 @@ export async function runCli(
295
297
  onPtyReady: (path: string) => io.stderr(`PTY ${path}`),
296
298
  ...(parsed.command === 'run' && parsed.reuseFromRunId !== undefined ? { reuseFromRunId: parsed.reuseFromRunId } : {}),
297
299
  localAgent: parsed.localAgent,
300
+ ...(parsed.agentCapacity === undefined ? {} : { agentCapacity: parsed.agentCapacity }),
298
301
  onProgress: showProgress,
299
302
  onWait: (progress: RunProgress) => {
300
303
  emitWait(progress, io);
@@ -581,6 +584,7 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
581
584
  let syncCode = false;
582
585
  let noConnect = false;
583
586
  let localAgent = false;
587
+ let agentCapacity: number | undefined;
584
588
  let allowHumanInfluenced = false;
585
589
  let dataDir = DEFAULT_DATA_DIR;
586
590
  let sawDataDir = false;
@@ -612,6 +616,14 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
612
616
  localAgent = true;
613
617
  continue;
614
618
  }
619
+ if (argument === '--agent-capacity') {
620
+ // Decimal digits only: `Number` would also read "0x4", "4e0" and " 4".
621
+ const value = args[++index];
622
+ if (command === 'check' || agentCapacity !== undefined || value === undefined || !/^[0-9]+$/.test(value)) return undefined;
623
+ agentCapacity = Number(value);
624
+ if (!isAgentCapacity(agentCapacity)) return undefined;
625
+ continue;
626
+ }
615
627
  if (argument === '--watch') {
616
628
  if (command !== 'check' || watch) return undefined;
617
629
  watch = true;
@@ -669,6 +681,8 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
669
681
  positionals.push(argument);
670
682
  }
671
683
  if (positionals.length !== 1) return undefined;
684
+ // It sizes the in-process worker, so without `--local-agent` it describes nothing.
685
+ if (agentCapacity !== undefined && !localAgent) return undefined;
672
686
 
673
687
  if (bucket !== undefined && (cloud || !parseDigestReference(positionals[0]!))) return undefined;
674
688
  if (cloud) {
@@ -688,8 +702,8 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
688
702
  return command === 'check'
689
703
  ? { command, json, watch, value: positionals[0]! }
690
704
  : command === 'run'
691
- ? { command, bucket, reuseFromRunId, localAgent, dataDir, input, json, spawn, noObserverLink, allowHumanInfluenced, value: positionals[0]! }
692
- : { command, localAgent, dataDir, json, spawn, noObserverLink, allowHumanInfluenced, value: positionals[0]! };
705
+ ? { command, bucket, reuseFromRunId, localAgent, agentCapacity, dataDir, input, json, spawn, noObserverLink, allowHumanInfluenced, value: positionals[0]! }
706
+ : { command, localAgent, agentCapacity, dataDir, json, spawn, noObserverLink, allowHumanInfluenced, value: positionals[0]! };
693
707
  }
694
708
 
695
709
  /**
@@ -927,6 +941,17 @@ function parseTickArgs(rest: readonly string[]): ParsedArgs | undefined {
927
941
  };
928
942
  }
929
943
 
944
+ const MODEL_PROVENANCE: Readonly<Record<CliModelSource, string>> = {
945
+ step: 'step',
946
+ named: 'named agent',
947
+ adapter: 'adapter default',
948
+ };
949
+
950
+ /** A resolution carrying no model source prints no provenance rather than a guess. */
951
+ function modelProvenance(source: CliModelSource | undefined): string {
952
+ return source === undefined ? '' : ` from ${MODEL_PROVENANCE[source]}`;
953
+ }
954
+
930
955
  function emitCheckReport(report: CheckReport, json: boolean, io: CliIo): void {
931
956
  emitDiagnostics(report.diagnostics, io);
932
957
  if (json) {
@@ -963,8 +988,14 @@ function emitCheckReport(report: CheckReport, json: boolean, io: CliIo): void {
963
988
  const config = resolution.source === 'project' && report.projectConfigPath !== undefined
964
989
  ? ` (${report.projectConfigPath})`
965
990
  : '';
966
- const model = resolution.model === undefined ? '' : ` model "${resolution.model}"`;
967
- io.stdout(`RESOLVED step "${resolution.stepId}" cli "${resolution.cli}"${model} from ${resolution.source}${config}`);
991
+ // `from <source>` describes whatever noun precedes it, and the source here
992
+ // is the CLI's. Printing it after the model made every step whose CLI and
993
+ // model come from different places claim the model was declared where the
994
+ // CLI was — a step `cli:` plus an adapter default read as `from step`.
995
+ const model = resolution.model === undefined
996
+ ? ''
997
+ : ` model "${resolution.model}"${modelProvenance(resolution.modelSource)}`;
998
+ io.stdout(`RESOLVED step "${resolution.stepId}" cli "${resolution.cli}" from ${resolution.source}${config}${model}`);
968
999
  }
969
1000
  // What the workspace must have connected before this flow can run there;
970
1001
  // the hosted verbs check the same list against Cloud before submitting.
@@ -177,9 +177,17 @@ export interface StepFailedDetails {
177
177
  */
178
178
  maxIterations?: number;
179
179
  exitCode?: number;
180
- /** Terminal-safe UTF-8 excerpt, at most 1,024 bytes. */
180
+ /**
181
+ * Terminal-safe UTF-8 excerpt of the captured stdout, at most
182
+ * `EXCERPT_BYTES` (cli/step-excerpt.ts). The field name is kept for
183
+ * compatibility; it is no longer a plain tail. When the captured field did
184
+ * not fit, the excerpt keeps a head, the lines from the elided middle that
185
+ * match a failure marker, and a tail, and states the elision in band. The
186
+ * absence of an elision marker means the field the journal carried fit
187
+ * whole — not that the command printed no more than this.
188
+ */
181
189
  stdoutTail?: string;
182
- /** Terminal-safe UTF-8 excerpt, at most 1,024 bytes. */
190
+ /** Terminal-safe UTF-8 excerpt of the captured stderr; see `stdoutTail`. */
183
191
  stderrTail?: string;
184
192
  /**
185
193
  * The daemon's own account when it was not a render of the fields above, or