In-place port of dsh-workflow-vm from the in-process node:vm execution to one worker thread per run (the workflow-workerthread engine of PR #215, adopted as THE engine): the script's vm context moves inside the worker, agent() bridges to ctx.subagents over the message port (host.ts/protocol.ts/session.ts/worker.ts are new; runtime.ts loses the abandon channel — the host's grace timer force-settles and TERMINATES instead), start() pre-parses the body host-side to keep the seam's synchronous SCRIPT_PARSE throw, and a ready→go handshake keeps a run cancelled before start from ever executing the body. start() no longer blocks the host, termination is real, and the value boundary is serialization by construction. The package keeps its name until the follow-up rename commit; scripts see the identical hook surface, and the seam-contract tests hardened ahead of this swap pass unchanged. The run and child-RPC surfaces are class-shaped rather than literal bundles: WorkerRun IMPLEMENTS the seam's WorkflowRun (id/meta are its own clone, separate from event payloads') and start() returns the instance directly — interface parity with the seam is compiler-checked; worker-side, ChildRpcBridge (implements ChildPort; callId allocation + pending book-keeping settled by onChild* entry points) and RpcChildHandle (every member an RPC keyed by its callId) carry names in stacks. ChildPort's method is startAgent — it names what it starts, matching the script-side agent() hook and the agentsStarted / workflow/agent-* vocabulary; the Child* type names deliberately stay (the worker side is cordis- and subagent-free; these are reduced JSON projections, not the seam's types). Review findings from the reference PR are folded in rather than re-introduced: - cancel() drives BOTH child-cancel channels host-side: the request signal aborts AND each registered child's explicit cancel() is called — a worker wedged in a synchronous spin cannot relay its own ChildCancel RPCs (regression: cancel-only provider + wedged worker). - All host warn paths render through the total renderThrown; a child dispose() rejecting a value whose coercion throws still acks ChildDisposed instead of wedging the script's finally (regression). - built-worker.e2e.ts is wired into builtBinSmokeGate and the AGENTS.md CI sequence — the built lib/worker.js resolution contract now runs in an automated gate. - workflow/end payload pinned on the worker-death path (with the cancelled and grace-force-settle pins riding the ported spec). - Real-Worker scripted timing budgets widened (50-300ms → 150-1000ms) for starved CI hosts. Workspace plumbing: the "./worker" subpath export sanctions the second runtime bundle (check-workspace-constraints), tsdown builds two single-entry passes, tsx becomes a devDependency for the unbuilt worker spawn.
@deepseek-ai/dsh-workflow-vm
The WorkflowService implementation, on node:worker_threads: each run gets its OWN worker thread (one run = one worker, no pooling — a run is heavyweight, so the ~tens-of-ms thread spin-up is noise), the script executes in a vm context INSIDE that worker with the workflow hooks injected, and every agent() call bridges back over the message port to ctx.subagents on the host. Child agents are I/O-bound LLM loops and stay on the host event loop; the thread isolates the SCRIPT, the only part that can spin synchronously.
Trust premise: what the thread buys (and what it does not)
Workflow scripts are model-written — the same trust level as the model's existing bash access — so this engine defends against buggy scripts, never hostile ones. A worker thread is NOT a security boundary: the vm context inside it is escapable by construction (node:vm shares object machinery with its surrounding realm, so a script can reach the Function constructor via globalThis.constructor.constructor and from it process and every Node builtin), and an escapee holds the same process privileges as the host — Node's permission model is process-wide. The absent globals are API surface that keeps honest scripts portable, not walls. What the thread concretely buys:
- The host never blocks:
start()returns without running any script code on the host; a synchronous spin anywhere in the script occupies the worker's loop, not the harness's. - Termination is real: a script that outlives its post-cancel grace is
worker.terminate()d — nothing of it survivesdispose(), where an in-process engine could only abandon the spin on its own loop. - Serialization by construction: everything crossing the thread is structured-clone data, and plain JSON before that — the
materializeFromRealmwalk rejects loud what JSON cannot carry, which is also what makes every postMessage hop total.
What the seam guarantees regardless, because benign scripts hit these constantly: result never rejects, a dropped hook promise never becomes an unhandled rejection, values JSON cannot carry are rejected loud instead of silently mangled, and hook misuse is fatal instead of dissolving into a per-item null. Genuine sandboxing (containing what an escaped script may touch) remains an isolated-vm/separate-process engine swap behind the seam, still deferred.
The script contract it executes
- Meta extraction (
extractMeta, host-side): a string/comment-aware brace scanner finds the leadingexport const metaliteral (template interpolation rejected — the literal must be pure), evaluates it ALONE in an empty timed vm context, materializes the result to plain JSON data, validates the shape (name/descriptionrequired; unknown fields rejected loud), and blanks the statement line-preservingly so error stacks keep the script's own line numbers. - Hooks:
agent(prompt, {label, phase, schema, model})(schema = the structured-output subset, forwarded asoutputSchema; result = validated object, or final text without a schema; a failed child resolvesnull),parallel(thunks),pipeline(items, ...stages)with NO cross-stage barrier and(prev, item, index)stage callbacks,phase(title),log(message), and theargsglobal. Anything else —effort/isolation/agentType, unknown options, malformed arguments, schemas outside the subset — throws a FATALWorkflowErrorthatparallel/pipelinere-throw rather than nulling (see the seam README's failure discipline). - No ambient APIs: no timers, filesystem, or Node APIs are injected into the context (absence is API surface, not containment — see the trust premise).
How a run executes
start() extracts and validates the meta HOST-side and parse-checks the body with the identical wrapper the worker compiles (new vm.Script, discarded), preserving the seam's synchronous SCRIPT_PARSE/META_INVALID throws; one redundant parse per run is the deliberate price. It then spawns the worker (src/worker.ts unbuilt via an explicit tsx execArgv; the sibling lib/worker.js bundle when built) with the meta, blanked body, args, and worker-side limits as workerData.
Inside the worker, runWorkerSession builds the execution core (hooks, combinators, concurrency semaphore, caps, fatal-error discipline) over a child port: agent() sends child-start and the host starts the child on ctx.subagents (parent attribution, the shared per-run abort signal, outputSchema/model pass-through), replying with the child id, its settlement (a JSON projection; an infrastructure REJECTION crosses as child-failed and stays the fatal AGENT_RESULT), and dispose acks. Observer narration (phase/log/agent-start/agent-end) crosses as messages and re-emits as the seam's workflow/* events. A ready→go handshake gates the body: a cancellation racing worker boot arrives before go, so a run cancelled before start never executes the body at all.
The value boundary
Values LEAVING the script (the meta literal, hook options/schemas, the script's return) are materialized by materializeFromRealm: a plain recursive walk that rejects loud everything JSON cannot carry (exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested undefined), copying into plain containers via defineProperty so a "__proto__" key becomes a data property, never a prototype mutation. Getters are read ordinarily — the RESULT is what crosses; a read that throws fails loud. Values ENTERING the realm (args, agent() results, hook promises and their failures, combinator arrays) are handed over directly as worker-realm values — the script is trusted, so outer prototypes are not a leak; args is cloned once at start so a script scribbling on it cannot mutate the caller's object. One script-visible consequence: an error thrown by a hook is built OUTSIDE the script's vm context, so e instanceof Error inside the script is false — branch on e.name/e.code instead (the combinators recognize fatality by instanceof against their own realm's class, which a script-built object can never pass, so fatal-vs-null cannot be forged or dissolved).
Cancellation, death, disposal
Per-run limits: a concurrency semaphore (maxConcurrentAgents), a total-agent() cap (maxTotalAgents), and a per-call item cap (maxItemsPerCall), all config. cancel() posts the cancel to the worker (its hooks start throwing CANCELLED; the script dies at its next await) and cancels every host-side child NOW on both seam channels — the shared request signal aborts AND each registered child's explicit cancel() is called host-side, because the seam leaves a provider free to honor either channel and a worker wedged in a synchronous spin could not relay its own per-child cancel RPCs (those later land as idempotent no-ops). The grace then arms: a run still unsettled disposeGraceMs later force-settles cancelled and the worker is terminated. A cancellation that lands before the body runs (the ready→go handshake) reports cancelled without executing anything; a worker result racing an in-flight host cancellation reports cancelled too (first-wins settlement — the seam-visible result had not settled when cancellation was requested); post-cancel phase/log narration is suppressed host-side, while cancelled children still deliver their paired agent-end.
A worker that dies unexpectedly (an OOM, a script reaching process.exit through the documented vm escape) settles the run stopReason: 'error' with the exit diagnostics — or 'cancelled' when a cancel was in flight — and the host-side child registry is what winds every surviving child down. dispose() = cancel + bounded wait (result, then child-registry quiescence, capped by the grace) + unconditional worker.terminate(): the thread never outlives its run. Once a run settles, stray children a script fired without awaiting are cancelled too, and dispose() waits for their disposal (bounded by the grace) before returning.
Engine-specific limitations: worker startup is paid per run; on a termination path agentsStarted reports the HOST-observed count (accepted child-starts — calls still queued worker-side for a concurrency slot are unknowable then); and a returned promise or thenable resolves per JavaScript semantics BEFORE materialization — that is what makes an un-awaited return agent('x') work — with the value-boundary guard applying to the resolution.
Config
| Key | Default | Meaning |
|---|---|---|
provider |
spawn |
The ctx.subagents provider children run on (host-side). |
maxConcurrentAgents |
0 (auto) |
Concurrent agent() ceiling; 0 resolves to min(16, max(1, cores - 2)). |
maxTotalAgents |
1000 |
Total agent() calls one run may start (runaway-loop backstop). |
maxItemsPerCall |
4096 |
Items accepted by one parallel()/pipeline() call. |
syncTimeoutMs |
5000 |
vm timeout for the initial synchronous slice (in the worker) and the host-side meta evaluation. |
disposeGraceMs |
5000 |
How long a cancelled run may stay unsettled before force-settle + terminate; also bounds dispose(). |