runWorkflow is not a process lifecycle boundary. The first task
dispatch lazily boots a process-local Effect Cluster “SingleRunner”: an
in-process task routing stack that forks repeating daemon fibers (shard lock
refresh, shard assignment refresh, message polling, runner health). Their timers
keep the event loop alive, so a finite Bun or Node program can finish every run
and still hang.
closeSingleRunnerRuntime() is the teardown boundary. Calling process.exit()
instead is unsafe: a hard exit skips your own database flushes and cleanup.
up --serve, and
the HTTP server deliberately keep the runtime open across many runs, so
Gateway.close() does not close it.
Semantics
Construction failures are unaffected: a build that throws while the runtime is
open stays retryable and the next dispatch rebuilds, exactly as before.
Busy is a lease, not a guess
Two levels of lease keep a close from landing in the middle of live work:- A run lease spans the entirety of one
runWorkflowcall: validation, every task attempt, the driver’s retry backoff between attempts, and cleanup. - A dispatch lease is taken at the top of every task dispatch, before the runtime is even built.
Ordering for a process owner
Only the component that owns the process should close the runtime, and only after it has stopped admitting work:- Stop accepting new runs.
- Abort in-flight runs through
RunOptions.signal. - Await settlement of every
runWorkflowpromise you started. await closeSingleRunnerRuntime().- Clean up your own resources: database handles, servers, backends.
SIGINT or SIGTERM handlers of its own, so this ordering
is entirely yours to choose.
Reopening
reopenSingleRunnerRuntime() exists for long-lived hosts that embed a component
which closes the runtime at its own shutdown. It returns a closed runtime to
idle so the next dispatch rebuilds it. It is a no-op when the runtime is still
usable, and it throws while a close is in flight: await
closeSingleRunnerRuntime() first.