diff --git a/docs/superpowers/plans/2026-07-20-global-runtime.md b/docs/superpowers/plans/2026-07-20-global-runtime.md new file mode 100644 index 0000000..cd5de83 --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-global-runtime.md @@ -0,0 +1,1068 @@ +# Global Runtime (Main-Thread) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace dedicated workers with a lazily-booted global runtime: a process-wide pool defined by a `define()`'d config, registered via `registerRuntime()`, dispatched to by bare `await task` and the ambient `runtime` handle, with unref-based auto-teardown and opt-in `runtime.shutdown()`. + +**Architecture:** The runtime is a `workers()` pool held in a module-level singleton, created on first dispatch from whichever runtime definition is registered (moroutine ships a default definition — "default vs custom" is only a question of which `define()`'d config boots). Bare `await task` / `for await task` retarget from `dedicated-runner.ts` (deleted) to this singleton. Unlike `workers()`-owned pools, the global runtime's workers are `unref()`'d when idle and `ref()`'d while work is in flight (the pattern dedicated-runner used), so the process exits naturally. Worker-side participation (peer mesh, worker-side `runtime.run()`) is a SEPARATE follow-up plan — in this plan the ambient `runtime` handle works on the main thread only. + +**Tech Stack:** Node 24 (native TS, `using`), `node:test`, pnpm. Prefix node commands with `source ~/.nvm/nvm.sh && nvm use 24 && `. Tests import from `'moroutine'` (package self-reference). This continues the semver-major started by the foundations work (changeset exists; Task 8 amends it). + +**Spec:** `docs/superpowers/specs/2026-07-19-runtime-design.md` §1 (Concept & API). Read it if a decision here seems surprising. + +--- + +## File structure + +- Create `src/runtime-definition.ts` — `RuntimeDefinition` type + `defaultRuntime` (moroutine's own `define()`'d default config). Side-effect-free. +- Create `src/runtime.ts` — registration (`registerRuntime`), the lazy singleton (`getRuntime`), the ambient `runtime` handle, `shutdown()`. Owns runtime lifecycle state. +- Modify `src/worker-pool.ts` — extract the pool internals into a shape both `workers()` and the runtime can use; add ref-counting mode (`refMode: 'lazy'`) used by the runtime. +- Modify `src/task.ts`, `src/stream-task.ts`, `src/execute.ts`, `src/channel.ts` — retarget dedicated-worker call sites to the runtime. +- Delete `src/dedicated-runner.ts`. +- Modify `src/index.ts` — export `registerRuntime`, `runtime`, `defaultRuntime`, types. +- Tests: new `test/runtime.test.ts`, `test/runtime-lifecycle.test.ts` (child-process fixtures), rename/adjust `test/dedicated.test.ts` → semantics now "bare await on global runtime". + +## Design decisions locked by the spec (do not relitigate mid-task) + +- `registerRuntime()` after the runtime booted → throw. Registering twice → throw. Bare dispatch with nothing registered → boot the default definition. +- The default runtime definition is a real `define()`'d value inside moroutine — no special-case code path. +- The global runtime's pool workers are unref'd when idle (natural process exit), ref'd while any task is in flight. +- `runtime.shutdown()` is opt-in graceful teardown (abort signal → await in-flight → terminate); after shutdown, further dispatch throws; a NEW registration cannot revive it (keep it simple: one runtime per process lifetime). +- The ambient `runtime` object is a lazy view: property access boots nothing; dispatch (`runtime.run(...)` or bare await) boots. +- `runtime.run()` has the same callable shape as a pool `Runner` (single task, batch array, streaming) but is a method, not a callable module export. +- Task-arg caching semantics change: caches are per-worker (already true for pools); a task-arg may re-evaluate once per runtime worker rather than once per dedicated worker. Tests asserting once-per-process for bare await must be updated to once-per-worker. + +--- + +### Task 1: RuntimeDefinition + default runtime definition + +**Files:** +- Create: `src/runtime-definition.ts` +- Create: `test/runtime-definition.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `test/runtime-definition.test.ts`: + +```ts +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { availableParallelism } from 'node:os'; +import { defaultRuntime } from '../src/runtime-definition.ts'; +import { getDefineId } from '../src/define.ts'; + +describe('defaultRuntime definition', () => { + it('is a define()-branded value', () => { + const id = getDefineId(defaultRuntime); + assert.ok(id !== undefined); + assert.match(id!, /runtime-definition\.ts#\d+$/); + }); + + it('carries default config', () => { + assert.equal(defaultRuntime.size, availableParallelism()); + assert.equal(defaultRuntime.balance, undefined); // undefined → pool default (leastBusy) + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `source ~/.nvm/nvm.sh && nvm use 24 && node --test test/runtime-definition.test.ts` +Expected: FAIL — `Cannot find module .../src/runtime-definition.ts`. + +- [ ] **Step 3: Implement `src/runtime-definition.ts`** + +```ts +import { availableParallelism } from 'node:os'; +import { define } from './define.ts'; +import type { Balancer } from './runner.ts'; + +/** + * Configuration for a global runtime, defined in a side-effect-free module + * via `define()` so every thread can resolve the identical config by module + * identity. Workers never receive the config by serialization — they import + * the defining module (follow-up plan); on the main thread it configures the + * lazily-booted global pool. + */ +export interface RuntimeDefinition { + /** Number of worker threads. Defaults to `os.availableParallelism()`. */ + size?: number; + /** Load balancing strategy. Defaults to least-busy. */ + balance?: Balancer; + /** Max ms to wait for in-flight tasks during `runtime.shutdown()`. */ + shutdownTimeout?: number; +} + +/** + * Moroutine's built-in runtime definition — used when no custom definition is + * registered. It is "just another runtime": the only default-vs-custom + * difference anywhere is which definition boots. + */ +export const defaultRuntime: RuntimeDefinition = define(import.meta, { + size: availableParallelism(), +}); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `source ~/.nvm/nvm.sh && nvm use 24 && node --test test/runtime-definition.test.ts` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/runtime-definition.ts test/runtime-definition.test.ts +git commit -m "feat: RuntimeDefinition and built-in default runtime definition" +``` + +--- + +### Task 2: Pool ref-mode (unref when idle) + +The global runtime must not pin the event loop while idle. `workers()` pools stay as they are (always ref'd, owned by `using`); the runtime passes an internal option. Reuse the ref/unref-on-count pattern that `dedicated-runner.ts` uses today (it is deleted in Task 5). + +**Files:** +- Modify: `src/worker-pool.ts` +- Modify: `src/runner.ts` (internal option on WorkerOptions) +- Create: `test/fixtures/ref-mode-main.ts` +- Modify: `test/pool-ref.test.ts` + +- [ ] **Step 1: Write the failing child-process test** + +Create `test/fixtures/ref-mode-main.ts` (fixture is EFFECTFUL — it must NOT define any `mo()`/`define()` values; it imports them): + +```ts +import { workers } from 'moroutine'; +import { busy } from './load-balancing.ts'; + +// A refMode:'lazy' pool must not keep the process alive once work is done, +// even WITHOUT explicit disposal. If the workers stay ref'd, this process +// hangs and the test times out. +const run = workers(1, { refMode: 'lazy' } as any); +const result = await run(busy(20)); +console.log('DONE ' + result); +// No dispose — natural exit is the assertion. +``` + +Append to `test/pool-ref.test.ts` inside the existing describe: + +```ts + it('refMode lazy pool lets the process exit without disposal', async () => { + const { stdout } = await exec(process.execPath, ['--no-warnings', join(fixturesDir, 'ref-mode-main.ts')], { + timeout: 15000, + }); + assert.ok(stdout.includes('DONE')); + }); + + it('refMode lazy pool keeps the process alive while work is in flight', async () => { + // Reuses the same fixture: if workers were unref'd DURING the busy() call, + // the process would exit before printing DONE and stdout would be empty. + const { stdout } = await exec(process.execPath, ['--no-warnings', join(fixturesDir, 'ref-mode-main.ts')], { + timeout: 15000, + }); + assert.match(stdout, /DONE \d/); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `source ~/.nvm/nvm.sh && nvm use 24 && node --test test/pool-ref.test.ts` +Expected: the new first test FAILS with a timeout (workers are ref'd, process never exits). Kill time is bounded by the 15s exec timeout. + +- [ ] **Step 3: Implement ref-mode in the pool** + +In `src/runner.ts`, add to `WorkerOptions`: + +```ts + /** @internal 'lazy' unrefs workers when idle so the process can exit naturally; + * used by the global runtime. Default 'held': workers keep the event loop alive + * until disposal. */ + refMode?: 'held' | 'lazy'; +``` + +In `src/worker-pool.ts`, after the pool creation loop (`pool.push(worker)`), add: + +```ts + const lazyRef = opts?.refMode === 'lazy'; + let refCount = 0; + if (lazyRef) for (const worker of pool) worker.unref(); + + function refAll(): void { + if (!lazyRef) return; + if (refCount === 0) for (const worker of pool) worker.ref(); + refCount++; + } + + function unrefAll(): void { + if (!lazyRef) return; + refCount--; + if (refCount === 0) for (const worker of pool) worker.unref(); + } +``` + +Wire into tracking — `trackValue` and `trackStream` become: + +```ts + async function trackValue(index: number, promise: Promise): Promise { + inflight.add(promise); + counts.inc(index); + refAll(); + try { + return await promise; + } catch (err) { + if (err instanceof Error) { + const Ctor = err.constructor as ErrorConstructor; + throw new Ctor(err.message, { cause: err }); + } + throw new Error(String(err), { cause: err }); + } finally { + inflight.delete(promise); + counts.dec(index); + unrefAll(); + } + } + + function trackStream(index: number, done: Promise): void { + inflight.add(done); + counts.inc(index); + refAll(); + done.then(() => { + inflight.delete(done); + counts.dec(index); + unrefAll(); + }); + } +``` + +Design note: ref/unref the WHOLE pool per in-flight transition (not per-worker counts) — simpler, and matches the semantic "process stays alive while the runtime has any work." Per-worker granularity buys nothing: an idle-but-ref'd sibling worker doesn't block exit once all work settles and unrefAll runs. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `source ~/.nvm/nvm.sh && nvm use 24 && node --test test/pool-ref.test.ts` +Expected: PASS (3 tests: the pre-existing held-mode test plus the two new ones). + +- [ ] **Step 5: Full suite + gates** + +Run: `source ~/.nvm/nvm.sh && nvm use 24 && pnpm test && pnpm types && pnpm lint` +Expected: all green (default refMode 'held' preserves existing behavior everywhere). + +- [ ] **Step 6: Commit** + +```bash +git add src/runner.ts src/worker-pool.ts test/pool-ref.test.ts test/fixtures/ref-mode-main.ts +git commit -m "feat: refMode 'lazy' pools unref idle workers for natural exit" +``` + +--- + +### Task 3: registerRuntime() + lazy singleton + ambient runtime handle + +**Files:** +- Create: `src/runtime.ts` +- Create: `test/runtime.test.ts` +- Create: `test/fixtures/runtime-def.ts` +- Modify: `src/index.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `test/fixtures/runtime-def.ts` (side-effect-free): + +```ts +import { define } from 'moroutine'; +import type { RuntimeDefinition } from 'moroutine'; + +export const tinyRuntime: RuntimeDefinition = define(import.meta, { size: 1 }); +``` + +Create `test/runtime.test.ts`. IMPORTANT test-isolation constraint: the runtime is a process-wide singleton, and node --test runs each FILE in its own process but each TEST in the same process. Tests that need a fresh un-booted runtime must live in separate child-process fixtures (Task 4 does lifecycle); here we test the in-process API surface in a deliberate order within one file (order-dependent within the describe is acceptable and commented). + +```ts +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { runtime, registerRuntime, defaultRuntime } from 'moroutine'; +import { tinyRuntime } from './fixtures/runtime-def.ts'; +import { double, add } from './fixtures/math.ts'; + +// NOTE: the global runtime is a process-wide singleton; these tests share it +// and are order-dependent by design. Register first, then boot, then assert +// post-boot behavior. Fresh-boot scenarios live in child-process fixtures +// (runtime-lifecycle tests). +describe('global runtime', () => { + it('property access does not boot the runtime', () => { + // runtime.workers before boot must throw rather than boot as a side effect + assert.throws(() => runtime.workers, { message: /not booted/ }); + }); + + it('registerRuntime() before boot succeeds and wins over the default', async () => { + registerRuntime(tinyRuntime); + const result = await runtime.run(double(21)); + assert.equal(result, 42); + assert.equal(runtime.workers.length, 1); // tinyRuntime.size, not availableParallelism + }); + + it('registerRuntime() after boot throws', () => { + assert.throws(() => registerRuntime(defaultRuntime), { + message: /already booted/, + }); + }); + + it('runtime.run() dispatches batches', async () => { + const [a, b] = await runtime.run([add(1, 2), add(3, 4)]); + assert.equal(a, 3); + assert.equal(b, 7); + }); + + it('runtime.workers exposes uniform handles after boot', () => { + assert.equal(runtime.workers[0].index, 0); + assert.equal(typeof runtime.workers[0].activeCount, 'number'); + }); + + it('runtime.signal is an AbortSignal that has not fired', () => { + assert.ok(runtime.signal instanceof AbortSignal); + assert.equal(runtime.signal.aborted, false); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `source ~/.nvm/nvm.sh && nvm use 24 && node --test test/runtime.test.ts` +Expected: FAIL — `'moroutine'` has no export named `runtime` / `registerRuntime` / `defaultRuntime` / type `RuntimeDefinition`. + +- [ ] **Step 3: Implement `src/runtime.ts`** + +```ts +import { workers } from './worker-pool.ts'; +import { getDefineId } from './define.ts'; +import { defaultRuntime } from './runtime-definition.ts'; +import { AsyncIterableTask } from './stream-task.ts'; +import type { RuntimeDefinition } from './runtime-definition.ts'; +import type { Task, RunResult, Runner, WorkerHandle } from './runner.ts'; +import type { ChannelOptions } from './channel.ts'; + +let registered: RuntimeDefinition | null = null; +let pool: Runner | null = null; +let shutdownStarted = false; + +/** + * Registers a custom runtime definition as the process's global runtime. + * Must be called from the main thread before the first bare dispatch + * (`await task`) or `runtime.run()` call — the runtime boots lazily on + * first dispatch, and re-configuring a booted runtime would be a silent + * behavior change, so this throws instead. + */ +export function registerRuntime(definition: RuntimeDefinition): void { + if (pool !== null) { + throw new Error( + 'Cannot registerRuntime(): the global runtime has already booted. ' + + 'Register before the first dispatch (bare `await task` or runtime.run()).', + ); + } + if (shutdownStarted) { + throw new Error('Cannot registerRuntime(): the global runtime has been shut down.'); + } + if (registered !== null) { + throw new Error('Cannot registerRuntime(): a runtime definition is already registered.'); + } + if (getDefineId(definition) === undefined) { + throw new Error( + 'registerRuntime() requires a define()-branded runtime definition ' + + '(create it with define(import.meta, {...}) in a side-effect-free module).', + ); + } + registered = definition; +} + +/** Boots (if needed) and returns the global runtime pool. @internal */ +export function getRuntime(): Runner { + if (shutdownStarted) { + throw new Error('The global runtime has been shut down'); + } + if (pool === null) { + const def = registered ?? defaultRuntime; + pool = workers(def.size ?? undefined!, { + balance: def.balance, + shutdownTimeout: def.shutdownTimeout, + refMode: 'lazy', + } as any); + } + return pool; +} + +/** Returns the booted pool or throws — for surface that must not boot. */ +function bootedRuntime(): Runner { + if (pool === null) { + throw new Error('The global runtime is not booted (it boots on first dispatch)'); + } + if (shutdownStarted) { + throw new Error('The global runtime has been shut down'); + } + return pool; +} + +/** + * The ambient global runtime handle. Task code imports this — never a specific + * runtime definition module — so it stays portable across runtimes. + * + * Dispatch (`runtime.run(...)` or bare `await task`) boots the runtime on + * first use; property access (`runtime.workers`, `runtime.signal`) never + * boots and throws before boot. + */ +export const runtime = { + /** Dispatches a task, batch, or streaming task to the global runtime. */ + run(taskOrTasks: Task | Task[], opts?: ChannelOptions): any { + return getRuntime()(taskOrTasks as any, opts); + }, + /** Uniform worker handles, one per runtime worker. Throws before boot. */ + get workers(): readonly WorkerHandle[] { + return bootedRuntime().workers; + }, + /** Fires when shutdown begins. Throws before boot. */ + get signal(): AbortSignal { + return bootedRuntime().signal; + }, + /** + * Graceful opt-in teardown: fires `signal`, awaits in-flight tasks (up to + * the definition's `shutdownTimeout`), then terminates workers. After + * shutdown, dispatch throws; the runtime cannot be re-registered or + * re-booted (one runtime per process lifetime). + */ + async shutdown(): Promise { + if (pool === null) { + shutdownStarted = true; // never booted: nothing to drain, lock the door + return; + } + shutdownStarted = true; + await pool[Symbol.asyncDispose](); + }, +}; + +export type { RuntimeDefinition }; +``` + +Note on `def.size ?? undefined!`: `workers()`'s first overload takes `size: number` — when the definition omits size, call the zero-arg form instead. Implement the call as: + +```ts + pool = + def.size !== undefined + ? workers(def.size, { balance: def.balance, shutdownTimeout: def.shutdownTimeout, refMode: 'lazy' } as any) + : workers({ balance: def.balance, shutdownTimeout: def.shutdownTimeout, refMode: 'lazy' } as any); +``` + +(Delete the `?? undefined!` sketch above — use this two-branch form. Once `refMode` stops being internal-`as any`, drop the casts.) + +- [ ] **Step 4: Export from `src/index.ts`** + +Add after the `workers` export: + +```ts +export { runtime, registerRuntime } from './runtime.ts'; +export { defaultRuntime } from './runtime-definition.ts'; +export type { RuntimeDefinition } from './runtime-definition.ts'; +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `source ~/.nvm/nvm.sh && nvm use 24 && node --test test/runtime.test.ts` +Expected: PASS (6 tests). + +- [ ] **Step 6: Full gates and commit** + +Run: `source ~/.nvm/nvm.sh && nvm use 24 && pnpm test && pnpm types && pnpm lint` + +```bash +git add src/runtime.ts src/runtime-definition.ts src/index.ts test/runtime.test.ts test/fixtures/runtime-def.ts +git commit -m "feat: global runtime with registerRuntime() and ambient handle" +``` + +--- + +### Task 4: Runtime lifecycle (child-process tests: lazy default boot, natural exit, shutdown) + +Singleton lifecycle scenarios need fresh processes. Follow the `test/pool-ref.test.ts` exec pattern. + +**Files:** +- Create: `test/fixtures/runtime-natural-exit.ts` +- Create: `test/fixtures/runtime-default-boot.ts` +- Create: `test/fixtures/runtime-shutdown.ts` +- Create: `test/runtime-lifecycle.test.ts` + +- [ ] **Step 1: Write the fixtures and failing test** + +`test/fixtures/runtime-default-boot.ts` (effectful main — imports moroutines, never defines them): + +```ts +import { runtime } from 'moroutine'; +import { double } from './math.ts'; + +// No registerRuntime() — first dispatch boots the DEFAULT definition. +const viaRun = await runtime.run(double(4)); +console.log('SIZE ' + runtime.workers.length); +console.log('DONE ' + viaRun); +``` + +`test/fixtures/runtime-natural-exit.ts`: + +```ts +import { runtime, registerRuntime } from 'moroutine'; +import { tinyRuntime } from './runtime-def.ts'; +import { busy } from './load-balancing.ts'; + +registerRuntime(tinyRuntime); +const r = await runtime.run(busy(20)); +console.log('DONE ' + r); +// No shutdown() call — the unref'd runtime must let the process exit here. +``` + +`test/fixtures/runtime-shutdown.ts`: + +```ts +import { runtime, registerRuntime } from 'moroutine'; +import { tinyRuntime } from './runtime-def.ts'; +import { double } from './math.ts'; + +registerRuntime(tinyRuntime); +await runtime.run(double(1)); + +let signalFired = false; +runtime.signal.addEventListener('abort', () => { + signalFired = true; +}); +await runtime.shutdown(); +console.log('SIGNAL ' + signalFired); + +try { + await runtime.run(double(2)); + console.log('POST-SHUTDOWN-DISPATCH no-throw'); +} catch (err) { + console.log('POST-SHUTDOWN-DISPATCH threw'); +} +``` + +`test/runtime-lifecycle.test.ts`: + +```ts +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; +import { availableParallelism } from 'node:os'; + +const exec = promisify(execFile); +const fixturesDir = join(fileURLToPath(import.meta.url), '..', 'fixtures'); + +describe('global runtime lifecycle', () => { + it('first dispatch lazily boots the default definition', async () => { + const { stdout } = await exec( + process.execPath, + ['--no-warnings', join(fixturesDir, 'runtime-default-boot.ts')], + { timeout: 20000 }, + ); + assert.ok(stdout.includes(`SIZE ${availableParallelism()}`)); + assert.ok(stdout.includes('DONE 8')); + }); + + it('process exits naturally without shutdown()', async () => { + const { stdout } = await exec( + process.execPath, + ['--no-warnings', join(fixturesDir, 'runtime-natural-exit.ts')], + { timeout: 15000 }, + ); + assert.match(stdout, /DONE \d/); + }); + + it('shutdown() fires signal, drains, and locks out further dispatch', async () => { + const { stdout } = await exec(process.execPath, ['--no-warnings', join(fixturesDir, 'runtime-shutdown.ts')], { + timeout: 15000, + }); + assert.ok(stdout.includes('SIGNAL true')); + assert.ok(stdout.includes('POST-SHUTDOWN-DISPATCH threw')); + }); +}); +``` + +- [ ] **Step 2: Run test to verify current state** + +Run: `source ~/.nvm/nvm.sh && nvm use 24 && node --test test/runtime-lifecycle.test.ts` +Expected: all three PASS already if Tasks 2-3 are correct — these are integration proofs, not unit-first TDD (the units were test-driven in Tasks 2-3). If any FAILS, treat it as a real defect in Task 2/3 work and fix there before proceeding. The natural-exit test is the critical one: it fails (timeout) if refMode wiring missed a path. + +- [ ] **Step 3: Commit** + +```bash +git add test/runtime-lifecycle.test.ts test/fixtures/runtime-natural-exit.ts test/fixtures/runtime-default-boot.ts test/fixtures/runtime-shutdown.ts +git commit -m "test: global runtime lifecycle (lazy boot, natural exit, shutdown)" +``` + +--- + +### Task 5: Retarget bare await/iterate to the runtime; delete dedicated-runner + +The behavioral heart of the change. `PromiseLikeTask.then` and `AsyncIterableTask[Symbol.asyncIterator]` currently call `runOnDedicated`/`runStreamOnDedicated`; `execute.ts` (`prepareArg` streaming-task-arg branch) and `channel.ts` (AsyncIterableTask source) also call `runStreamOnDedicated`. All four retarget to the global runtime. + +**Files:** +- Modify: `src/task.ts` +- Modify: `src/stream-task.ts` +- Modify: `src/execute.ts:10,78-81` +- Modify: `src/channel.ts:4,53-56` +- Delete: `src/dedicated-runner.ts` +- Modify: `test/dedicated.test.ts` (rename semantics) + +- [ ] **Step 1: Update the bare-await tests to describe the new semantics** + +Rewrite `test/dedicated.test.ts` → keep the same file name (git history) but retitle: + +```ts +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { runtime } from 'moroutine'; +import { double, add } from './fixtures/math.ts'; + +describe('bare await dispatches to the global runtime', () => { + it('executes a moroutine on a runtime worker', async () => { + const result = await double(2); + assert.equal(result, 4); + }); + + it('handles multiple arguments', async () => { + const result = await add(3, 4); + assert.equal(result, 7); + }); + + it('handles sequential calls', async () => { + const a = await double(5); + const b = await double(10); + assert.equal(a, 10); + assert.equal(b, 20); + }); + + it('handles concurrent calls', async () => { + const results = await Promise.all([double(1), double(2), double(3)]); + assert.deepEqual(results, [2, 4, 6]); + }); + + it('bare await boots the global runtime', async () => { + await double(1); + assert.ok(runtime.workers.length >= 1); // booted — access no longer throws + }); +}); +``` + +- [ ] **Step 2: Run to verify current behavior still passes but the new assertion fails** + +Run: `source ~/.nvm/nvm.sh && nvm use 24 && node --test test/dedicated.test.ts` +Expected: first four PASS (dedicated workers still live), `bare await boots the global runtime` FAILS (`runtime.workers` throws /not booted/ — bare await still targets dedicated workers). + +- [ ] **Step 3: Retarget the four call sites** + +`src/task.ts` — replace the dedicated import and `then`: + +```ts +import { getRuntime } from './runtime.ts'; +``` + +```ts + /** Enables `await task` by dispatching to the global runtime. @returns The worker function's result. */ + then( + onfulfilled?: ((value: T) => T1 | PromiseLike) | null, + onrejected?: ((reason: any) => T2 | PromiseLike) | null, + ): Promise { + return (getRuntime()(this as unknown as Task) as Promise).then(onfulfilled, onrejected); + } +``` + +Check `task.ts` imports: it needs `import type { Task } from './runner.ts';` (already imports WorkerHandle type from there). + +CIRCULAR-IMPORT NOTE: `runtime.ts` imports `stream-task.ts` (for AsyncIterableTask type checks) and `worker-pool.ts`; `task.ts`/`stream-task.ts` importing `runtime.ts` creates a cycle (`worker-pool → execute → task → runtime → worker-pool`). ESM handles cycles via live bindings — since `getRuntime` is only CALLED at dispatch time (never at module-eval time), the cycle is benign. If `pnpm test` surfaces a TDZ/undefined-import error at load, break the cycle by having `runtime.ts` avoid importing AsyncIterableTask (it doesn't actually need it — `run` just forwards to the pool callable which does its own instanceof checks) — remove that import in runtime.ts rather than restructuring task.ts. + +`src/stream-task.ts` — same move: + +```ts +import { getRuntime } from './runtime.ts'; +``` + +```ts + /** Enables `for await...of` by dispatching to the global runtime. @returns An iterator of yielded values. */ + [Symbol.asyncIterator](): AsyncIterator { + const iterable = getRuntime()(this as any) as AsyncIterable; + return iterable[Symbol.asyncIterator](); + } +``` + +`src/execute.ts` — replace the import (line 10) and the StreamTask-arg branch (78-81): + +```ts +import { getRuntime } from './runtime.ts'; +``` + +```ts + // Auto-detect StreamTask args — dispatch to the global runtime, pipe output + if (arg instanceof AsyncIterableTask) { + return pipeArgToWorker(getRuntime()(arg as any) as AsyncIterable); + } +``` + +`src/channel.ts` — replace the import (line 4) and constructor branch (53-56): + +```ts +import { getRuntime } from './runtime.ts'; +``` + +```ts + const src = + source instanceof AsyncIterableTask + ? (getRuntime()(source as any) as AsyncIterable) + : source; +``` + +Delete `src/dedicated-runner.ts`: + +```bash +git rm src/dedicated-runner.ts +``` + +- [ ] **Step 4: Run the target test, then the full suite** + +Run: `source ~/.nvm/nvm.sh && nvm use 24 && node --test test/dedicated.test.ts` +Expected: PASS (5 tests). + +Run: `source ~/.nvm/nvm.sh && nvm use 24 && pnpm test` +Expected: mostly green, with KNOWN candidates to inspect one by one — do NOT weaken assertions; adjust semantics where the spec changed them: + +- `test/context.test.ts` / `test/stream-context.test.ts`: task-arg caching tests that bare-await'd may now observe once-per-runtime-worker evaluation instead of once-per-dedicated-worker. Any test asserting cache identity across bare awaits should pin `size: 1` semantics — if the default runtime (availableParallelism workers) makes them flaky, add a `registerRuntime(tinyRuntime)`-style fixture... BUT registerRuntime is per-process and node --test runs each FILE in one process, so instead route those specific dispatches through an explicit `workers(1)` pool (which those tests mostly already do — verify) or move the bare-await variants into child-process fixtures. Report what you found and chose. +- `test/stream-exit.test.ts` fixture `exit-dedicated-main.ts`: renames conceptually to runtime; the fixture content is a bare `for await` — behavior should hold (runtime is unref'd/lazy). If it hangs, that is a REAL bug in Task 2's stream tracking (trackStream must unrefAll when done) — fix the source, not the test. +- `test/error.test.ts`, `test/abort-signal.test.ts`, `test/stream.test.ts`, `test/freeze.test.ts`, `test/define.test.ts` (bare-await define test): should pass unchanged (same protocol, different pool). Investigate any failure as a real defect. +- `test/stream-pipeline.test.ts` ("each stage runs on its own dedicated worker" — pipelines now get runtime placement): stages may share a worker. If assertions checked distinct threadIds, update to assert VALUES only, and note the semantic change for the README task. + +Run: `pnpm types && pnpm lint && npx tsc -p examples/tsconfig.json --noEmit` +Expected: types clean after removing dedicated-runner (nothing else imports it — verified by grep in plan prep; `git grep -n "dedicated-runner"` must return nothing). + +- [ ] **Step 5: Commit** + +```bash +git add -A src test +git commit -m "feat!: bare await dispatches to global runtime; remove dedicated workers" +``` + +--- + +### Task 6: Foreign-pin guard (spec: handle/runtime mismatch throws) + +With two kinds of pools alive (runtime + user pools), a task pinned via `assign(handleFromPoolA, task)` dispatched on pool B silently falls through to B's balancer today (`resolveWorker`'s `if (idx !== -1)`). Spec §2 says mismatch throws. This became user-reachable the moment bare await + pools coexist routinely. + +**Files:** +- Modify: `src/worker-pool.ts:98-107` +- Modify: `test/worker-handle.test.ts` + +- [ ] **Step 1: Write the failing test** + +Append to `test/worker-handle.test.ts`'s describe: + +```ts + it('a task pinned to a foreign pool handle throws instead of silently rebalancing', async () => { + const runA = workers(1); + const runB = workers(1); + try { + const pinned = assign(runA.workers[0], identity(1)); + assert.throws(() => runB(pinned), { message: /pinned to a worker from another pool/ }); + } finally { + runA[Symbol.dispose](); + runB[Symbol.dispose](); + } + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `source ~/.nvm/nvm.sh && nvm use 24 && node --test test/worker-handle.test.ts` +Expected: the new test FAILS — no throw; the task runs on runB's own worker via balancer fallthrough. + +- [ ] **Step 3: Implement the guard** + +In `src/worker-pool.ts`, `resolveWorker`: + +```ts + function resolveWorker(task: Task): { worker: Worker; idx: number } { + if (task.worker != null) { + const idx = workerHandles.indexOf(task.worker); + if (idx === -1) throw new Error('Task is pinned to a worker from another pool'); + return { worker: pool[idx], idx }; + } + const handle = balancer.select(workerHandles, task, balancerState); + const idx = workerHandles.indexOf(handle); + if (idx === -1) throw new Error('Balancer returned a handle that is not in this pool'); + return { worker: pool[idx], idx }; + } +``` + +- [ ] **Step 4: Run tests, full gates** + +Run: `source ~/.nvm/nvm.sh && nvm use 24 && node --test test/worker-handle.test.ts && pnpm test && pnpm types && pnpm lint` +Expected: all green — nothing in-tree relies on the silent fallthrough (worker-handle and load-balancing pin tests always use same-pool handles). + +- [ ] **Step 5: Commit** + +```bash +git add src/worker-pool.ts test/worker-handle.test.ts +git commit -m "fix!: pinning a foreign pool handle throws instead of rebalancing" +``` + +--- + +### Task 7: Balancer-state boot validation + +The foundations constrained balancer state to a single top-level shared value (or plain serializable value for single-thread pools). The runtime is where violating it would corrupt later (worker handshake, follow-up plan). Validate at boot per the crash-early directive — for the RUNTIME only (`workers()` pools keep accepting closure/plain-object state; scheduling never leaves main there). + +**Files:** +- Modify: `src/runtime.ts` (boot path) +- Create: `src/shared/is-shared.ts` — tiny helper IF one doesn't exist; check first +- Modify: `test/runtime.test.ts`? No — needs a fresh process. Create `test/fixtures/runtime-bad-balancer.ts` + extend `test/runtime-lifecycle.test.ts` + +- [ ] **Step 1: Check for an existing shared-brand predicate** + +Run: `grep -n "moroutine.shared" src/shared/reconstruct.ts src/shared/*.ts | head` +`serializeArg` detects shared values via a brand — reuse EXACTLY the predicate it uses (read `src/shared/reconstruct.ts` and import/export whatever internal check exists rather than duplicating the symbol lookup). If the predicate is inline, extract it as `isSharedValue(value: unknown): boolean` exported from `reconstruct.ts`. + +- [ ] **Step 2: Write the failing test** + +`test/fixtures/runtime-bad-balancer.ts`: + +```ts +import { define, runtime, registerRuntime } from 'moroutine'; +import type { Balancer, RuntimeDefinition } from 'moroutine'; +import { double } from './math.ts'; + +// A balancer whose state is a plain object wrapping shared memory — legal in +// a workers() pool, but the global runtime must reject it at boot because the +// state cannot cross the future worker handshake intact. +const badBalancer: Balancer<{ n: number }> = { + initialState: () => ({ n: 0 }), + select: (w, _t, _s) => w[0], +}; + +const def: RuntimeDefinition = define(import.meta, { size: 1, balance: badBalancer }); +registerRuntime(def); +try { + await runtime.run(double(1)); + console.log('BOOT no-throw'); +} catch (err) { + console.log('BOOT threw: ' + (err as Error).message); +} +``` + +Wait — this fixture calls `define()` AND is effectful (top-level await). That is the fork-bomb pattern. Since the definition must be define()-branded and the dispatch must live in a main script, split: put the definition in `test/fixtures/runtime-bad-balancer-def.ts` (side-effect-free) and the dispatch in `test/fixtures/runtime-bad-balancer.ts` (effectful, imports the def). The def module: + +```ts +// test/fixtures/runtime-bad-balancer-def.ts — side-effect free +import { define } from 'moroutine'; +import type { Balancer, RuntimeDefinition } from 'moroutine'; + +const badBalancer: Balancer<{ n: number }> = { + initialState: () => ({ n: 0 }), + select: (w, _t, _s) => w[0], +}; + +export const badDef: RuntimeDefinition = define(import.meta, { size: 1, balance: badBalancer }); +``` + +The main fixture: + +```ts +// test/fixtures/runtime-bad-balancer.ts — effectful main +import { runtime, registerRuntime } from 'moroutine'; +import { badDef } from './runtime-bad-balancer-def.ts'; +import { double } from './math.ts'; + +registerRuntime(badDef); +try { + await runtime.run(double(1)); + console.log('BOOT no-throw'); +} catch (err) { + console.log('BOOT threw: ' + (err as Error).message); +} +``` + +Extend `test/runtime-lifecycle.test.ts`: + +```ts + it('boot rejects balancer state that cannot cross threads', async () => { + const { stdout } = await exec( + process.execPath, + ['--no-warnings', join(fixturesDir, 'runtime-bad-balancer.ts')], + { timeout: 15000 }, + ); + assert.match(stdout, /BOOT threw: .*balancer state/i); + }); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `source ~/.nvm/nvm.sh && nvm use 24 && node --test test/runtime-lifecycle.test.ts` +Expected: new test FAILS (`BOOT no-throw` — no validation yet). + +- [ ] **Step 4: Implement validation in `getRuntime()`** + +In `src/runtime.ts` boot path, after computing the definition and before creating the pool: + +```ts +import { isSharedValue } from './shared/reconstruct.ts'; // or wherever Step 1 found/extracted it +``` + +```ts + const state = def.balance?.initialState?.(); + if (state !== undefined && !isSharedValue(state) && !isPlainSerializable(state)) { + throw new Error( + 'Global runtime balancer state must be a single shared value (e.g. uint32atomic() ' + + 'or shared({...})) or a plain serializable value — got a non-shared object, which ' + + 'cannot stay consistent across scheduling threads.', + ); + } +``` + +Where `isPlainSerializable` (module-local in runtime.ts) is: + +```ts +function isPlainSerializable(value: unknown): boolean { + // Primitives serialize fine; objects/functions (other than shared values, + // checked by the caller) do not survive the worker handshake intact. + return value === null || (typeof value !== 'object' && typeof value !== 'function'); +} +``` + +IMPORTANT WIRING DETAIL: the pool ALSO calls `initialState()` internally (worker-pool.ts line 35). Calling it twice would allocate two cursors — the validated one and the used one. To keep single-allocation semantics, validate the VALUE the pool will use: add an internal `WorkerOptions` field `balancerState?: unknown` that, when present, worker-pool uses instead of calling `initialState()` itself: + +```ts + // worker-pool.ts line 35 becomes: + const balancerState: unknown = 'balancerState' in (opts ?? {}) ? opts!.balancerState : balancer.initialState?.(); +``` + +with `runner.ts` `WorkerOptions` gaining: + +```ts + /** @internal Pre-computed balancer state (already validated by the runtime). */ + balancerState?: unknown; +``` + +and runtime.ts passing `balancerState: state` in the pool options. `'balancerState' in opts` (not `!== undefined`) so an explicitly-undefined state from a stateless balancer isn't recomputed. + +- [ ] **Step 5: Run tests, full gates** + +Run: `source ~/.nvm/nvm.sh && nvm use 24 && node --test test/runtime-lifecycle.test.ts && pnpm test && pnpm types && pnpm lint` +Expected: all green. Built-ins pass validation (roundRobin → Uint32Atomic is shared-branded; leastBusy → undefined state). + +- [ ] **Step 6: Commit** + +```bash +git add src/runtime.ts src/worker-pool.ts src/runner.ts src/shared/reconstruct.ts test/runtime-lifecycle.test.ts test/fixtures/runtime-bad-balancer.ts test/fixtures/runtime-bad-balancer-def.ts +git commit -m "feat: validate runtime balancer state is thread-safe at boot" +``` + +--- + +### Task 8: README + changeset update + +**Files:** +- Modify: `README.md` +- Modify: `.changeset/runtime-foundations.md` + +- [ ] **Step 1: README — replace the Dedicated Workers section** + +The `### Dedicated Workers` subsection (after the define() section) becomes `### The Global Runtime`: + +````markdown +### The Global Runtime + +Awaiting a task directly (without a pool) runs it on the **global runtime** — a +process-wide pool that boots lazily on first dispatch and tears down automatically +when the process is done with it. + +```ts +const result = await add(3, 4); // boots the global runtime on first use +``` + +Configure it by registering a runtime definition — a `define()`'d config in its own +side-effect-free module — before the first dispatch: + +```ts +// app-runtime.ts +import { define } from 'moroutine'; +import type { RuntimeDefinition } from 'moroutine'; + +export default define(import.meta, { + size: 8, +}) satisfies RuntimeDefinition; +``` + +```ts +// main.ts +import { registerRuntime, runtime } from 'moroutine'; +import appRuntime from './app-runtime.ts'; +import { add } from './math.ts'; + +registerRuntime(appRuntime); // must precede the first dispatch + +const result = await runtime.run(add(3, 4)); // explicit form +const same = await add(3, 4); // bare await — same runtime +``` + +`runtime` is the ambient handle to the global runtime: `runtime.run(task | tasks)` +dispatches (booting if needed), `runtime.workers` exposes uniform worker handles for +pinning with `assign()`, and `runtime.signal` fires when shutdown begins. +`registerRuntime()` after the runtime has booted throws — no silent reconfiguration. + +Long-running apps (servers) can drain gracefully: + +```ts +await runtime.shutdown(); // fires signal, awaits in-flight work, terminates workers +``` + +Scripts never need to call it — idle runtime workers don't keep the process alive. +```` + +- [ ] **Step 2: README — sweep stale "dedicated worker" mentions** + +Grep README.md for `dedicated`. Update each (verify against final code, don't trust this list blindly): +- Quick Start prose "import and run it on a worker pool" — fine. +- Streaming section "Iterate directly (dedicated worker)" → "(global runtime)". +- Pipelines section "Each stage runs on its own dedicated worker." → "Stages are placed by the runtime's balancer; pin stages with `assign()` when placement matters." +- Examples list `examples/primes` "CPU-bound prime checking on a dedicated worker" → "on the global runtime". +- Any other hits: same treatment. + +Also verify the examples themselves still run (they bare-await): `source ~/.nvm/nvm.sh && nvm use 24 && node examples/primes/main.ts` and `node examples/pipeline/main.ts` — expected to work unchanged via the runtime. Report any that break; fix only if the break is a real defect in this plan's code (placement-semantics differences in example OUTPUT text are acceptable — update the example's comments then). + +- [ ] **Step 3: Update the changeset** + +Append to `.changeset/runtime-foundations.md`'s bullet list: + +```markdown +- **Breaking:** dedicated workers are removed. Bare `await task` / `for await task` + dispatches to the global runtime — a lazily-booted, process-wide pool configured + via `registerRuntime()` + a `define()`'d `RuntimeDefinition`, with automatic + teardown (idle runtime workers don't hold the process open) and opt-in + `runtime.shutdown()`. Task-arg caches are now per runtime worker (previously per + dedicated worker); streaming pipelines get balancer placement instead of one + thread per stage. +- **Breaking:** dispatching a task pinned to a handle from a different pool throws. +``` + +- [ ] **Step 4: Gates and commit** + +Run: `source ~/.nvm/nvm.sh && nvm use 24 && pnpm test && pnpm types && pnpm lint && npx tsc -p examples/tsconfig.json --noEmit` + +```bash +git add README.md .changeset/runtime-foundations.md examples +git commit -m "docs: global runtime replaces dedicated workers" +``` + +--- + +## Explicitly deferred to Plan B (workers-as-peers) + +Boot handshake (`workerData` with runtime define-ID + counts buffer + serialized balancer state), control-frame discriminator on parentPort, brokered lazy peer mesh, worker-side `runtime`/`runtime.run()`/bare-await, same-worker shortcut, dead-peer error frames, `pending`-map rejection on endpoint death, crash-loudly-on-unknown-frame in setupWorker. TODO.md already carries these. + +## Self-review notes (already applied) + +- Task 7's fixture initially violated the fork-bomb rule (define + top-level await in one file) — split into def + main fixtures; the violation and fix are left visible in the task as a warning to the implementer. +- Task 3's `def.size ?? undefined!` sketch replaced with the explicit two-branch call. +- Task 5 carries the circular-import contingency inline.