Skip to content

WIP: executor — pool TypeScript runner processes per harness module#125

Draft
akrentsel wants to merge 1 commit into
mainfrom
ts-runner-pool
Draft

WIP: executor — pool TypeScript runner processes per harness module#125
akrentsel wants to merge 1 commit into
mainfrom
ts-runner-pool

Conversation

@akrentsel

Copy link
Copy Markdown
Collaborator

Draft / WIP — opening for early review of the approach.

Problem

TypeScriptExecutor kept one Node runner process per module_path and held its mutex for the whole turn (model latency included), serializing every turn that shares a harness module — across all conversations and agents. The agent-scale eval measured this as the dominant throughput ceiling and worked around it with K module-path symlinks round-robin'd by the driver (multiplies the map key rather than fixing the executor; round-robin also idles a slow turn's slot).

Change

Replace the per-runner mutex with a per-module RunnerPool: a semaphore budget over a LIFO free-list of warm runners.

  • Checkout by ownershipacquire hands the runner to the turn; the mutex is gone. A clean turn checks it back in warm; a failed turn drops it (the guest already exits on error, so "turn failed" == "process dead").
  • Free assignment, LIFO reuse — any idle runner serves any turn; load concentrates on hot processes, the rest age out.
  • Lazy spawn, TTL drain — spawn only when a turn arrives and no idle runner exists; idle runners expire after RUNNER_IDLE_TTL (5 min) via a one-shot per-check-in timer holding a Weak to the pool, so dropping the executor reaps them immediately and a quiescent pool drains to zero.
  • Size via EXO_TS_RUNNER_POOL (default 4, clamped to 256).
  • No protocol / guest (runner.ts) / exoharness changes.

Design + semantics: docs/ts-runner-pool-design.md.

Not in scope (deliberate)

Tests

  • 6 unit tests (fake spawner, paused clock): LIFO reuse, drop-discards-and-frees-capacity, saturated-acquire-blocks-then-reuses, TTL expiry, checkout-invalidates-stale-timer, Weak-not-pinned-on-drop.
  • Ignored end-to-end test driving real Node runners (--ignored): 4 concurrent turns on one module → 4 distinct runner PIDs, overlapping, + warm reuse.

Measured (agent-scale, synthetic mock model, single box)

  • vs old serialized executor: ~6× throughput, ~5× lower p50 latency (batch, 8-way).
  • vs the symlink workaround at equal 8-way: +19% throughput, −23% p50, ~equal peak memory.
  • Under light/bursty load: ~40% lower mean memory (lazy spawn + reuse vs 8 pinned runners).

Open / follow-ups

  • Per-module runners map entry is never removed (minor; module set normally fixed).
  • No global ceiling across modules (only per-module pinned + env clamp).

🤖 Draft generated with assistance from Claude.

TypeScriptExecutor kept one Node runner process per module_path and held
its mutex for the whole turn, serializing every turn that shares a harness
module across all conversations and agents. Replace that with a per-module
RunnerPool: a semaphore budget over a LIFO free-list of warm runners.

Checkout transfers ownership of a runner to the turn (the mutex is gone);
a clean turn checks it back in warm, a failed turn drops it (the guest
already exits on error). Idle runners expire after a TTL via a one-shot
per-check-in timer holding a Weak to the pool, so dropping the executor
reaps them immediately and a quiescent pool drains to zero. Size via
EXO_TS_RUNNER_POOL (default 4, clamped to 256).

Adds unit tests (fake spawner) and an ignored end-to-end test driving real
Node runners. Design: docs/ts-runner-pool-design.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@akrentsel

Copy link
Copy Markdown
Collaborator Author

Please don't review yet – am editing still.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant