···11+# Extensible Load Balancing
22+33+## Goal
44+55+Support pluggable load balancing strategies for worker pools, with built-in round-robin and least-busy implementations. Custom balancers can track their own metrics using the exposed worker thread and active count.
66+77+## `Balancer` Interface
88+99+```ts
1010+interface Balancer {
1111+ select(workers: readonly WorkerHandle[], task: Task<any> | StreamTask<any>): WorkerHandle;
1212+ [Symbol.dispose]?(): void;
1313+ [Symbol.asyncDispose]?(): Promise<void>;
1414+}
1515+```
1616+1717+`select()` is called synchronously on every dispatch to choose which worker runs the task. It receives the full handles array and the task being dispatched. Pinned tasks (via `assign()`) bypass the balancer.
1818+1919+`Symbol.dispose` and `Symbol.asyncDispose` are optional cleanup hooks. The pool calls the appropriate one during disposal — `Symbol.dispose` on sync dispose, `Symbol.asyncDispose` on async dispose. If only one is defined, the pool falls back to whichever is available.
2020+2121+## `WorkerHandle` Additions
2222+2323+```ts
2424+interface WorkerHandle {
2525+ exec<T>(task: Task<T>): Promise<T>;
2626+ exec<T>(task: StreamTask<T>, opts?: ChannelOptions): AsyncIterable<T>;
2727+ readonly thread: Worker; // underlying worker_threads.Worker
2828+ readonly activeCount: number; // in-flight tasks on this worker
2929+}
3030+```
3131+3232+`thread` exposes the raw `worker_threads.Worker` for advanced use cases (event loop utilization via `worker.performance`, custom messaging, etc.).
3333+3434+`activeCount` is the number of currently in-flight tasks on this worker. Incremented on dispatch, decremented on task settle or stream completion. This is per-handle tracking, separate from the pool-level `inflight` set.
3535+3636+## `WorkerOptions` Update
3737+3838+```ts
3939+interface WorkerOptions {
4040+ shutdownTimeout?: number;
4141+ balance?: Balancer;
4242+}
4343+```
4444+4545+## `workers()` Overloads
4646+4747+```ts
4848+workers(): Runner
4949+workers(size: number): Runner
5050+workers(opts: WorkerOptions): Runner
5151+workers(size: number, opts: WorkerOptions): Runner
5252+```
5353+5454+When the first argument is an object (not a number), it's treated as `WorkerOptions` with default size. Default balancer is `roundRobin`.
5555+5656+## Built-in Balancers
5757+5858+Exported as factory functions from `moroutine`. Each call returns a fresh `Balancer` instance, safe for use with a single pool.
5959+6060+```ts
6161+import { workers, roundRobin, leastBusy } from 'moroutine';
6262+6363+workers(4) // round-robin by default
6464+workers(4, { balance: leastBusy() }) // least-busy
6565+workers({ balance: leastBusy() }) // least-busy, default size
6666+workers(4, { balance: myCustomBalancer }) // custom
6767+```
6868+6969+**`roundRobin()`** — cycles through workers in order. Maintains an internal index.
7070+7171+**`leastBusy()`** — picks the worker with the lowest `activeCount`. Ties broken by index (first wins). Stateless — reads `activeCount` on each call.
7272+7373+## Dispatch Flow
7474+7575+1. If `task.worker` is set (pinned via `assign()`), dispatch to that worker. Balancer is not called.
7676+2. Otherwise, call `balancer.select(handles, task)` to choose a worker.
7777+3. Dispatch to the chosen worker.
7878+7979+## Dispose Flow
8080+8181+On sync dispose: call `balancer[Symbol.dispose]?.()`, falling back to `balancer[Symbol.asyncDispose]?.()` (fire-and-forget if async).
8282+8383+On async dispose: call `balancer[Symbol.asyncDispose]?.()`, falling back to `balancer[Symbol.dispose]?.()`. If async, await it.
8484+8585+## Files Changed
8686+8787+- `src/runner.ts` — add `Balancer` interface, update `WorkerHandle` with `thread` and `activeCount`, update `WorkerOptions`
8888+- `src/balancers.ts` — new file, `roundRobin` and `leastBusy` implementations
8989+- `src/worker-pool.ts` — overloaded `workers()` signature, integrate balancer into dispatch, activeCount tracking per handle, balancer dispose
9090+- `src/index.ts` — export `Balancer`, `roundRobin`, `leastBusy`
9191+- Tests