@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
@@ -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
+ }