@relayflows/sdk 2.0.27 → 2.0.29

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 (128) 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 +36 -0
  31. package/dist/authored-step-index.d.ts.map +1 -1
  32. package/dist/authored-step-index.js +68 -15
  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/status.d.ts +12 -0
  50. package/dist/cli/status.d.ts.map +1 -1
  51. package/dist/cli/status.js +39 -1
  52. package/dist/cli/status.js.map +1 -1
  53. package/dist/cli/step-excerpt.d.ts +48 -0
  54. package/dist/cli/step-excerpt.d.ts.map +1 -0
  55. package/dist/cli/step-excerpt.js +302 -0
  56. package/dist/cli/step-excerpt.js.map +1 -0
  57. package/dist/cli/step-failure.d.ts.map +1 -1
  58. package/dist/cli/step-failure.js +9 -20
  59. package/dist/cli/step-failure.js.map +1 -1
  60. package/dist/cli-commands.d.ts +8 -0
  61. package/dist/cli-commands.d.ts.map +1 -1
  62. package/dist/cli-commands.js +6 -0
  63. package/dist/cli-commands.js.map +1 -1
  64. package/dist/cli.d.ts +2 -0
  65. package/dist/cli.d.ts.map +1 -1
  66. package/dist/cli.js +38 -7
  67. package/dist/cli.js.map +1 -1
  68. package/dist/failure-kinds.d.ts +10 -2
  69. package/dist/failure-kinds.d.ts.map +1 -1
  70. package/dist/failure-kinds.js.map +1 -1
  71. package/dist/journal-client.d.ts +8 -2
  72. package/dist/journal-client.d.ts.map +1 -1
  73. package/dist/journal-client.js +22 -6
  74. package/dist/journal-client.js.map +1 -1
  75. package/dist/llm-worker.d.ts +2 -1
  76. package/dist/llm-worker.d.ts.map +1 -1
  77. package/dist/llm-worker.js +5 -2
  78. package/dist/llm-worker.js.map +1 -1
  79. package/dist/local-agent.d.ts +1 -1
  80. package/dist/local-agent.d.ts.map +1 -1
  81. package/dist/local-agent.js +8 -3
  82. package/dist/local-agent.js.map +1 -1
  83. package/dist/preflight.d.ts.map +1 -1
  84. package/dist/preflight.js +29 -3
  85. package/dist/preflight.js.map +1 -1
  86. package/dist/promise-ancestry.d.ts +20 -0
  87. package/dist/promise-ancestry.d.ts.map +1 -0
  88. package/dist/promise-ancestry.js +104 -0
  89. package/dist/promise-ancestry.js.map +1 -0
  90. package/dist/validate.d.ts.map +1 -1
  91. package/dist/validate.js +7 -0
  92. package/dist/validate.js.map +1 -1
  93. package/dist/worker-cli.d.ts +7 -0
  94. package/dist/worker-cli.d.ts.map +1 -1
  95. package/dist/worker-cli.js +42 -10
  96. package/dist/worker-cli.js.map +1 -1
  97. package/dist/worker-slots.d.ts +42 -0
  98. package/dist/worker-slots.d.ts.map +1 -0
  99. package/dist/worker-slots.js +94 -0
  100. package/dist/worker-slots.js.map +1 -0
  101. package/package.json +2 -2
  102. package/src/authored-flow-executor.ts +22 -3
  103. package/src/authored-flow-lifecycle.ts +111 -16
  104. package/src/authored-flow-operation.ts +10 -0
  105. package/src/authored-node-entry.ts +4 -1
  106. package/src/authored-node-runner.ts +2 -1
  107. package/src/authored-promise-graph.ts +46 -19
  108. package/src/authored-root.ts +4 -0
  109. package/src/authored-step-graph.ts +246 -0
  110. package/src/authored-step-index.ts +90 -13
  111. package/src/authored-step-output.ts +7 -3
  112. package/src/authored-worker-step.ts +25 -6
  113. package/src/cli/direct-run.ts +5 -2
  114. package/src/cli/run.ts +11 -4
  115. package/src/cli/status.ts +50 -1
  116. package/src/cli/step-excerpt.ts +332 -0
  117. package/src/cli/step-failure.ts +9 -20
  118. package/src/cli-commands.ts +6 -0
  119. package/src/cli.ts +40 -9
  120. package/src/failure-kinds.ts +10 -2
  121. package/src/journal-client.ts +25 -6
  122. package/src/llm-worker.ts +7 -2
  123. package/src/local-agent.ts +8 -2
  124. package/src/preflight.ts +37 -2
  125. package/src/promise-ancestry.ts +98 -0
  126. package/src/validate.ts +7 -0
  127. package/src/worker-cli.ts +41 -9
  128. package/src/worker-slots.ts +92 -0
@@ -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
@@ -61,6 +61,8 @@ export class JournalProtocolError extends Error {
61
61
 
62
62
  export class JournalClient extends EventEmitter {
63
63
  private socket: Socket | null = null;
64
+ /** Why the connection ended, so a later "not connected" names its cause rather than hiding it. */
65
+ private disconnectCause: Error | undefined;
64
66
  private buffer = '';
65
67
  private readonly pending = new Map<string, Pending>();
66
68
  private readonly requestTimeoutMs: number;
@@ -103,18 +105,31 @@ export class JournalClient extends EventEmitter {
103
105
  socket.once('connect', () => {
104
106
  clearTimeout(timer);
105
107
  socket.removeListener('error', onError);
106
- socket.on('error', (err) => this.failAll(err));
108
+ socket.on('error', (err) => { this.disconnectCause ??= err; this.failAll(err); });
107
109
  socket.on('data', (chunk) => this.onData(chunk));
108
- socket.on('close', () => this.failAll(new Error('journal client: connection closed')));
110
+ socket.on('close', () => {
111
+ const closed = new Error('journal client: connection closed');
112
+ this.disconnectCause ??= closed;
113
+ this.failAll(closed);
114
+ });
115
+ this.disconnectCause = undefined;
109
116
  this.socket = socket;
110
117
  resolve();
111
118
  });
112
119
  });
113
120
  }
114
121
 
115
- /** Close the connection and reject any pending requests. */
116
- close(): void {
117
- this.failAll(new Error('journal client: closed by caller'));
122
+ /**
123
+ * Close the connection and reject any pending requests. A `cause` (for
124
+ * example the worker failure that forced the close) is carried into every
125
+ * later "not connected" rejection.
126
+ */
127
+ close(cause?: unknown): void {
128
+ const closed = cause === undefined
129
+ ? new Error('journal client: closed by caller')
130
+ : new Error(`journal client: closed after ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
131
+ this.disconnectCause ??= closed;
132
+ this.failAll(closed);
118
133
  this.socket?.destroy();
119
134
  this.socket = null;
120
135
  this.buffer = '';
@@ -170,7 +185,11 @@ export class JournalClient extends EventEmitter {
170
185
  ): Promise<VerbContract[V]['result']> {
171
186
  return new Promise((resolve, reject) => {
172
187
  if (!this.socket || this.socket.destroyed) {
173
- reject(new Error(`journal client: not connected (${verb})`));
188
+ const cause = this.disconnectCause;
189
+ reject(new Error(
190
+ `journal client: not connected (${verb})${cause === undefined ? '' : `: ${cause.message}`}`,
191
+ cause === undefined ? undefined : { cause },
192
+ ));
174
193
  return;
175
194
  }
176
195
  const id = randomUUID();
package/src/llm-worker.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { workerSpend } from './worker-spend.js';
2
+ import { DEFAULT_LOCAL_AGENT_CAPACITY } from './worker-slots.js';
2
3
  import type { WorkerCliResult } from './worker-cli.js';
3
4
  import { EventEmitter } from 'node:events';
4
5
  import type { JournalClient } from './journal-client.js';
@@ -17,7 +18,11 @@ export class LlmWorker extends EventEmitter {
17
18
  private closing = false;
18
19
  private readonly inFlight = new Set<Promise<void>>();
19
20
 
20
- constructor(private readonly client: JournalClient, private readonly workerId: string) {
21
+ constructor(
22
+ private readonly client: JournalClient,
23
+ private readonly workerId: string,
24
+ private readonly capacity: number = DEFAULT_LOCAL_AGENT_CAPACITY,
25
+ ) {
21
26
  super();
22
27
  }
23
28
 
@@ -25,7 +30,7 @@ export class LlmWorker extends EventEmitter {
25
30
  if (this.attached || this.closing) throw new Error('llm worker: cannot attach twice or after close');
26
31
  this.client.on('step.dispatch', this.onDispatch);
27
32
  try {
28
- await this.client.workerAttach(this.workerId, ['llm'], { workspace: [], streams: [] }, 1);
33
+ await this.client.workerAttach(this.workerId, ['llm'], { workspace: [], streams: [] }, this.capacity);
29
34
  this.attached = true;
30
35
  } catch (error) {
31
36
  this.client.off('step.dispatch', this.onDispatch);
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import type { JournalClient } from './journal-client.js';
3
3
  import { AgentWorker } from './worker.js';
4
+ import { DEFAULT_LOCAL_AGENT_CAPACITY } from './worker-slots.js';
4
5
 
5
6
  /** A local worker for stream-only steps; no workspace recovery is claimed. */
6
7
  export async function attachLocalAgent(
@@ -8,6 +9,7 @@ export async function attachLocalAgent(
8
9
  dataDir?: string,
9
10
  onPtyReady?: (path: string) => void,
10
11
  requestedStream?: string,
12
+ capacity: number = DEFAULT_LOCAL_AGENT_CAPACITY,
11
13
  ): Promise<{
12
14
  stream: string;
13
15
  readonly failure: unknown;
@@ -18,12 +20,16 @@ export async function attachLocalAgent(
18
20
  const stream = requestedStream ?? `local-agent-${randomUUID()}`;
19
21
  const worker = new AgentWorker(client, {
20
22
  workerId: stream,
21
- capacity: 1,
23
+ // More than one: independent agent steps run side by side instead of the
24
+ // second parking behind the first. Authored bodies size their admission to
25
+ // this same number (worker-slots.ts), so they never ask for more.
26
+ capacity,
22
27
  dataDir, onPtyReady,
23
28
  pins: { workspace: [], streams: [{ stream, read_offset: 0 }] },
24
29
  });
25
30
  let failure: unknown;
26
- worker.on('error', error => { failure = error; client.close(); });
31
+ // The cause travels with the close, so the flow's next request names it.
32
+ worker.on('error', error => { failure = error; client.close(error); });
27
33
  await worker.attach();
28
34
  return {
29
35
  stream,
package/src/preflight.ts CHANGED
@@ -395,7 +395,9 @@ function unknownModelDiagnostics(
395
395
  stepId: step.id,
396
396
  ...(resolution === undefined ? {} : { cli: resolution.cli }),
397
397
  model,
398
- message: unknownModelMessage(step.id, model, resolution?.cli, options.modelRegistryPath),
398
+ message: unknownModelMessage(
399
+ step.id, model, resolution?.cli, options.modelRegistryPath, resolution?.modelSource,
400
+ ),
399
401
  });
400
402
  }
401
403
 
@@ -418,15 +420,46 @@ function isKnownModel(model: string, models: readonly string[] | undefined): boo
418
420
  return models?.includes(model) === true;
419
421
  }
420
422
 
423
+ /**
424
+ * Attribute an effective model to where its value actually came from. Only a
425
+ * `step` value was written on the step; saying "declares" about an adapter
426
+ * default sends the reader looking for a `model:` key that is not there.
427
+ */
428
+ function describeEffectiveModel(
429
+ stepId: string,
430
+ model: string,
431
+ cli: string,
432
+ modelSource: CliModelSource | undefined,
433
+ ): string {
434
+ if (modelSource === 'adapter') {
435
+ return `Step "${stepId}" declares no model; the default for CLI "${cli}" is "${model}"`;
436
+ }
437
+ if (modelSource === 'named') {
438
+ return `Step "${stepId}" uses model "${model}" from its named agent for CLI "${cli}"`;
439
+ }
440
+ return `Step "${stepId}" declares model "${model}" for CLI "${cli}"`;
441
+ }
442
+
421
443
  function unknownModelMessage(
422
444
  stepId: string,
423
445
  model: string,
424
446
  cli: string | undefined,
425
447
  registryPath: string | undefined,
448
+ modelSource: CliModelSource | undefined,
426
449
  ): string {
427
450
  const source = registryPath === undefined
428
451
  ? 'the nearest project config (no model registry was found)'
429
452
  : `project model registry "${registryPath}"`;
453
+ if (modelSource === 'adapter' && cli !== undefined) {
454
+ // Registry policy still governs a default — it is the model that will run
455
+ // — but the remedy has to name the value the author never typed, and the
456
+ // second way out: declaring a model the registry already allows.
457
+ const add = registryPath === undefined
458
+ ? 'add the exact model'
459
+ : `add the exact model to "models" in "${registryPath}"`;
460
+ return `${describeEffectiveModel(stepId, model, cli, modelSource)}, which is not listed in ${source}; `
461
+ + `${add} only after verifying that project is allowed to use it, or declare an allowed model on the step.`;
462
+ }
430
463
  const cliContext = cli === undefined ? '' : ` for CLI "${cli}"`;
431
464
  return `Step "${stepId}" declares model "${model}"${cliContext}, but it is not listed in ${source}; add the exact model only after verifying that project is allowed to use it.`;
432
465
  }
@@ -545,7 +578,9 @@ function probeResolvedCli(
545
578
  stepId: resolution.stepId,
546
579
  cli: resolution.cli,
547
580
  model: resolution.model,
548
- message: `Step "${resolution.stepId}" declares model "${resolution.model}" for CLI "${resolution.cli}", but its model-scoped "${result.modelCommand ?? `${resolution.cli} auth status`}" probe exited non-zero; verify the model name and this credential's access.`,
581
+ message: describeEffectiveModel(resolution.stepId, resolution.model, resolution.cli, resolution.modelSource)
582
+ + `, but its model-scoped "${result.modelCommand ?? `${resolution.cli} auth status`}" probe exited non-zero;`
583
+ + ` verify the model name and this credential's access.`,
549
584
  });
550
585
  }
551
586
  }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Which of a fixed set of `targets` each of `starts` can reach by following
3
+ * `edgesOf` — the batch form of asking `dependsOn(start, target)` for every
4
+ * pair, with the same answer (a node reaches itself).
5
+ *
6
+ * The completion gate used to ask that pair-by-pair, re-walking each start's
7
+ * whole ancestry every time: operations × combinator members × tracked
8
+ * promises. A flow of ~110 steps whose agents ran for twenty minutes produced
9
+ * enough tracked promises (journal polling while each step waited) that the
10
+ * gate ran for three minutes without yielding, starving the root's lease
11
+ * renewal until the run was reaped as crashed.
12
+ *
13
+ * Here the upward closure of every start is visited once, cycles (a promise
14
+ * resolved from a context created after it) are condensed with an iterative
15
+ * Tarjan pass, and target sets flow as bitsets from ancestors to descendants
16
+ * in the order Tarjan emits components. Cost is O((V + E) · ⌈T / 32⌉) for the
17
+ * visited subgraph, whatever the number of starts.
18
+ */
19
+ export function reachableTargets(
20
+ starts: readonly number[],
21
+ targets: readonly number[],
22
+ edgesOf: (node: number) => readonly number[],
23
+ ): Map<number, ReadonlySet<number>> {
24
+ const bit = new Map<number, number>();
25
+ for (const target of targets) if (!bit.has(target)) bit.set(target, bit.size);
26
+ const words = Math.max(1, Math.ceil(bit.size / 32));
27
+
28
+ const index = new Map<number, number>();
29
+ const low = new Map<number, number>();
30
+ const onStack = new Set<number>();
31
+ const stack: number[] = [];
32
+ const component = new Map<number, number>();
33
+ const labels: Uint32Array[] = [];
34
+
35
+ const closeComponent = (root: number): void => {
36
+ const label = new Uint32Array(words);
37
+ const members: number[] = [];
38
+ let member: number;
39
+ do {
40
+ member = stack.pop()!;
41
+ onStack.delete(member);
42
+ members.push(member);
43
+ } while (member !== root);
44
+ const id = labels.length;
45
+ for (const node of members) component.set(node, id);
46
+ for (const node of members) {
47
+ const own = bit.get(node);
48
+ if (own !== undefined) label[own >>> 5]! |= 1 << (own & 31);
49
+ // Every edge leaving the component points at one Tarjan already closed.
50
+ for (const edge of edgesOf(node)) {
51
+ const target = component.get(edge);
52
+ if (target === undefined || target === id) continue;
53
+ const inherited = labels[target]!;
54
+ for (let word = 0; word < words; word++) label[word]! |= inherited[word]!;
55
+ }
56
+ }
57
+ labels.push(label);
58
+ };
59
+
60
+ // Iterative Tarjan: a deep promise chain must not overflow the JS stack.
61
+ for (const start of starts) {
62
+ if (index.has(start)) continue;
63
+ const frames: Array<{ node: number; edges: readonly number[]; next: number }> = [];
64
+ const open = (node: number): void => {
65
+ index.set(node, index.size);
66
+ low.set(node, index.get(node)!);
67
+ stack.push(node);
68
+ onStack.add(node);
69
+ frames.push({ node, edges: edgesOf(node), next: 0 });
70
+ };
71
+ open(start);
72
+ while (frames.length > 0) {
73
+ const frame = frames.at(-1)!;
74
+ if (frame.next < frame.edges.length) {
75
+ const edge = frame.edges[frame.next++]!;
76
+ if (!index.has(edge)) open(edge);
77
+ else if (onStack.has(edge)) low.set(frame.node, Math.min(low.get(frame.node)!, index.get(edge)!));
78
+ continue;
79
+ }
80
+ frames.pop();
81
+ const parent = frames.at(-1);
82
+ if (parent !== undefined) low.set(parent.node, Math.min(low.get(parent.node)!, low.get(frame.node)!));
83
+ if (low.get(frame.node) === index.get(frame.node)) closeComponent(frame.node);
84
+ }
85
+ }
86
+
87
+ const reached = new Map<number, ReadonlySet<number>>();
88
+ for (const start of starts) {
89
+ if (reached.has(start)) continue;
90
+ const label = labels[component.get(start)!]!;
91
+ const found = new Set<number>();
92
+ for (const [target, position] of bit) {
93
+ if ((label[position >>> 5]! & (1 << (position & 31))) !== 0) found.add(target);
94
+ }
95
+ reached.set(start, found);
96
+ }
97
+ return reached;
98
+ }
package/src/validate.ts CHANGED
@@ -486,6 +486,13 @@ class Validator {
486
486
  }
487
487
  this.validateCli(st.cli, at);
488
488
  this.validateModel(st.model, at);
489
+ // The kernel refuses a relative cwd at run.start; refuse it here so `flows
490
+ // check` and the run agree. Not resolved against the checking directory:
491
+ // that would make the compiled spec (and its hash) depend on where the
492
+ // check ran. `f.agent` resolves before compiling, so it never lands here.
493
+ if (st.cwd !== undefined && (!isNonEmptyString(st.cwd) || !st.cwd.startsWith('/'))) {
494
+ this.fail(`${at}.cwd: expected an absolute path (got ${JSON.stringify(st.cwd)})`);
495
+ }
489
496
  if (st.surfaces !== undefined) this.validateSurfaces(st.surfaces, `${at}.surfaces`);
490
497
  if (st.permissions !== undefined) this.validatePermissions(st.permissions, `${at}.permissions`);
491
498
  }
package/src/worker-cli.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { realpathSync } from 'node:fs';
2
- import { resolve, sep } from 'node:path';
2
+ import { basename, dirname, join, resolve, sep } from 'node:path';
3
3
  import { diffWorkspaceFiles, snapshotWorkspaceFiles } from './agent-artifacts.js';
4
4
  import { claudeResultOutcome, decodeProviderResult, decodeWrapperResult, requirePricedUsage } from './worker-usage.js';
5
5
  import { openSidechannel, type SidechannelContext } from './pty-sidechannel.js';
@@ -121,7 +121,7 @@ export async function runAgentCli(
121
121
  const artifactRoot = mode === 'agent' ? resolve(cwd ?? process.cwd()) : undefined;
122
122
  return artifactRoot === undefined
123
123
  ? execute()
124
- : serializedByDirectory(artifactRoot, async () => {
124
+ : serializedByDirectory(canonicalTree(artifactRoot), async () => {
125
125
  const before = await snapshotWorkspaceFiles(artifactRoot);
126
126
  const result = await execute();
127
127
  const kernelData = sidechannel === undefined ? undefined : realPath(resolve(sidechannel.dataDir));
@@ -214,20 +214,52 @@ function under(path: string | undefined, directory: string | undefined): boolean
214
214
  return path === directory || path.startsWith(directory.endsWith(sep) ? directory : directory + sep);
215
215
  }
216
216
 
217
+ /**
218
+ * Symlink-free form of an absolute path that may not exist yet: the deepest
219
+ * existing ancestor is resolved and the missing tail is kept. A plain
220
+ * `realPath` fallback would leave `/link/new` unresolved while `/link`
221
+ * resolves, and the overlap check would then treat them as disjoint trees.
222
+ */
223
+ export function canonicalTree(path: string): string {
224
+ const missing: string[] = [];
225
+ let current = path;
226
+ for (;;) {
227
+ try {
228
+ return join(realpathSync(current), ...missing.reverse());
229
+ } catch {
230
+ const parent = dirname(current);
231
+ if (parent === current) return path;
232
+ missing.push(basename(current));
233
+ current = parent;
234
+ }
235
+ }
236
+ }
237
+
217
238
  /** Symlink-free form of a path, or the path itself when it cannot be resolved. */
218
239
  function realPath(path: string | undefined): string | undefined {
219
240
  if (path === undefined) return undefined;
220
241
  try { return realpathSync(path); } catch { return path; }
221
242
  }
222
243
 
223
- /** One agent at a time per canonical working directory, for the artifact interval. */
224
- const directoryQueues = new Map<string, Promise<unknown>>();
244
+ /**
245
+ * One agent at a time per overlapping working tree, for the artifact interval.
246
+ *
247
+ * `directory` must be canonical (symlink-free): `/repo` and a `/tmp/link` to it
248
+ * are one tree. Two trees overlap when one contains the other, because the
249
+ * snapshot walks the whole subtree — an agent in `/repo` would otherwise be
250
+ * credited with files an agent in `/repo/.wt/api` wrote meanwhile. Disjoint
251
+ * trees (sibling worktrees) run side by side. Each run waits for every
252
+ * earlier-registered overlapping run, so order within an overlap is arrival.
253
+ */
254
+ const directoryRuns = new Set<{ readonly directory: string; readonly settled: Promise<void> }>();
225
255
  function serializedByDirectory<T>(directory: string, task: () => Promise<T>): Promise<T> {
226
- const previous = directoryQueues.get(directory) ?? Promise.resolve();
227
- const run = previous.then(task, task);
228
- const settled = run.then(() => undefined, () => undefined);
229
- directoryQueues.set(directory, settled);
230
- void settled.then(() => { if (directoryQueues.get(directory) === settled) directoryQueues.delete(directory); });
256
+ const blockers = [...directoryRuns]
257
+ .filter(other => under(other.directory, directory) || under(directory, other.directory))
258
+ .map(other => other.settled);
259
+ const run = Promise.all(blockers).then(task);
260
+ const entry = { directory, settled: run.then(() => undefined, () => undefined) };
261
+ directoryRuns.add(entry);
262
+ void entry.settled.then(() => { directoryRuns.delete(entry); });
231
263
  return run;
232
264
  }
233
265
 
@@ -0,0 +1,92 @@
1
+ /** The local agent worker's default concurrency, and the ceiling `--agent-capacity` accepts. */
2
+ export const DEFAULT_LOCAL_AGENT_CAPACITY = 4;
3
+ export const MAX_LOCAL_AGENT_CAPACITY = 32;
4
+
5
+ /** A positive integer no larger than the ceiling; anything else is not a capacity. */
6
+ export function isAgentCapacity(value: unknown): value is number {
7
+ return Number.isInteger(value) && (value as number) >= 1 && (value as number) <= MAX_LOCAL_AGENT_CAPACITY;
8
+ }
9
+
10
+ /**
11
+ * First-come admission to a worker that holds `capacity` dispatches at once.
12
+ *
13
+ * The kernel never queues a step for a busy worker: an attempt with no worker
14
+ * below capacity parks the run ("no worker is attached for step type …"). An
15
+ * authored body opens one child run per `f.agent`/`f.llm`, so `Promise.all`
16
+ * over more calls than the worker holds would park the overflow. Holding the
17
+ * overflow here, before `run.start`, keeps the kernel's admission exact and
18
+ * turns "too many at once" into "wait for a slot".
19
+ */
20
+ export class WorkerSlots {
21
+ private held = 0;
22
+ private readonly waiting: Array<{ resolve: () => void; reject: (reason: unknown) => void }> = [];
23
+ private closed: { readonly reason: unknown } | undefined;
24
+
25
+ constructor(readonly capacity: number) {
26
+ if (!isAgentCapacity(capacity)) throw new RangeError(`worker capacity must be an integer from 1 to ${MAX_LOCAL_AGENT_CAPACITY} (got ${capacity})`);
27
+ }
28
+
29
+ async run<T>(work: () => Promise<T>): Promise<T> {
30
+ if (this.closed !== undefined) throw this.closed.reason;
31
+ if (this.held < this.capacity) this.held++;
32
+ // A released slot is handed straight to the next waiter, so `held` never
33
+ // dips below capacity while anyone is queued and no later caller can jump it.
34
+ else {
35
+ await new Promise<void>((resolve, reject) => this.waiting.push({ resolve, reject }));
36
+ // Woken with the slot, but the body may have failed in between (see release).
37
+ const closed = this.closedReason();
38
+ if (closed !== undefined) {
39
+ this.release();
40
+ throw closed.reason;
41
+ }
42
+ }
43
+ try {
44
+ return await work();
45
+ } finally {
46
+ this.release();
47
+ }
48
+ }
49
+
50
+ /** Read through a call so a check after an `await` is not narrowed away. */
51
+ private closedReason(): { readonly reason: unknown } | undefined {
52
+ return this.closed;
53
+ }
54
+
55
+ /**
56
+ * Hand the slot to the next waiter on a later macrotask, not synchronously.
57
+ * When the work that held it rejected and that rejection fails the body, the
58
+ * failure reaches `close` through microtasks only (operation → Promise.all →
59
+ * body → executor). Waking the waiter synchronously let it admit a child run
60
+ * before `close` could refuse it; deferring lets the teardown land first.
61
+ */
62
+ private release(): void {
63
+ const next = this.waiting.shift();
64
+ if (next === undefined) {
65
+ this.held--;
66
+ return;
67
+ }
68
+ setImmediate(() => {
69
+ if (this.closed === undefined) {
70
+ next.resolve();
71
+ return;
72
+ }
73
+ // Closed meanwhile. `close` could not see this waiter (already taken off
74
+ // the queue), so refuse it here, and pass the slot on instead of leaking it.
75
+ next.reject(this.closed.reason);
76
+ this.release();
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Refuse every queued and future admission with `reason`; work already
82
+ * holding a slot is not interrupted. The authored body calls this when it
83
+ * fails: a queued call is already `running` as an operation, so operation
84
+ * cancellation cannot reach it, and without this it would still be admitted
85
+ * and run after the flow had failed.
86
+ */
87
+ close(reason: unknown): void {
88
+ if (this.closed !== undefined) return;
89
+ this.closed = { reason };
90
+ for (const waiter of this.waiting.splice(0)) waiter.reject(reason);
91
+ }
92
+ }