@relayflows/sdk 2.0.33 → 2.0.35

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 (83) hide show
  1. package/dist/authored-dispatch.d.ts +24 -0
  2. package/dist/authored-dispatch.d.ts.map +1 -0
  3. package/dist/authored-dispatch.js +81 -0
  4. package/dist/authored-dispatch.js.map +1 -0
  5. package/dist/authored-flow-error.d.ts +1 -1
  6. package/dist/authored-flow-error.d.ts.map +1 -1
  7. package/dist/authored-flow-error.js.map +1 -1
  8. package/dist/authored-flow-executor.d.ts +17 -0
  9. package/dist/authored-flow-executor.d.ts.map +1 -1
  10. package/dist/authored-flow-executor.js +92 -16
  11. package/dist/authored-flow-executor.js.map +1 -1
  12. package/dist/authored-flow-loader.d.ts.map +1 -1
  13. package/dist/authored-flow-loader.js +9 -0
  14. package/dist/authored-flow-loader.js.map +1 -1
  15. package/dist/authored-node-entry.js +1 -0
  16. package/dist/authored-node-entry.js.map +1 -1
  17. package/dist/authored-root.js +1 -0
  18. package/dist/authored-root.js.map +1 -1
  19. package/dist/authored-worker-step.d.ts +3 -1
  20. package/dist/authored-worker-step.d.ts.map +1 -1
  21. package/dist/authored-worker-step.js +18 -7
  22. package/dist/authored-worker-step.js.map +1 -1
  23. package/dist/cli/run.d.ts +5 -0
  24. package/dist/cli/run.d.ts.map +1 -1
  25. package/dist/cli/run.js +8 -0
  26. package/dist/cli/run.js.map +1 -1
  27. package/dist/cli.d.ts.map +1 -1
  28. package/dist/cli.js +3 -0
  29. package/dist/cli.js.map +1 -1
  30. package/dist/cloud-mirror-step.d.ts +2 -0
  31. package/dist/cloud-mirror-step.d.ts.map +1 -1
  32. package/dist/cloud-mirror-step.js +9 -1
  33. package/dist/cloud-mirror-step.js.map +1 -1
  34. package/dist/cloud-mirror.d.ts.map +1 -1
  35. package/dist/cloud-mirror.js +17 -2
  36. package/dist/cloud-mirror.js.map +1 -1
  37. package/dist/communication/environment.d.ts +2 -0
  38. package/dist/communication/environment.d.ts.map +1 -1
  39. package/dist/communication/environment.js +13 -7
  40. package/dist/communication/environment.js.map +1 -1
  41. package/dist/flow-extension-compat.d.ts.map +1 -1
  42. package/dist/flow-extension-compat.js +3 -1
  43. package/dist/flow-extension-compat.js.map +1 -1
  44. package/dist/hosted-extension-declarations.d.ts.map +1 -1
  45. package/dist/hosted-extension-declarations.js +4 -0
  46. package/dist/hosted-extension-declarations.js.map +1 -1
  47. package/dist/hosted-extension-isolation.d.ts +19 -2
  48. package/dist/hosted-extension-isolation.d.ts.map +1 -1
  49. package/dist/hosted-extension-isolation.js +29 -3
  50. package/dist/hosted-extension-isolation.js.map +1 -1
  51. package/dist/hosted-extension-runtime.js +2 -2
  52. package/dist/hosted-extension-sandbox.d.ts +2 -0
  53. package/dist/hosted-extension-sandbox.d.ts.map +1 -1
  54. package/dist/hosted-extension-sandbox.js +20 -6
  55. package/dist/hosted-extension-sandbox.js.map +1 -1
  56. package/dist/index.d.ts +1 -1
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +1 -1
  59. package/dist/index.js.map +1 -1
  60. package/dist/worker-slots.d.ts +15 -0
  61. package/dist/worker-slots.d.ts.map +1 -1
  62. package/dist/worker-slots.js +40 -3
  63. package/dist/worker-slots.js.map +1 -1
  64. package/package.json +2 -2
  65. package/src/authored-dispatch.ts +113 -0
  66. package/src/authored-flow-error.ts +3 -0
  67. package/src/authored-flow-executor.ts +126 -15
  68. package/src/authored-flow-loader.ts +12 -0
  69. package/src/authored-node-entry.ts +1 -0
  70. package/src/authored-root.ts +1 -0
  71. package/src/authored-worker-step.ts +17 -6
  72. package/src/cli/run.ts +10 -0
  73. package/src/cli.ts +3 -0
  74. package/src/cloud-mirror-step.ts +16 -2
  75. package/src/cloud-mirror.ts +15 -2
  76. package/src/communication/environment.ts +15 -8
  77. package/src/flow-extension-compat.ts +3 -1
  78. package/src/hosted-extension-declarations.ts +4 -0
  79. package/src/hosted-extension-isolation.ts +38 -2
  80. package/src/hosted-extension-runtime.ts +2 -2
  81. package/src/hosted-extension-sandbox.ts +18 -3
  82. package/src/index.ts +2 -0
  83. package/src/worker-slots.ts +63 -4
@@ -152,6 +152,8 @@ export interface MirroredJournal {
152
152
  children: string[];
153
153
  /** Authored graph hints by step id, from an authored root's step index. */
154
154
  hints: Map<string, { label?: string; after?: string[] }>;
155
+ /** Authored completion receipts are durable evidence, not graph nodes. */
156
+ hidden: string[];
155
157
  }
156
158
 
157
159
  type Payload = Record<string, unknown>;
@@ -507,6 +509,7 @@ export function mirrorJournal(
507
509
  transcripts,
508
510
  children: index.children,
509
511
  hints: index.hints,
512
+ hidden: index.hidden,
510
513
  };
511
514
  }
512
515
 
@@ -522,7 +525,11 @@ export function mirrorJournal(
522
525
  function authoredIndex(
523
526
  events: readonly JournalEvent[],
524
527
  env: NodeJS.ProcessEnv,
525
- ): { children: string[]; hints: Map<string, { label?: string; after?: string[] }> } {
528
+ ): {
529
+ children: string[];
530
+ hints: Map<string, { label?: string; after?: string[] }>;
531
+ hidden: string[];
532
+ } {
526
533
  const messages: unknown[] = [];
527
534
  for (const event of events) {
528
535
  if (event.entry_type !== 'stream.appended') continue;
@@ -531,8 +538,15 @@ function authoredIndex(
531
538
  }
532
539
  const children: string[] = [];
533
540
  const hints = new Map<string, { label?: string; after?: string[] }>();
541
+ const hidden: string[] = [];
534
542
  for (const entry of foldAuthoredStepRecords(messages).values()) {
535
543
  if (/^[A-Za-z0-9][A-Za-z0-9_-]*$/u.test(entry.runId)) children.push(entry.runId);
544
+ // This is the executor's durable verdict receipt, not an authored unit of
545
+ // work. Only an entry in the authored root index can be hidden here, so a
546
+ // declarative step coincidentally named `complete-N` remains visible.
547
+ if (/(?:^|--)complete-[1-9][0-9]*$/.test(entry.step)) {
548
+ hidden.push(`${entry.runId}/${entry.step}`);
549
+ }
536
550
  const name = label(entry.label, env);
537
551
  const after = entry.after === undefined
538
552
  ? undefined
@@ -546,7 +560,7 @@ function authoredIndex(
546
560
  ...(after === undefined ? {} : { after }),
547
561
  });
548
562
  }
549
- return { children, hints };
563
+ return { children, hints, hidden };
550
564
  }
551
565
 
552
566
  /**
@@ -287,6 +287,7 @@ export function createRunMirror(options: RunMirrorOptions): RunMirror {
287
287
  const finals = new Map<string, { journalRunId: string; row: FinalStep }>();
288
288
  const transcripts = new Map<string, { stepName: string; attempts: Array<{ attempt: number; path: string }> }>();
289
289
  const hints = new Map<string, { label?: string; after?: string[] }>();
290
+ const hidden = new Set<string>();
290
291
  /** Live steps the cache cap dropped, by identity, so a capped view owns up to it. */
291
292
  const evicted = new Set<string>();
292
293
  /** Finished steps past the report cap, by identity. Counted, never sent. */
@@ -318,6 +319,7 @@ export function createRunMirror(options: RunMirrorOptions): RunMirror {
318
319
 
319
320
  const remember = (journalRunId: string, step: SnapshotStep, order: number): void => {
320
321
  const key = `${journalRunId}/${step.stepName}`;
322
+ if (hidden.has(key)) return;
321
323
  const existing = steps.get(key);
322
324
  const changed = existing === undefined || fingerprint(existing.step) !== fingerprint(step);
323
325
  steps.set(key, { step, order, changedAt: changed ? now() : existing!.changedAt });
@@ -365,19 +367,30 @@ export function createRunMirror(options: RunMirrorOptions): RunMirror {
365
367
  continue;
366
368
  }
367
369
  for (const child of folded.children) admit(child);
370
+ for (const key of folded.hidden) {
371
+ hidden.add(key);
372
+ steps.delete(key);
373
+ finals.delete(key);
374
+ transcripts.delete(key);
375
+ }
368
376
  for (const [key, hint] of folded.hints) {
369
377
  // Merge, never replace: a completion record can omit a label or an
370
378
  // edge the admission carried, and the view must not lose a step's
371
379
  // name between them.
372
380
  const existing = hints.get(key);
373
381
  hints.set(key, {
374
- ...(hint.label ?? existing?.label ? { label: hint.label ?? existing?.label } : {}),
375
- ...(hint.after ?? existing?.after ? { after: hint.after ?? existing?.after } : {}),
382
+ // The root's authored index is scanned before the child journal and
383
+ // carries causal edges across child runs. A child spec only knows
384
+ // its own single-step graph (`depends_on: []`), so it may fill a
385
+ // missing field but must never erase the root's richer answer.
386
+ ...(existing?.label ?? hint.label ? { label: existing?.label ?? hint.label } : {}),
387
+ ...(existing?.after ?? hint.after ? { after: existing?.after ?? hint.after } : {}),
376
388
  });
377
389
  }
378
390
  folded.steps.forEach((step, order) => remember(runId, step, order));
379
391
  for (const row of folded.finals) {
380
392
  const key = `${runId}/${row.stepName}`;
393
+ if (hidden.has(key)) continue;
381
394
  // The report cap bounds what this process holds, not just what it
382
395
  // sends: a run with ten thousand steps must not grow its own mirror.
383
396
  // Steps past it are counted by identity, so a step read twice is one
@@ -4,19 +4,26 @@ import { wrapperEnvironment } from '../wrapper-runtime.js';
4
4
  const terminalNames = ['TERM', 'COLORTERM', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_CACHE_HOME'];
5
5
  const relayNames = ['RELAY_API_KEY', 'RELAY_WORKSPACE_KEY', 'AGENT_RELAY_WORKSPACE_KEY',
6
6
  'RELAY_BROKER_API_KEY', 'RELAY_AGENT_TOKEN', 'RELAY_NODE_TOKEN', 'RELAY_WORKSPACES_JSON'];
7
- const providerNames: Record<string, string[]> = {
7
+ // Multi-provider CLIs select their provider in their own configuration.
8
+ const multiProvider = ['OPENAI_API_KEY', 'OPENAI_BASE_URL', 'ANTHROPIC_API_KEY', 'ANTHROPIC_BASE_URL',
9
+ 'GEMINI_API_KEY', 'GOOGLE_API_KEY', 'OPENROUTER_API_KEY'] as const;
10
+ const providerNames: Readonly<Record<string, readonly string[]>> = {
8
11
  claude: ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'CLAUDE_CODE_OAUTH_TOKEN', 'CLAUDE_CONFIG_DIR'],
9
12
  codex: ['OPENAI_API_KEY', 'OPENAI_BASE_URL', 'CODEX_HOME'],
10
13
  gemini: ['GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GOOGLE_APPLICATION_CREDENTIALS', 'GOOGLE_CLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION'],
11
- 'cursor-agent': ['CURSOR_API_KEY'],
14
+ // AgentWorkforce/cloud#4026 passes both endpoint names unchanged as aliases
15
+ // for one run-scoped House proxy base. Dropping either can let a compatible
16
+ // Cursor version fall back to its provider default and bypass the House route.
17
+ 'cursor-agent': ['CURSOR_API_KEY', 'CURSOR_API_ENDPOINT', 'CURSOR_API_BASE_URL'],
12
18
  droid: ['FACTORY_API_KEY'],
13
- grok: ['XAI_API_KEY'],
19
+ // AgentWorkforce/cloud#4026's house-provider run contract gives Grok an
20
+ // isolated home plus an xAI-compatible base URL. These remain Grok-scoped.
21
+ grok: ['XAI_API_KEY', 'GROK_XAI_API_BASE_URL', 'GROK_HOME'],
22
+ ...Object.fromEntries(['opencode', 'aider', 'goose', 'pi', 'deepagents'].map(cli => [cli, multiProvider])),
14
23
  };
15
- // Multi-provider CLIs select their provider in their own configuration.
16
- const multiProvider = ['OPENAI_API_KEY', 'OPENAI_BASE_URL', 'ANTHROPIC_API_KEY', 'ANTHROPIC_BASE_URL',
17
- 'GEMINI_API_KEY', 'GOOGLE_API_KEY', 'OPENROUTER_API_KEY'];
18
- for (const cli of ['opencode', 'aider', 'goose', 'pi', 'deepagents']) providerNames[cli] = multiProvider;
19
- function selected(source: NodeJS.ProcessEnv, names: string[]) {
24
+ /** @internal Names-only test seam; credential lists remain private. */
25
+ export function providerEnvironmentCliNames(): readonly string[] { return Object.keys(providerNames); }
26
+ function selected(source: NodeJS.ProcessEnv, names: readonly string[]) {
20
27
  return Object.fromEntries(names.filter(name => source[name] !== undefined).map(name => [name, source[name]]));
21
28
  }
22
29
  export function brokerEnvironment(source: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
@@ -42,7 +42,9 @@ function assertRuntimeRange(
42
42
  */
43
43
  export function assertBaseCompatible(manifest: FlowExtensionManifest, base: { readonly name: string; readonly version?: string }): void {
44
44
  let entry: FlowExtensionManifest['compat']['base'][number] | undefined;
45
- for (let index = 0; index < manifest.compat.base.length; index += 1) {
45
+ // First match, as `.find()` did before the intrinsic-capture rewrite: a
46
+ // later duplicate name must not widen or narrow the range that decides.
47
+ for (let index = 0; index < manifest.compat.base.length && entry === undefined; index += 1) {
46
48
  if (manifest.compat.base[index]!.name === base.name) entry = manifest.compat.base[index];
47
49
  }
48
50
  if (entry === undefined) {
@@ -67,6 +67,9 @@ const STRING_STARTS_WITH = Function.prototype.call.bind(
67
67
  ) as (value: string, search: string) => boolean;
68
68
  const LOCK_HEX64 = /^[0-9a-f]{64}$/;
69
69
  const LOCK_SHA = /^[0-9a-f]{40}$/;
70
+ // `name` reaches pluginStoreDirectory as a path segment, so it stays one
71
+ // lowercase kebab-case component: no separators, no `.`/`..`, no traversal.
72
+ const LOCK_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
70
73
  const LOCK_OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
71
74
  const LOCK_REPO = /^[A-Za-z0-9_.-]{1,100}$/;
72
75
  const HTTPS_GITHUB = /^https:\/\/github\.com\//;
@@ -240,6 +243,7 @@ function parseHostedPluginLock(input: unknown): {
240
243
  ]) ||
241
244
  entry.kind !== "flow-extension" ||
242
245
  typeof entry.name !== "string" ||
246
+ !REGEXP_TEST(LOCK_NAME, entry.name) ||
243
247
  typeof entry.version !== "string" ||
244
248
  typeof entry.digest !== "string" ||
245
249
  !REGEXP_TEST(LOCK_HEX64, entry.digest) ||
@@ -10,6 +10,7 @@ import {
10
10
  import {
11
11
  assertHostedInstallationAuthority,
12
12
  assertHostedRuntimeAuthority,
13
+ loadHostedExtensionRuntime,
13
14
  type HostedExtensionArtifact,
14
15
  type HostedExtensionBase,
15
16
  type HostedExtensionInstallation,
@@ -104,8 +105,42 @@ export interface RunHostedExtensionOptions {
104
105
  readonly prlimitPath?: string;
105
106
  }
106
107
 
108
+ export interface RunHostedSoftwareGardenBabysitterOptions
109
+ extends Omit<RunHostedExtensionOptions, 'installation' | 'base'> {
110
+ /** Exact reviewed Software Garden source inside its lock-backed project. */
111
+ readonly flowPath: string;
112
+ }
113
+
107
114
  export type HostedExtensionResult = HostedExtensionProtocolResult;
108
115
 
116
+ /**
117
+ * Compose the canonical Software Garden base with its installed native
118
+ * Babysitter and execute one host-verified delivery. Base source, extension
119
+ * declarations, lock entries and stored bytes are captured as one opaque
120
+ * generation; the capability runner rechecks that generation immediately
121
+ * before the isolated process starts.
122
+ *
123
+ * Hosted callers use this entrypoint instead of the ordinary authored loader,
124
+ * which imports extension JavaScript in the host and therefore keeps refusing
125
+ * matched hosted handlers.
126
+ */
127
+ export async function runHostedSoftwareGardenBabysitter(
128
+ options: RunHostedSoftwareGardenBabysitterOptions,
129
+ ): Promise<HostedExtensionResult> {
130
+ const runtime = await loadHostedExtensionRuntime(options.flowPath);
131
+ return await runHostedCapabilityExtension({
132
+ installation: runtime.installation,
133
+ base: runtime.base,
134
+ dispatch: options.dispatch,
135
+ input: options.input,
136
+ babysitterTurn: options.babysitterTurn,
137
+ ...(OBJECT_HAS_OWN(options, 'timeoutMs') ? { timeoutMs: options.timeoutMs } : {}),
138
+ ...(OBJECT_HAS_OWN(options, 'bubblewrapPath') ? { bubblewrapPath: options.bubblewrapPath } : {}),
139
+ ...(OBJECT_HAS_OWN(options, 'nodePath') ? { nodePath: options.nodePath } : {}),
140
+ ...(OBJECT_HAS_OWN(options, 'prlimitPath') ? { prlimitPath: options.prlimitPath } : {}),
141
+ });
142
+ }
143
+
109
144
  /**
110
145
  * Execute a capability-only hosted extension in a Linux mount/PID/network/user
111
146
  * namespace. The extension is first imported inside that namespace. It sees
@@ -114,8 +149,9 @@ export type HostedExtensionResult = HostedExtensionProtocolResult;
114
149
  * authenticated capability adapter and passes the original branded dispatch
115
150
  * authority to that adapter out of band.
116
151
  *
117
- * This is deliberately not wired into executeAuthoredFlow yet. #549's refusal
118
- * remains the rollout gate until the Cloud adapter and independent review land.
152
+ * The ordinary authored executor deliberately stays refused: it imports
153
+ * extension JavaScript in the host. `runHostedSoftwareGardenBabysitter` is the
154
+ * canonical composition boundary for a hosted Software Garden delivery.
119
155
  */
120
156
  export async function runHostedCapabilityExtension(
121
157
  options: RunHostedExtensionOptions,
@@ -26,7 +26,7 @@ const REALPATH = realpath;
26
26
  const PATH_DIRNAME = dirname;
27
27
  const PATH_JOIN = join;
28
28
  const PATH_RESOLVE = resolve;
29
- const SOFTWARE_FACTORY_SHA256 = 'b97a3466c2affabb61afa655d4b2f726942d753c0466740b64ba26a78da359c6';
29
+ const SOFTWARE_FACTORY_SHA256 = '8bbcf0e42f47d6bc6491a129935f96b791be03c8a5ed5b73d651c4e77913e2d7';
30
30
  const ARRAY_IS_ARRAY = Array.isArray;
31
31
  const OBJECT_FREEZE = Object.freeze;
32
32
  const WEAK_MAP_GET = Function.prototype.call.bind(WeakMap.prototype.get) as <K extends object, V>(
@@ -243,7 +243,7 @@ async function baseAt(origin: string, generation: RuntimeGeneration): Promise<Ho
243
243
  }
244
244
  const value = frozenHostedPromiseValue({
245
245
  name: 'software-factory',
246
- version: '2.0.22',
246
+ version: '2.0.23',
247
247
  });
248
248
  WEAK_SET_ADD(BASE_AUTHORITY, value);
249
249
  WEAK_MAP_SET(BASE_GENERATION, value, generation);
@@ -53,6 +53,22 @@ const PATH_RESOLVE = resolve;
53
53
  const SPAWN = spawn;
54
54
  const PROCESS_EXEC_PATH = process.execPath;
55
55
  const PROCESS_NODE_VERSION = process.versions.node;
56
+
57
+ /**
58
+ * The host's own `@relayflows/surface` entry, resolved once while this module
59
+ * initializes. `require.resolve` consults `Module._resolveFilename`, which is
60
+ * writable: resolving lazily would run after authored flow code has had a
61
+ * chance to replace it and could hand the sandbox a different package. The
62
+ * lookup therefore happens before any authored module can load, and a failure
63
+ * is reported when a caller actually needs the root.
64
+ */
65
+ const SURFACE_ENTRY: string | undefined = (() => {
66
+ try { return REALPATH_SYNC(CREATE_REQUIRE(import.meta.url).resolve('@relayflows/surface')); }
67
+ catch { return undefined; }
68
+ })();
69
+
70
+ /** @internal Seam for the regression that pins initialization-time capture. */
71
+ export function capturedSurfaceEntry(): string | undefined { return SURFACE_ENTRY; }
56
72
  const PROCESS_PLATFORM = process.platform;
57
73
  const PROMISE = Promise;
58
74
  const EVENT_ON = Function.prototype.call.bind(EventEmitter.prototype.on) as (
@@ -250,9 +266,8 @@ export async function runHostedExtensionSandbox(
250
266
 
251
267
  async function resolveSurfaceRoot(expectedVersion: string, override?: string): Promise<string> {
252
268
  if (override !== undefined) return await checkedSurfaceRoot(override, expectedVersion);
253
- let resolved: string;
254
- try { resolved = REALPATH_SYNC(CREATE_REQUIRE(import.meta.url).resolve('@relayflows/surface')); }
255
- catch { return unsupported('hosted extension cannot resolve @relayflows/surface'); }
269
+ const resolved = SURFACE_ENTRY;
270
+ if (resolved === undefined) return unsupported('hosted extension cannot resolve @relayflows/surface');
256
271
  let directory = PATH_DIRNAME(resolved);
257
272
  const root = PATH_PARSE(directory).root;
258
273
  while (directory !== root) {
package/src/index.ts CHANGED
@@ -61,6 +61,7 @@ export type { StepSpend } from './protocol.js';
61
61
  export { SPEC_SCHEMA_VERSION } from './spec.js';
62
62
 
63
63
  export {
64
+ runHostedSoftwareGardenBabysitter,
64
65
  runHostedCapabilityExtension,
65
66
  loadHostedExtensionRuntime,
66
67
  type HostedExtensionArtifact,
@@ -70,6 +71,7 @@ export {
70
71
  type HostedCapabilityAuthority,
71
72
  type HostedBabysitterCapability,
72
73
  type RunHostedExtensionOptions,
74
+ type RunHostedSoftwareGardenBabysitterOptions,
73
75
  type HostedExtensionResult,
74
76
  } from './hosted-extension-isolation.js';
75
77
  export {
@@ -7,6 +7,16 @@ export function isAgentCapacity(value: unknown): value is number {
7
7
  return Number.isInteger(value) && (value as number) >= 1 && (value as number) <= MAX_LOCAL_AGENT_CAPACITY;
8
8
  }
9
9
 
10
+ export interface WorkerSlotScope {
11
+ run<T>(work: () => Promise<T>): Promise<T>;
12
+ /** Refuse this flow's queued/future work without closing the shared pool. */
13
+ close(reason: unknown): void;
14
+ }
15
+
16
+ interface ScopeState {
17
+ closed?: { readonly reason: unknown };
18
+ }
19
+
10
20
  /**
11
21
  * First-come admission to a worker that holds `capacity` dispatches at once.
12
22
  *
@@ -19,7 +29,11 @@ export function isAgentCapacity(value: unknown): value is number {
19
29
  */
20
30
  export class WorkerSlots {
21
31
  private held = 0;
22
- private readonly waiting: Array<{ resolve: () => void; reject: (reason: unknown) => void }> = [];
32
+ private readonly waiting: Array<{
33
+ resolve: () => void;
34
+ reject: (reason: unknown) => void;
35
+ scope?: ScopeState;
36
+ }> = [];
23
37
  private closed: { readonly reason: unknown } | undefined;
24
38
 
25
39
  constructor(readonly capacity: number) {
@@ -27,18 +41,37 @@ export class WorkerSlots {
27
41
  }
28
42
 
29
43
  async run<T>(work: () => Promise<T>): Promise<T> {
44
+ return this.runInScope(work);
45
+ }
46
+
47
+ /** One independently closable admission scope over this shared capacity. */
48
+ scope(): WorkerSlotScope {
49
+ const scope: ScopeState = {};
50
+ return Object.freeze({
51
+ run: <T>(work: () => Promise<T>) => this.runInScope(work, scope),
52
+ close: (reason: unknown) => this.closeScope(scope, reason),
53
+ });
54
+ }
55
+
56
+ private async runInScope<T>(work: () => Promise<T>, scope?: ScopeState): Promise<T> {
30
57
  if (this.closed !== undefined) throw this.closed.reason;
58
+ if (scope?.closed !== undefined) throw scope.closed.reason;
31
59
  if (this.held < this.capacity) this.held++;
32
60
  // A released slot is handed straight to the next waiter, so `held` never
33
61
  // dips below capacity while anyone is queued and no later caller can jump it.
34
62
  else {
35
- await new Promise<void>((resolve, reject) => this.waiting.push({ resolve, reject }));
63
+ await new Promise<void>((resolve, reject) => this.waiting.push({ resolve, reject, scope }));
36
64
  // Woken with the slot, but the body may have failed in between (see release).
37
65
  const closed = this.closedReason();
38
66
  if (closed !== undefined) {
39
67
  this.release();
40
68
  throw closed.reason;
41
69
  }
70
+ const scopeClosed = scopeClosedReason(scope);
71
+ if (scopeClosed !== undefined) {
72
+ this.release();
73
+ throw scopeClosed.reason;
74
+ }
42
75
  }
43
76
  try {
44
77
  return await work();
@@ -66,17 +99,28 @@ export class WorkerSlots {
66
99
  return;
67
100
  }
68
101
  setImmediate(() => {
69
- if (this.closed === undefined) {
102
+ if (this.closed === undefined && next.scope?.closed === undefined) {
70
103
  next.resolve();
71
104
  return;
72
105
  }
73
106
  // Closed meanwhile. `close` could not see this waiter (already taken off
74
107
  // the queue), so refuse it here, and pass the slot on instead of leaking it.
75
- next.reject(this.closed.reason);
108
+ next.reject(this.closed?.reason ?? next.scope!.closed!.reason);
76
109
  this.release();
77
110
  });
78
111
  }
79
112
 
113
+ private closeScope(scope: ScopeState, reason: unknown): void {
114
+ if (scope.closed !== undefined) return;
115
+ scope.closed = { reason };
116
+ for (let index = this.waiting.length - 1; index >= 0; index--) {
117
+ const waiter = this.waiting[index]!;
118
+ if (waiter.scope !== scope) continue;
119
+ this.waiting.splice(index, 1);
120
+ waiter.reject(reason);
121
+ }
122
+ }
123
+
80
124
  /**
81
125
  * Refuse every queued and future admission with `reason`; work already
82
126
  * holding a slot is not interrupted. The authored body calls this when it
@@ -90,3 +134,18 @@ export class WorkerSlots {
90
134
  for (const waiter of this.waiting.splice(0)) waiter.reject(reason);
91
135
  }
92
136
  }
137
+
138
+ /** Read through a call so a check after an `await` is not narrowed away. */
139
+ function scopeClosedReason(scope?: ScopeState): { readonly reason: unknown } | undefined {
140
+ return scope?.closed;
141
+ }
142
+
143
+ /** One run-tree-wide capacity pool, shared by parent and child flows. */
144
+ export interface AuthoredWorkerSlots {
145
+ readonly agent: WorkerSlots;
146
+ readonly llm: WorkerSlots;
147
+ }
148
+
149
+ export function authoredWorkerSlots(capacity: number): AuthoredWorkerSlots {
150
+ return Object.freeze({ agent: new WorkerSlots(capacity), llm: new WorkerSlots(capacity) });
151
+ }