workflow: swap the engine's internals to node:worker_threads

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.
This commit is contained in:
imccyu
2026-07-09 19:31:14 +08:00
parent fbc9eb313c
commit b5f618bcfb
21 changed files with 2073 additions and 953 deletions
+1 -1
View File
@@ -70,7 +70,7 @@ printf '%s\n' "$out" | grep -q '\[tool call\] echo({"text":"ci smoke"})'
printf '%s\n' "$out" | grep -q '\[tool result\] ECHO: CI SMOKE'
ls .sessions/_no-cwd/main-session-*.jsonl >/dev/null
rm -rf .sessions
pnpm exec vitest run --config vitest.e2e.config.ts packages/ui/stdio-agent/tests/built-bin.e2e.ts packages/ui/acp-agent/tests/built-bin.e2e.ts
pnpm exec vitest run --config vitest.e2e.config.ts packages/ui/stdio-agent/tests/built-bin.e2e.ts packages/ui/acp-agent/tests/built-bin.e2e.ts packages/workflow/workflow-vm/tests/built-worker.e2e.ts
```
`test:coverage`, not `test`, is the gating run ([why](docs/testing.md)); a sign-off counts only for commands actually run.
@@ -10,7 +10,7 @@ import { WorkflowRunId, WorkflowService } from '@deepseek-ai/dsh-workflow'
import type { WorkflowResult, WorkflowRun, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow'
import { CallId } from '@deepseek-ai/dsh-llm'
import SubagentService from '@deepseek-ai/dsh-subagent'
import VmWorkflowEngine from '@deepseek-ai/dsh-workflow-vm'
import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-vm'
import * as toolWorkflow from '../src/index.ts'
/** A controllable engine standing in behind ctx.workflows (the tool's only seam). */
@@ -221,7 +221,7 @@ describe('dsh-tool-workflow', () => {
expect(typeof unwrapped.apply).toBe('function')
})
describe('composition with the REAL vm engine (the mock above must stay honest)', () => {
describe('composition with the REAL worker-thread engine (the mock above must stay honest)', () => {
it('an abort releases the tool even when the script parks on a promise no hook owns', async () => {
// Regression for the review-found turn wedge: the tool awaits
// run.result BEFORE its disposing finally, the registry and the loop
@@ -234,7 +234,7 @@ describe('dsh-tool-workflow', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(SubagentService)
await ctx.plugin(VmWorkflowEngine, { disposeGraceMs: 30 })
await ctx.plugin(WorkerWorkflowEngine, { disposeGraceMs: 30 })
await ctx.plugin(toolWorkflow, {})
const parent = { id: AgentId('caller'), options: {} } as unknown as Agent
const controller = new AbortController()
+25 -11
View File
@@ -1,34 +1,48 @@
# @deepseek-ai/dsh-workflow-vm
The first [`WorkflowService`](../workflow/README.md) implementation: an in-process **`node:vm` engine**. It parses the Claude Code-format script (`export const meta = {...}` + plain-JS body), runs the body in a fresh vm context with the workflow hooks injected, and fans `agent()` calls out to [`ctx.subagents`](../../subagent/README.md).
The [`WorkflowService`](../workflow/README.md) 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`](../../subagent/README.md) 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
## 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. vm is NOT a security boundary and no attempt is made to contain adversarial values: property reads on script values may run script code (a getter, a `toString`, a proxy trap) on the host stack, and a script determined to hang the process can simply spin past its first await (see the limitations below). Concretely, the context is **escapable by construction**: `node:vm` shares object machinery with the host, so a script can reach the host `Function` constructor (`globalThis.constructor.constructor`) and from it `process` and every Node builtin — the absent globals are API surface that keeps honest scripts portable, not walls. What the engine DOES guarantee, because benign scripts hit these constantly: `result` never rejects, a dropped hook promise never becomes an unhandled rejection (the app boot layer exits the process on those), values that 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 is an engine swap behind the seam (worker-thread/isolated-vm, where the boundary is serialization by construction), not incremental host-side defenses here.
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 survives `dispose()`, 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 `materializeFromRealm` walk 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`): a string/comment-aware brace scanner finds the leading `export const meta` literal (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`/`description` required; unknown fields rejected loud), and blanks the statement line-preservingly so error stacks keep the script's own line numbers.
- **Meta extraction** (`extractMeta`, host-side): a string/comment-aware brace scanner finds the leading `export const meta` literal (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`/`description` required; 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](../../core/tools/README.md), forwarded as `outputSchema`; result = validated object, or final text without a schema; a failed child resolves `null`), `parallel(thunks)`, `pipeline(items, ...stages)` with NO cross-stage barrier and `(prev, item, index)` stage callbacks, `phase(title)`, `log(message)`, and the `args` global. Anything else — `effort`/`isolation`/`agentType`, unknown options, malformed arguments, schemas outside the subset — throws a FATAL `WorkflowError` that `parallel`/`pipeline` re-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 ENTERING the host (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 host 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 host values — the script is trusted, so host prototypes are not a leak; `args` is `structuredClone`d 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 a HOST error, so `e instanceof Error` inside the script is `false` — branch on `e.name`/`e.code` instead (the combinators recognize fatality by host `instanceof`, which a script-built object can never pass, so fatal-vs-null cannot be forged or dissolved).
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).
## Limits, cancellation, disposal
## Cancellation, death, disposal
Per-run: a concurrency semaphore (`maxConcurrentAgents`), a total-`agent()` cap (`maxTotalAgents`), and a per-call item cap (`maxItemsPerCall`), all config. `cancel()` aborts every child (a shared `AbortSignal`), rejects waiting `agent()` slots, and makes every future hook call throw `CANCELLED` the script dies at its next await and the run settles `cancelled`; a cancellation that lands before the body runs (or before it settles) reports `cancelled` even if the script itself needed no hooks, and a script that STILL has not settled `disposeGraceMs` after the cancel (parked on a promise no hook owns, like `await new Promise(() => {})`) is ABANDONED with `result` force-settling `cancelled` — a consumer awaiting `result` is never wedged past a cancellation. Once a run settles, stray children a script fired without awaiting are aborted too, and `dispose()` waits for those children to finish disposing (bounded by the grace) before returning. Every hook-returned promise carries a no-op rejection consumer, so a dropped promise cannot surface an unhandled rejection; thrown script values are rendered by a total host-side renderer (stack, then message, then `String()`, with a fixed label if rendering itself throws) — `result` cannot reject.
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`.
**Documented limitations** (the accepted cost of the in-process mechanism; the seam exists so a worker-thread/isolated-vm engine can swap in): `start()` runs the script's initial synchronous slice inline, so the caller blocks until the first await or the vm `timeout`; that `timeout` covers ONLY the initial slice, so a synchronous spin past it (an await continuation, a thenable's `then` invoked by promise resolution, or script code the host runs while rendering a thrown value) cannot be killed; `dispose()` waits `disposeGraceMs` then ABANDONS such a script (its settlement stays contained, but an abandoned spin would still occupy the event loop). A returned promise or thenable resolves per JavaScript semantics BEFORE materialization — that is what makes an un-awaited `return agent('x')` work — and the value-boundary guard applies to the resolution.
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-start`s — 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. |
| `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 and the meta evaluation. |
| `disposeGraceMs` | `5000` | How long `dispose()` waits for a cancelled script and its children before abandoning them. |
| `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()`. |
+8 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-workflow-vm",
"description": "node:vm workflow engine: executes model-written orchestration scripts over ctx.subagents",
"description": "worker-thread workflow engine: executes model-written orchestration scripts off the host event loop, bridging agent() calls back to ctx.subagents",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -11,11 +11,16 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./worker": {
"types": "./lib/types/worker.d.ts",
"default": "./lib/worker.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/worker.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
@@ -45,6 +50,7 @@
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-workflow": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.6",
"tsx": "^4.19.2"
}
}
+384
View File
@@ -0,0 +1,384 @@
/**
* The host half of one worker-engine run: spawn the Worker, bridge its child
* RPC onto `ctx.subagents`, fan its observer messages into the engine's
* events, and own cancellation, the settle-within-grace guarantee, and child
* cleanup. The worker's lifetime IS the run's lifetime: `dispose()` always
* ends with `worker.terminate()`, so no thread outlives its run.
*
* The run's `result` promise settles exactly once, from whichever of these
* lands first: the worker's `result` message (a host-side cancellation in
* flight overrides a non-cancelled report — the seam-visible result had not
* settled when cancellation was requested), an unexpected worker death
* (`error`/`messageerror`/premature `exit` → `stopReason: 'error'`, or
* `'cancelled'` when a cancel was in flight), or the post-cancel grace timer
* (a script that never settles is force-settled `cancelled` and its worker
* terminated — the real kill an in-process engine could not perform).
*
* Children live in a host-side registry (callId → run): the worker drives
* their disposal by RPC on the graceful path, and the registry is what lets
* the host abort and dispose every survivor when the worker dies or is
* terminated mid-flight. On a termination path `agentsStarted` reports the
* HOST-observed count (accepted `child-start` messages) — `agent()` calls
* still queued worker-side for a concurrency slot are unknowable then; the
* worker's own count rides the result message on every graceful path.
*
* @module @deepseek-ai/dsh-workflow-vm/host
*/
import { fileURLToPath } from 'node:url'
import { Worker } from 'node:worker_threads'
import type { WorkerOptions } from 'node:worker_threads'
import type { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { assertNever } from '@deepseek-ai/dsh-llm'
import type { SubagentRun } from '@deepseek-ai/dsh-subagent'
import type { WorkflowMeta, WorkflowResult, WorkflowRun, WorkflowRunId } from '@deepseek-ai/dsh-workflow'
import { renderThrown } from './realm.ts'
import type { ExecutionObserver } from './runtime.ts'
import { HostToWorkerType, WorkerToHostType } from './protocol.ts'
import type { HostToWorkerPayloads, WorkerToHostMessage } from './protocol.ts'
import type { ChildStartRequest, WorkerInit } from './types.ts'
/**
* Resolve the worker entry and spawn options for the current runtime shape.
* Unbuilt (tsx demos, vitest — `import.meta.url` points into `src/`), the
* entry is the TypeScript sibling and the worker needs the tsx loader
* registered explicitly: a worker thread inherits no transform pipeline from
* vitest (vite transforms in-process, not via a node loader), and passing
* execArgv explicitly also shields the worker from any loader flags the
* parent was started with. Built (`lib/index.js`), the entry is the sibling
* bundle the package tsdown config emits and no loader is needed.
* @param init - the run payload, passed as `workerData`.
* @returns the entry URL and the Worker options to spawn it with.
*/
function resolveWorkerSpawn(init: WorkerInit): { entry: URL; options: WorkerOptions } {
/* v8 ignore next 3 -- the built-output arm: tests always run unbuilt (src/); the built-worker e2e exercises this shape for real */
if (!import.meta.url.endsWith('.ts')) {
return { entry: new URL('./worker.js', import.meta.url), options: { workerData: init } }
}
// Lazy tsx resolution: only the unbuilt shape needs it, so the built
// bundle never requires tsx to be installed.
return {
entry: new URL('./worker.ts', import.meta.url),
options: { workerData: init, execArgv: ['--import', fileURLToPath(import.meta.resolve('tsx'))] },
}
}
/**
* One live worker-engine run — the seam's {@link WorkflowRun}, returned by
* `start()` directly. Owns the Worker, the child registry, and the result
* settlement; `result` never rejects. `meta` is this handle's OWN clone
* (event payloads carry separate clones), so a consumer mutating it corrupts
* nothing.
*/
export class WorkerRun implements WorkflowRun {
/** Settles exactly once with the run's outcome; never rejects. */
readonly result: Promise<WorkflowResult>
private settleResolve!: (result: WorkflowResult) => void
private settled = false
private cancelReason: string | undefined
private graceTimer: NodeJS.Timeout | undefined
private readonly worker: Worker
/** Set on `exit`: the thread is gone, so posting has nowhere to go. */
private workerGone = false
/** Accepted `child-start` messages — the terminate-path `agentsStarted` (see module doc). */
private hostStarted = 0
/** Live children by callId; an entry leaves ONLY after its dispose settles (quiescence = empty). */
private readonly children = new Map<number, SubagentRun>()
private readonly quiescenceWaiters: (() => void)[] = []
/** The per-run abort fanout every child start request carries. */
private readonly controller = new AbortController()
private disposed: Promise<void> | undefined
constructor(
private readonly ctx: Context,
readonly id: WorkflowRunId,
readonly meta: WorkflowMeta,
private readonly parent: Agent,
init: WorkerInit,
private readonly provider: string,
private readonly disposeGraceMs: number,
private readonly observer: ExecutionObserver,
signal: AbortSignal | undefined,
) {
this.result = new Promise<WorkflowResult>((resolve) => { this.settleResolve = resolve })
// workerData rides the structured clone: args are plain JSON by the seam
// contract, so the clone is total and doubles as the caller-isolation
// copy (a clone failure throws loud out of start()).
const { entry, options } = resolveWorkerSpawn(init)
this.worker = new Worker(entry, options)
this.worker.on('message', (message: WorkerToHostMessage) => { this.onMessage(message) })
this.worker.on('error', (error) => { this.onWorkerDeath(`workflow worker failed: ${renderThrown(error)}`) })
/* v8 ignore next -- messageerror: not constructible from the engine's own protocol (every payload is JSON data) */
this.worker.on('messageerror', (error) => { this.onWorkerDeath(`workflow worker message failed to deserialize: ${renderThrown(error)}`) })
this.worker.on('exit', (code) => {
this.workerGone = true
this.onWorkerDeath(`workflow worker exited before the run settled (exit code ${code})`)
})
if (signal?.aborted) {
this.cancel('workflow start signal already aborted')
} else {
signal?.addEventListener('abort', () => { this.cancel('workflow signal aborted') }, { once: true })
}
}
/**
* Cancel the run: the worker is told (its hooks start throwing and the
* script dies at its next await), every host-side child is cancelled NOW on
* BOTH seam channels — the shared request signal aborts and each registered
* child's explicit `cancel()` is called (the seam leaves a provider free to
* honor either, and a worker wedged in a synchronous spin could not relay
* its own per-child cancel RPCs until far too late) — and the grace timer
* arms: a run still unsettled `disposeGraceMs` later force-settles
* `cancelled` and its worker is TERMINATED. Idempotent; the first reason
* wins.
* @param reason - human-readable cause (default `'workflow cancelled'`).
*/
cancel(reason?: string): void {
// A settled run has nothing left to cancel: without this guard the
// ordinary consumer path (await result, then dispose -> cancel) would arm
// a grace timer nothing ever clears, pinning the run and its Worker
// closure until the grace expires - a bounded leak per completed run.
if (this.settled || this.cancelReason !== undefined) return
this.cancelReason = reason ?? 'workflow cancelled'
this.post(HostToWorkerType.Cancel, { reason: this.cancelReason })
this.controller.abort(this.cancelReason)
// The explicit channel is driven host-side, not left to the worker: a
// provider honoring only run.cancel() must not wait on a wedged worker's
// ChildCancel relay (those later RPCs land as idempotent no-ops).
for (const run of this.children.values()) run.cancel(this.cancelReason)
this.graceTimer = setTimeout(() => {
this.settleResult(this.cancelledResult(this.hostStarted))
void this.worker.terminate()
}, this.disposeGraceMs)
// unref'd: an armed grace timer must never hold the process open.
this.graceTimer.unref()
}
/**
* Cancel + bounded settle + termination. Waits (at most the grace) for the
* result and child quiescence, then terminates the worker unconditionally
* — the thread never outlives its run — and reaps whatever children
* remain (their disposal is contained, not awaited past the grace, the
* same abandonment the seam documents for a slow-disposing child).
* Idempotent; safe on every path.
* @returns resolves when the run's resources are released or abandoned.
*/
dispose(): Promise<void> {
this.disposed ??= (async () => {
this.cancel('workflow disposed')
await Promise.race([
(async () => {
await this.result
await this.childQuiescence()
})(),
sleep(this.disposeGraceMs),
])
await this.worker.terminate()
this.reapChildren('workflow disposed')
})()
return this.disposed
}
/** Post one message to the worker (payload looked up from the tag's map entry), tolerating a thread that is already gone. */
private post<T extends HostToWorkerType>(type: T, payload: HostToWorkerPayloads[T]): void {
if (this.workerGone) return
try {
this.worker.postMessage({ type, ...payload })
} catch (error: unknown) {
// Only a teardown race can land here (every engine message is JSON
// data, so serialization cannot fail); there is nothing left to
// deliver to — log and move on.
/* v8 ignore next -- postMessage teardown race (a throw between exit and its event): not constructible in-process */
this.ctx.logger.warn(`workflow-vm: postMessage failed: ${renderThrown(error)}`)
}
}
private onMessage(message: WorkerToHostMessage): void {
switch (message.type) {
case WorkerToHostType.Ready:
this.post(HostToWorkerType.Go, {})
break
case WorkerToHostType.Phase:
// Post-cancel narration is suppressed host-side: worker-side the
// hooks throw once the cancel message is PROCESSED, but narration
// already in flight (or emitted while the cancel crossed the
// boundary) must not reach observers — nothing is emitted after
// cancel() returns.
if (this.cancelReason === undefined) this.observer.phase(message.title)
break
case WorkerToHostType.Log:
if (this.cancelReason === undefined) this.observer.log(message.message)
break
case WorkerToHostType.AgentStart:
this.observer.agentStart(message.info)
break
case WorkerToHostType.AgentEnd:
// NOT suppressed on cancel: cancelled children report their paired
// agent-end with outcome 'cancelled' (the one-pair-per-started-child
// contract holds on every stop path).
this.observer.agentEnd(message.info)
break
case WorkerToHostType.ChildStart:
this.onChildStart(message.callId, message.request)
break
case WorkerToHostType.ChildCancel:
this.children.get(message.callId)?.cancel(message.reason)
break
case WorkerToHostType.ChildDispose:
this.onChildDispose(message.callId)
break
case WorkerToHostType.Result:
this.onResult(message.result)
break
/* v8 ignore next 2 -- closed engine-owned union; the arm only makes adding a message type a compile error */
default:
assertNever(message, 'worker-to-host message')
}
}
private onChildStart(callId: number, request: ChildStartRequest): void {
if (this.cancelReason !== undefined) {
// The worker's start raced our cancel: refuse — a child must never
// start on an already-aborted signal (a provider subscribing only to
// future abort events would never observe it).
this.post(HostToWorkerType.ChildStartError, { callId, rendered: `workflow run cancelled: ${this.cancelReason}` })
return
}
this.hostStarted += 1
let run: SubagentRun
try {
run = this.ctx.subagents.start(this.provider, {
prompt: [{ type: 'text', text: request.prompt }],
parent: this.parent,
signal: this.controller.signal,
...request.schema !== undefined ? { outputSchema: request.schema } : {},
...request.model !== undefined ? { agentOptions: { model: request.model } } : {},
})
} catch (error: unknown) {
this.post(HostToWorkerType.ChildStartError, { callId, rendered: renderThrown(error) })
return
}
this.children.set(callId, run)
this.post(HostToWorkerType.ChildStarted, { callId, childId: run.id })
run.result.then(
(result) => {
this.post(HostToWorkerType.ChildSettled, {
callId,
result: {
output: result.output,
...result.structured !== undefined ? { structured: result.structured } : {},
stopReason: result.stopReason,
},
})
},
(error: unknown) => { this.post(HostToWorkerType.ChildFailed, { callId, rendered: renderThrown(error) }) },
)
}
private onChildDispose(callId: number): void {
const run = this.children.get(callId)
/* v8 ignore next 5 -- dispose RPC for an already-reaped child: only a worker-death race can produce it, not orderable in-process */
if (run === undefined) {
// Already reaped — the ack is still owed (the worker-side wrapper awaits it).
this.post(HostToWorkerType.ChildDisposed, { callId })
return
}
void run.dispose().then(
() => {
this.finishChild(callId)
this.post(HostToWorkerType.ChildDisposed, { callId })
},
(error: unknown) => {
// The subagent seam's dispose() is not supposed to reject; a backend
// that does anyway must not wedge the script's finally (which awaits
// the ack) — ack and move on.
this.ctx.logger.warn(`workflow-vm: child dispose failed: ${renderThrown(error)}`)
this.finishChild(callId)
this.post(HostToWorkerType.ChildDisposed, { callId })
},
)
}
/** Drop a child from the registry, releasing quiescence waiters at zero. */
private finishChild(callId: number): void {
this.children.delete(callId)
if (this.children.size === 0) {
for (const waiter of this.quiescenceWaiters.splice(0)) waiter()
}
}
/** Resolves once the child registry is empty (every disposal settled). */
private childQuiescence(): Promise<void> {
if (this.children.size === 0) return Promise.resolve()
return new Promise((resolve) => { this.quiescenceWaiters.push(resolve) })
}
/** Abort + dispose every registered child (worker death / final teardown); disposal is contained, not awaited. */
private reapChildren(reason: string): void {
this.controller.abort(this.cancelReason ?? reason)
for (const [callId, run] of [...this.children]) {
run.cancel(this.cancelReason ?? reason)
void run.dispose().then(
() => { this.finishChild(callId) },
(error: unknown) => {
this.ctx.logger.warn(`workflow-vm: child dispose failed during reap: ${renderThrown(error)}`)
this.finishChild(callId)
},
)
}
}
private onResult(result: WorkflowResult): void {
// The worker's settle-reap already child-cancel()s every stray; this
// abort fires the seam signal too, for providers that only honor the
// request signal (both channels, on every path).
if (this.cancelReason === undefined) this.controller.abort('workflow settled')
if (this.cancelReason !== undefined && result.stopReason !== 'cancelled') {
// The script settled while our cancel was crossing the thread boundary
// — the seam-visible result had NOT settled when cancellation was
// requested, so report cancelled (the vm drive()'s post-settle check,
// relocated to the receiving side of the race).
this.settleResult(this.cancelledResult(result.agentsStarted))
return
}
this.settleResult(result)
}
/** An unexpected worker death (or the expected exit after termination). */
private onWorkerDeath(message: string): void {
// Whatever the worker left behind must not leak — abort + dispose it all.
if (this.children.size > 0) this.reapChildren('workflow worker gone')
// settleResult no-ops on an already-settled run (the expected exit after
// a dispose's terminate lands here too).
if (this.cancelReason !== undefined) {
this.settleResult(this.cancelledResult(this.hostStarted))
return
}
this.settleResult({ value: null, stopReason: 'error', error: message, agentsStarted: this.hostStarted })
}
private cancelledResult(agentsStarted: number): WorkflowResult {
// cancel() is the only writer of cancelReason and every caller checks it
// first; the fallback guards the type, not a reachable path.
/* v8 ignore next */
const reason = this.cancelReason ?? 'workflow cancelled'
return { value: null, stopReason: 'cancelled', error: `workflow run cancelled: ${reason}`, agentsStarted }
}
/** First settle wins; disarms the grace timer. */
private settleResult(result: WorkflowResult): void {
if (this.settled) return
this.settled = true
clearTimeout(this.graceTimer)
this.settleResolve(result)
}
}
/** A plain timer sleep (the dispose grace); unref'd so it never holds the process open. */
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(resolve, ms)
timer.unref()
})
}
+89 -95
View File
@@ -1,39 +1,37 @@
/**
* The `node:vm` workflow engine: the first {@link WorkflowService}
* implementation. Parses the Claude Code-format script (meta + body), runs the
* body in a fresh in-process vm context with the workflow hooks injected, and
* fans `agent()` calls out to `ctx.subagents`.
* The `node:worker_threads` workflow engine: the {@link WorkflowService}
* implementation. Runs each script in its OWN worker thread (one run = one
* worker, no pooling — a run is heavyweight, so thread spin-up is noise): the
* body executes in a vm context INSIDE the worker with the workflow hooks
* injected, and `agent()` calls bridge back to `ctx.subagents` over the
* message port — 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: 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. vm is NOT a security boundary and no attempt
* is made to contain adversarial values (see ./realm.ts); the context is
* escapable by construction (the host `Function` constructor is reachable via
* `globalThis.constructor.constructor`, and `process` from there), so the
* absent globals are API surface, not containment. Genuine sandboxing is an
* engine swap behind the seam (worker-thread/isolated-vm), not incremental
* host-side defenses here.
* scripts, never hostile ones. A worker thread is NOT a security boundary:
* the vm context inside it is escapable by construction, and an escapee
* holds the same process privileges as the host (Node's permission model is
* process-wide); genuine sandboxing (isolated-vm, a separate process) is an
* engine swap behind the seam. What the thread buys, concretely:
*
* Engine limitations, documented as the accepted cost of the in-process
* mechanism:
* - `start()` never blocks the host: the script's initial synchronous slice
* (and any later synchronous spin) occupies the WORKER's event loop, not
* the harness's.
* - Termination is REAL: a script that outlives its post-cancel grace is
* `worker.terminate()`d — nothing of the script survives `dispose()`,
* where an in-process engine could only abandon the spin on its own loop.
* - The value boundary is serialization by construction: everything crossing
* the thread is structured-clone data (and plain JSON before that, by the
* materialization walk in ./realm.ts).
*
* - `start()` runs the script's initial SYNCHRONOUS slice inline, so the
* CALLER blocks on the host event loop until the script's first await (or
* the vm `timeout` kills the slice); the meta-literal evaluation has its
* own timeout budget on the same call.
* - The vm `timeout` covers only that initial slice; realm code running past
* it — an await continuation, a thenable's `then` invoked by promise
* resolution (including one the script RETURNS: a returned thenable
* resolves per JavaScript semantics before materialization, which is what
* makes an un-awaited `return agent('x')` work) — is beyond the timeout, so
* a synchronous spin there cannot be killed in-process, and neither can
* script code the host invokes while rendering a failure (a getter on a
* thrown value). `dispose()` waits a bounded grace for the script to settle
* AND its children (stray `agent()` calls included) to finish disposing,
* then ABANDONS whatever is left: pending hook promises are already
* rejected and the script's settlement is contained (no unhandled
* rejection), but an abandoned synchronous spin would still occupy the
* event loop.
* Engine-specific limitations: worker startup (~tens of ms) is paid per run;
* on a termination path `agentsStarted` reports the host-observed child
* count (calls still queued worker-side for a slot are unknowable — see
* ./host.ts); and 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.
*
* Plugin export shape: a default-exported {@link WorkflowService} subclass
* (the class-based service form, like `dsh-bash-local`).
@@ -43,16 +41,29 @@
import { randomUUID } from 'node:crypto'
import { availableParallelism } from 'node:os'
import * as vm from 'node:vm'
import type { Context } from 'cordis'
import z from 'schemastery'
import WorkflowService, { WorkflowRunId } from '@deepseek-ai/dsh-workflow'
import type { WorkflowResult, WorkflowRun, WorkflowRunInfo, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow'
import WorkflowService, { WorkflowError, WorkflowRunId } from '@deepseek-ai/dsh-workflow'
import type { WorkflowRun, WorkflowRunInfo, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow'
import { WorkerRun } from './host.ts'
import { extractMeta } from './meta.ts'
import { WorkflowExecution, type ExecutionLimits } from './runtime.ts'
import type { WorkerInit, WorkerLimits } from './types.ts'
export { extractMeta, type ExtractedScript } from './meta.ts'
export { HostToWorkerType, WorkerToHostType } from './protocol.ts'
export type { HostToWorkerMessage, HostToWorkerPayloads, WorkerToHostMessage, WorkerToHostPayloads } from './protocol.ts'
export { materializeFromRealm, MaterializeError } from './realm.ts'
export { WorkflowExecution, type ExecutionLimits, type ExecutionObserver } from './runtime.ts'
export { WorkflowExecution, type ExecutionObserver } from './runtime.ts'
export { requireParentPort, runWorkerSession } from './session.ts'
export type {
ChildHandle,
ChildPort,
ChildResult,
ChildStartRequest,
WorkerInit,
WorkerLimits,
} from './types.ts'
/** Plugin config (all optional — `static Config` supplies the defaults). */
export interface Config {
@@ -64,12 +75,12 @@ export interface Config {
maxTotalAgents?: number
/** Items accepted by a single `parallel()`/`pipeline()` call (default 4096). */
maxItemsPerCall?: number
/** vm timeout for the script's initial synchronous slice AND the meta-literal evaluation (default 5000 ms). */
/** vm timeout for the initial synchronous slice (inside the worker) AND the host-side meta evaluation (default 5000 ms). */
syncTimeoutMs?: number
/**
* How long after a cancellation an unsettled script may keep running before
* it is abandoned and `result` force-settles `cancelled` (default 5000 ms);
* also bounds `dispose()`.
* the run force-settles `cancelled` and its worker is TERMINATED (default
* 5000 ms); also bounds `dispose()`.
*/
disposeGraceMs?: number
}
@@ -77,11 +88,27 @@ export interface Config {
type ResolvedConfig = Required<Config>
/**
* The vm engine service. `start()` validates the script up front (meta +
* body compile) and returns a {@link WorkflowRun} whose `result` never
* rejects; the `workflow/*` events fire around the run per the seam contract.
* Parse-check the body with the SAME wrapper the worker-side runtime
* compiles, so `start()` keeps the seam's synchronous `SCRIPT_PARSE` throw
* (the worker's own compile happens a thread away, after `start()` returned).
* One redundant parse per run, bought deliberately for the contract.
*/
export class VmWorkflowEngine extends WorkflowService {
function assertBodyParses(body: string, name: string): void {
try {
// Parse only — the script object is discarded, nothing executes.
void new vm.Script(`(async () => {\n${body}\n})()`, { filename: `workflow:${name}`, lineOffset: -1 })
} catch (error: unknown) {
throw new WorkflowError(`workflow script does not parse: ${String(error)}`, 'SCRIPT_PARSE', { cause: error })
}
}
/**
* The worker-thread engine service. `start()` validates the script up front
* (meta + a host-side body parse) and returns a {@link WorkflowRun} whose
* `result` never rejects; the `workflow/*` events fire around the run per
* the seam contract.
*/
export class WorkerWorkflowEngine extends WorkflowService {
static inject = ['subagents']
static Config: z<Config> = z.object({
@@ -103,51 +130,56 @@ export class VmWorkflowEngine extends WorkflowService {
}
/**
* Parse and execute a workflow script. Throws {@link WorkflowError}
* synchronously (`SCRIPT_PARSE`/`META_INVALID`) for a script that cannot
* begin; once a run is returned, every failure resolves through
* `result.stopReason` instead.
* Parse and execute a workflow script in a fresh worker thread. Throws
* {@link WorkflowError} synchronously (`SCRIPT_PARSE`/`META_INVALID`) for a
* script that cannot begin; once a run is returned, every failure resolves
* through `result.stopReason` instead.
* @param request - the script, its `args`, the parent agent, and an
* optional cancel signal.
* @returns the live run (its `result` resolves when the script settles).
*/
start(request: WorkflowStartRequest): WorkflowRun {
const { meta, body } = extractMeta(request.script, this.config.syncTimeoutMs)
assertBodyParses(body, meta.name)
const id = WorkflowRunId(randomUUID())
// The event payloads and the run handle get SEPARATE meta clones: a
// listener mutating its snapshot must not corrupt the holder's view.
const info: WorkflowRunInfo = { id, meta: structuredClone(meta) }
const limits: ExecutionLimits = {
provider: this.config.provider,
const limits: WorkerLimits = {
maxConcurrentAgents: this.config.maxConcurrentAgents === 0
? Math.min(16, Math.max(1, availableParallelism() - 2))
: this.config.maxConcurrentAgents,
maxTotalAgents: this.config.maxTotalAgents,
maxItemsPerCall: this.config.maxItemsPerCall,
syncTimeoutMs: this.config.syncTimeoutMs,
disposeGraceMs: this.config.disposeGraceMs,
}
const execution = new WorkflowExecution(
this.ctx,
const init: WorkerInit = {
meta,
body,
request.parent,
request.args,
request.signal,
...request.args !== undefined ? { args: request.args } : {},
limits,
}
const workerRun = new WorkerRun(
this.ctx,
id,
structuredClone(meta),
request.parent,
init,
this.config.provider,
this.config.disposeGraceMs,
{
phase: (title) => { this.emitWorkflowEvent('workflow/phase', info, title) },
log: (message) => { this.emitWorkflowEvent('workflow/log', info, message) },
agentStart: (agent) => { this.emitWorkflowEvent('workflow/agent-start', info, agent) },
agentEnd: (agent) => { this.emitWorkflowEvent('workflow/agent-end', info, agent) },
},
request.signal,
)
this.emitWorkflowEvent('workflow/start', info)
const result: Promise<WorkflowResult> = execution.drive()
// `workflow/end` fires as the (never-rejecting) result settles, with the
// outcome DATA only — the value stays with the run's holder.
void result.then((settled) => {
void workerRun.result.then((settled) => {
this.emitWorkflowEvent('workflow/end', info, {
stopReason: settled.stopReason,
...settled.error !== undefined ? { error: settled.error } : {},
@@ -155,46 +187,8 @@ export class VmWorkflowEngine extends WorkflowService {
})
})
let disposed: Promise<void> | undefined
return {
id,
meta: structuredClone(meta),
result,
cancel(reason?: string): void {
execution.cancel(reason)
},
dispose: (): Promise<void> => {
// Idempotent: cancel, then wait min(settle + child quiescence, grace).
// The cancel itself bounds `result` (the execution abandons a script
// still unsettled `disposeGraceMs` later), so this outer race exists
// for CHILD quiescence: a slow-disposing child must not hold dispose
// past the grace. `result` and `quiesce()` never reject, so the race
// needs no rejection handling.
disposed ??= (async () => {
execution.cancel('workflow disposed')
await Promise.race([
(async () => {
await result
// The result settles with the SCRIPT; stray children a script
// fired without awaiting are still winding down — dispose must
// not return while they hold live resources.
await execution.quiesce()
})(),
sleep(this.config.disposeGraceMs),
])
})()
return disposed
},
}
return workerRun
}
}
/** A plain timer sleep (the dispose grace); unref'd so it never holds the process open. */
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(resolve, ms)
timer.unref()
})
}
export default VmWorkflowEngine
export default WorkerWorkflowEngine
@@ -0,0 +1,115 @@
/**
* The host⇄worker wire protocol: one string-valued enum of message tags per
* direction, a payload map giving each tag its parameters (the single source
* of truth), and the message unions derived from them. Everything in a
* payload is plain JSON data by construction (the runtime materializes
* script values before they reach a message; the host projects seam results
* down to their JSON fields), so the structured-clone hop never meets a
* value it cannot carry.
*
* Both directions are CLOSED (engine-owned): each side switches on `type`
* and ends with `assertNever` — an unknown message is a protocol bug, never
* something to skip silently. Senders go through a generic
* `post(type, payload)` whose payload parameter is looked up from the map,
* so a tag/payload mismatch is a compile error at the call site.
*
* @module @deepseek-ai/dsh-workflow-vm/protocol
*/
import type { WorkflowAgentEndInfo, WorkflowAgentInfo, WorkflowResult } from '@deepseek-ai/dsh-workflow'
import type { ChildResult, ChildStartRequest } from './types.ts'
/** Message tags the worker sends the host (the wire values are the tag strings). */
export enum WorkerToHostType {
/** The startup handshake: the session is listening and awaits {@link HostToWorkerType.Go}. */
Ready = 'ready',
/** Observer narration: a `phase(title)` call. */
Phase = 'phase',
/** Observer narration: a `log(message)` call. */
Log = 'log',
/** Observer lifecycle: one `agent()` call started a child. */
AgentStart = 'agent-start',
/** Observer lifecycle: one `agent()` call settled. */
AgentEnd = 'agent-end',
/** Child RPC: start a child on the host (answered by ChildStarted or ChildStartError). */
ChildStart = 'child-start',
/** Child RPC: cancel a started child (fire-and-forget). */
ChildCancel = 'child-cancel',
/** Child RPC: dispose a started child (answered by ChildDisposed). */
ChildDispose = 'child-dispose',
/** The run's single terminal result. */
Result = 'result',
}
/** The payload each worker→host tag carries. */
export interface WorkerToHostPayloads {
/** Ready carries nothing. */
[WorkerToHostType.Ready]: Record<never, never>
/** The phase title, verbatim. */
[WorkerToHostType.Phase]: { title: string }
/** The logged message, verbatim. */
[WorkerToHostType.Log]: { message: string }
/** The call's sequence number, label, phase, and child id. */
[WorkerToHostType.AgentStart]: { info: WorkflowAgentInfo }
/** The call identity plus its outcome. */
[WorkerToHostType.AgentEnd]: { info: WorkflowAgentEndInfo }
/** The RPC correlation id and the prompt plus validated options. */
[WorkerToHostType.ChildStart]: { callId: number; request: ChildStartRequest }
/** The RPC correlation id and the cancel reason (undefined = unspecified). */
[WorkerToHostType.ChildCancel]: { callId: number; reason: string | undefined }
/** The RPC correlation id of the child to dispose. */
[WorkerToHostType.ChildDispose]: { callId: number }
/** The run's terminal outcome. */
[WorkerToHostType.Result]: { result: WorkflowResult }
}
/** Message tags the host sends the worker (the wire values are the tag strings). */
export enum HostToWorkerType {
/** Releases the startup gate: run the script body. */
Go = 'go',
/** Cancel the run: hooks start throwing and the script dies at its next await. */
Cancel = 'cancel',
/** Child RPC reply: the start succeeded (exactly one of ChildStarted/ChildStartError per ChildStart). */
ChildStarted = 'child-started',
/** Child RPC reply: the start was refused or threw. */
ChildStartError = 'child-start-error',
/** Child RPC: a started child's result RESOLVED (its JSON projection). */
ChildSettled = 'child-settled',
/** Child RPC: a started child's result REJECTED (an infrastructure fault, rendered). */
ChildFailed = 'child-failed',
/** Child RPC reply: a requested disposal completed. */
ChildDisposed = 'child-disposed',
}
/** The payload each host→worker tag carries. */
export interface HostToWorkerPayloads {
/** Go carries nothing. */
[HostToWorkerType.Go]: Record<never, never>
/** The cancel reason, canonical for the whole run. */
[HostToWorkerType.Cancel]: { reason: string }
/** The RPC correlation id and the child agent's id (minted by the subagent seam). */
[HostToWorkerType.ChildStarted]: { callId: number; childId: string }
/** The RPC correlation id and the rendered start failure. */
[HostToWorkerType.ChildStartError]: { callId: number; rendered: string }
/** The RPC correlation id and the child's terminal result projection. */
[HostToWorkerType.ChildSettled]: { callId: number; result: ChildResult }
/** The RPC correlation id and the rendered infrastructure fault. */
[HostToWorkerType.ChildFailed]: { callId: number; rendered: string }
/** The RPC correlation id of the completed disposal. */
[HostToWorkerType.ChildDisposed]: { callId: number }
}
/**
* One worker→host message of tag `T`; unparameterized, the closed union over
* every tag (a discriminated union — `switch` on `type` narrows).
*/
export type WorkerToHostMessage<T extends WorkerToHostType = WorkerToHostType> =
{ [K in T]: { type: K } & WorkerToHostPayloads[K] }[T]
/**
* One host→worker message of tag `T`; unparameterized, the closed union over
* every tag (a discriminated union — `switch` on `type` narrows).
*/
export type HostToWorkerMessage<T extends HostToWorkerType = HostToWorkerType> =
{ [K in T]: { type: K } & HostToWorkerPayloads[K] }[T]
+14 -13
View File
@@ -1,7 +1,10 @@
/**
* The vm engine's value boundary: copy script-realm values into plain host
* JSON data — loud about everything JSON cannot carry — and render thrown
* script values to failure text.
* The engine's value boundary: copy script-realm values into plain JSON data
* — loud about everything JSON cannot carry — and render thrown script
* values to failure text. The script runs in a vm context INSIDE the worker
* thread, so "host" here means the worker-side JavaScript around that
* context; everything that later crosses the thread boundary is JSON by this
* walk, which is what makes the postMessage hop total.
*
* TRUST PREMISE (everything in this module hangs on it): workflow scripts are
* MODEL-WRITTEN, the same trust level as the model's existing bash access, so
@@ -13,18 +16,16 @@
* properties ordinarily (a getter runs, and whatever it returns is what
* crosses), {@link renderThrown} reads `stack`/`message`/`String()` directly,
* and a proxy is walked through its traps. A hostile script gains nothing
* worth defending here — it can already occupy the event loop forever with a
* synchronous spin past the first await (the engine's documented, accepted
* limitation) — so host-side hostile-value containment would be cost without
* a threat model; genuine hardening is an ENGINE SWAP (worker/isolated-vm,
* where the boundary is serialization by construction), not incremental
* defenses here.
* worth defending here — the vm context inside the worker is escapable by
* construction, so hostile-value containment would be cost without a threat
* model (what the worker thread DOES buy is that a spin occupies the
* worker's loop, not the host's, and termination is real).
*
* The host→realm direction needs no machinery at all: hooks hand the script
* plain host values, host prototypes included — the script is trusted. One
* consequence is documented in the engine README: an error thrown by a hook
* is a HOST error, so an in-script `instanceof Error` check is false; read
* `name`/`code`/`message` instead.
* plain values of the worker realm, prototypes included — the script is
* trusted. One consequence is documented in the engine README: an error
* thrown by a hook is built OUTSIDE the script's vm context, so an in-script
* `instanceof Error` check is false; read `name`/`code`/`message` instead.
*
* @module @deepseek-ai/dsh-workflow-vm/realm
*/
+102 -162
View File
@@ -1,40 +1,44 @@
/**
* Per-run execution state for the vm workflow engine: the script context and
* its injected hooks (`agent`/`parallel`/`pipeline`/`phase`/`log`/`args`), the
* concurrency semaphore and caps, cancellation, and the drive loop that turns
* a script settlement into a {@link WorkflowResult}.
* Per-run execution state for the engine's THREAD side: the script's vm
* context and its injected hooks (`agent`/`parallel`/`pipeline`/`phase`/
* `log`/`args`), the concurrency semaphore and caps, cancellation, and the
* drive loop that turns a script settlement into a {@link WorkflowResult}.
* Children are started by RPC to the host through a {@link ChildPort}, so
* this module never touches a cordis context — it runs inside the worker
* thread.
*
* Value boundary (the trust premise lives in ./realm.ts): values ENTERING the
* host from the script (hook options, schemas, the return value) are
* materialized by `materializeFromRealm` — a plain walk that rejects loud
* everything JSON cannot carry. Values ENTERING the realm (`args`, `agent()`
* results, hook promises and their failures, combinator arrays) are handed
* over DIRECTLY as host values: the script is model-written and trusted, so
* host prototypes are not a leak. `args` is host-side `structuredClone`d once
* at start so a script scribbling on it cannot mutate the caller's object —
* that is a benign-bug guard, not isolation. Realm functions (pipeline
* stages, parallel thunks) are called, not materialized — their values stay
* realm-side until they cross through a hook or the final return.
* worker-side host code from the script (hook options, schemas, the return
* value) are materialized by `materializeFromRealm` — a plain walk that
* rejects loud everything JSON cannot carry, which also makes every value
* safe for the later postMessage hop. 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 model-written
* and trusted, so outer prototypes are not a leak. `args` is cloned once at
* start so a script scribbling on it cannot mutate the session's init object
* (a benign-bug guard; the postMessage clone already isolated the caller).
*
* Failure discipline: fatal {@link WorkflowError}s (bad hook arguments,
* unsupported options/schemas, tripped caps, seam start failures and result
* rejections, cancellation) ALWAYS propagate through `parallel`/`pipeline` —
* recognized by host `instanceof`, which a script cannot forge — and the
* per-item `null` is reserved for child-run failures and ordinary in-stage
* script errors.
* Every hook-returned promise gets a no-op rejection consumer attached, so a
* script that drops a promise (fires an `agent()` without awaiting it) cannot
* surface an unhandled rejection when cancellation rejects it — the app boot
* layer exits the process on unhandled rejections.
* unsupported options/schemas, tripped caps, host start refusals and child
* result rejections, cancellation) ALWAYS propagate through
* `parallel`/`pipeline` — recognized by `instanceof` against this realm's
* class, which a script inside the vm context cannot forge — and the per-item
* `null` is reserved for child-run failures and ordinary in-stage script
* errors. Every hook-returned promise gets a no-op rejection consumer, so a
* dropped promise cannot surface an unhandled rejection (which would kill the
* worker and read as an engine fault).
*
* There is deliberately NO worker-side abandon channel: a script that never
* settles after a cancel simply never posts a result, and the HOST enforces
* the settles-within-grace guarantee by force-settling `cancelled` and
* terminating the worker — the real kill an in-process engine could not have.
*
* @module @deepseek-ai/dsh-workflow-vm/runtime
*/
import * as vm from 'node:vm'
import type { Context } from 'cordis'
import { AgentId } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-subagent'
import { assertSupportedOutputSchema, OutputSchemaError } from '@deepseek-ai/dsh-tools'
import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
import { isFatalWorkflowError, WorkflowError } from '@deepseek-ai/dsh-workflow'
@@ -45,24 +49,9 @@ import type {
WorkflowResult,
} from '@deepseek-ai/dsh-workflow'
import { materializeFromRealm, MaterializeError, renderThrown } from './realm.ts'
import type { ChildHandle, ChildPort, WorkerLimits } from './types.ts'
/** The per-run knobs the engine resolves from its Config. */
export interface ExecutionLimits {
/** The `ctx.subagents` provider name to start children on. */
provider: string
/** Concurrent `agent()` ceiling (already auto-resolved; ≥ 1). */
maxConcurrentAgents: number
/** Total `agent()` calls per run (the runaway-loop backstop). */
maxTotalAgents: number
/** Items accepted by one `parallel()`/`pipeline()` call. */
maxItemsPerCall: number
/** vm timeout for the script's initial synchronous slice. */
syncTimeoutMs: number
/** How long after `cancel()` a still-unsettled script is abandoned (result force-settles `cancelled`). */
disposeGraceMs: number
}
/** The engine-side observers the execution reports progress through. */
/** The observers the execution reports progress through (the session posts them to the host). */
export interface ExecutionObserver {
phase(title: string): void
log(message: string): void
@@ -91,9 +80,9 @@ function defaultLabel(prompt: string): string {
}
/**
* One live script execution. Constructed per run by the engine; `drive()` is
* called exactly once and NEVER rejects — every failure becomes a
* {@link WorkflowResult} with a non-`completed` stop reason.
* One live script execution inside the worker. Constructed per run by the
* session; `drive()` is called exactly once and NEVER rejects — every failure
* becomes a {@link WorkflowResult} with a non-`completed` stop reason.
*/
export class WorkflowExecution {
/** 1-based count of `agent()` calls started (the `agentsStarted` result field). */
@@ -106,36 +95,19 @@ export class WorkflowExecution {
private currentPhase: string | undefined
private readonly context: vm.Context
private readonly compiled: vm.Script
/** Every live `agent()` call promise — awaited or stray — for {@link quiesce}. */
private readonly inFlightAgents = new Set<Promise<unknown>>()
/** Fires {@link abandoned}; assigned by the promise executor at field initialization. */
private declareAbandoned!: () => void
private abandonTimer: NodeJS.Timeout | undefined
/**
* Rejects `disposeGraceMs` after {@link cancel} if the script has not
* settled by then. `drive()` races the script against it, so `result`
* ALWAYS settles within the grace of a cancellation — even when the script
* is parked on a promise no hook owns (`await new Promise(() => {})`), which
* cancellation cannot reject. Without this, a consumer awaiting `result`
* before disposing (the tool's shape) would hang forever on such a script,
* wedging its caller past any abort.
*/
private readonly abandoned = new Promise<never>((_, reject) => {
this.declareAbandoned = () => { reject(new WorkflowError('workflow script abandoned after the cancellation grace', 'CANCELLED')) }
})
constructor(
private readonly ctx: Context,
meta: WorkflowMeta,
body: string,
private readonly parent: Agent,
args: unknown,
signal: AbortSignal | undefined,
private readonly limits: ExecutionLimits,
private readonly limits: WorkerLimits,
private readonly observer: ExecutionObserver,
private readonly children: ChildPort,
) {
// Compile FIRST: a body syntax error must throw out of the constructor
// (the engine maps it to SCRIPT_PARSE) before any realm state exists.
// before any realm state exists. The host pre-parses the identical
// wrapper, so under one Node version this throw is unreachable in
// production — the session still maps it to an error result defensively.
// lineOffset compensates for the wrapper line, so stack traces carry the
// script's own line numbers (the meta statement was blanked, not removed).
try {
@@ -148,20 +120,17 @@ export class WorkflowExecution {
}
this.context = vm.createContext({}, { name: `workflow:${meta.name}` })
// A run that settles without ever being abandoned leaves `abandoned`
// permanently pending or rejecting into the void — consume it so a late
// grace timer cannot surface an unhandled rejection.
void this.contain(this.abandoned)
const globals: Record<string, unknown> = {
agent: (prompt: unknown, opts?: unknown) => this.contain(this.track(this.agent(prompt, opts))),
agent: (prompt: unknown, opts?: unknown) => this.contain(this.agent(prompt, opts)),
parallel: (thunks: unknown) => this.contain(this.parallel(thunks)),
pipeline: (items: unknown, ...stages: unknown[]) => this.contain(this.pipeline(items, stages)),
phase: (title: unknown) => { this.phase(title) },
log: (message: unknown) => { this.log(message) },
// Host-side clone: a script scribbling on args must not mutate the
// caller's object (a benign-bug guard; args is plain JSON by the seam
// contract, so structuredClone is total here and throws loud otherwise).
// Cloned once: a script scribbling on args must not mutate the
// session's init object (a benign-bug guard; args is plain JSON by the
// seam contract and already crossed one structured clone as workerData,
// so this clone is total).
args: args === undefined ? undefined : structuredClone(args),
}
for (const [key, value] of Object.entries(globals)) {
@@ -169,18 +138,12 @@ export class WorkflowExecution {
// a script overwriting its own hooks only sabotages itself.
;(this.context as Record<string, unknown>)[key] = typeof value === 'function' ? Object.freeze(value) : value
}
if (signal?.aborted) {
this.cancel('workflow start signal already aborted')
} else {
signal?.addEventListener('abort', () => { this.cancel('workflow signal aborted') }, { once: true })
}
}
/**
* Whether the run has been cancelled. A METHOD, not an inline property
* read: `cancel()` mutates `cancelReason` concurrently (a signal listener,
* a raced dispose), and an inline read after an `await` gets narrowed by
* read: `cancel()` mutates `cancelReason` concurrently (the session's
* message handler), and an inline read after an `await` gets narrowed by
* control flow into an always-false comparison.
*/
private isCancelled(): boolean {
@@ -199,45 +162,41 @@ export class WorkflowExecution {
}
/**
* Cancel the run: children abort (the shared signal), waiting `agent()`
* slots reject, and every future hook call throws `CANCELLED` — the script
* dies at its next await. A script that STILL has not settled after
* `disposeGraceMs` (parked on a promise no hook owns) is abandoned so
* `result` settles regardless (see {@link abandoned}). Idempotent; the
* first reason wins.
* Cancel the run: in-flight children get a cancel RPC (the shared abort
* fanout), waiting `agent()` slots reject, and every future hook call
* throws `CANCELLED` — the script dies at its next await. A script that
* never settles anyway (parked on a promise no hook owns) is the HOST's
* problem: its grace timer force-settles the run and terminates the
* worker. Idempotent; the first reason wins.
* @param reason - human-readable cause, carried on the CANCELLED error and
* into child `run.cancel()` calls (default `'workflow cancelled'`).
* into child cancel RPCs. Required: every caller (the session's cancel
* message, drive()'s settle-reap) has a concrete reason.
*/
cancel(reason?: string): void {
cancel(reason: string): void {
if (this.cancelReason !== undefined) return
this.cancelReason = reason ?? 'workflow cancelled'
this.cancelReason = reason
this.cancelError = new WorkflowError(`workflow run cancelled: ${this.cancelReason}`, 'CANCELLED')
this.controller.abort(this.cancelReason)
for (const waiter of this.slotWaiters.splice(0)) waiter.reject(this.cancelledError())
this.abandonTimer = setTimeout(() => { this.declareAbandoned() }, this.limits.disposeGraceMs)
// unref'd: an armed grace timer must never hold the process open.
this.abandonTimer.unref()
}
/**
* Run the script to settlement. Resolves — never rejects — with the run's
* {@link WorkflowResult}: the materialized return value on `completed`, the
* failure message on `error`, and `cancelled` when the script died of
* cancellation (or outlived its post-cancel grace and was abandoned — see
* {@link abandoned}). After settlement, any stray children a script fired
* without awaiting are aborted (their `agent()` wrappers dispose them).
* cancellation. After settlement, any stray children a script fired without
* awaiting are cancelled (their `agent()` wrappers dispose them via RPC).
* @returns the settled outcome — this promise NEVER rejects (the seam's
* `result`-never-rejects contract); every failure maps to a variant.
*/
async drive(): Promise<WorkflowResult> {
try {
// Cancelled before the body ever ran (an already-aborted start signal):
// the script must not execute at all, let alone report `completed`.
// Cancelled before the body ever ran (an already-aborted start signal,
// relayed by the host before its `go`): the script must not execute at
// all, let alone report `completed`.
if (this.isCancelled()) throw this.cancelledError()
const scriptPromise = this.compiled.runInContext(this.context, { timeout: this.limits.syncTimeoutMs }) as Promise<unknown>
// The race is the result-settles-after-cancel guarantee: a parked
// script loses to the abandon channel once the grace expires.
const raw: unknown = await Promise.race([this.contain(Promise.resolve(scriptPromise)), this.abandoned])
const raw: unknown = await this.contain(Promise.resolve(scriptPromise))
// Cancelled while the body ran: a script that settled without touching
// another hook (or without any) must still report `cancelled` — the
// holder asked for cancellation and `completed` would be a lie.
@@ -250,59 +209,30 @@ export class WorkflowExecution {
if (this.isCancelled()) {
return { value: null, stopReason: 'cancelled', error: this.cancelledError().message, agentsStarted: this.started }
}
// renderThrown is total (host- and realm-thrown values alike), so this
// arm cannot throw — drive() resolving is the `result` never-rejects
// seam contract.
// renderThrown is total (thrown values of any realm), so this arm
// cannot throw — drive() resolving is the `result` never-rejects seam
// contract.
return { value: null, stopReason: 'error', error: renderThrown(error), agentsStarted: this.started }
} finally {
// Reap strays: a script that fired agent() calls without awaiting them
// leaves live children behind after settlement — abort them all. (The
// leaves live children behind after settlement — cancel them all. (The
// per-call wrappers dispose each child; the contain() consumer keeps
// their rejections from going unhandled.)
if (this.cancelReason === undefined) this.cancel('workflow settled')
// drive() settling means nothing is left to abandon — including the
// timer the self-cancel above just armed (cancel() always arms it, so
// it is never undefined here; clearTimeout tolerates undefined anyway).
clearTimeout(this.abandonTimer)
}
}
/**
* Attach a no-op rejection consumer WITHOUT changing what the caller
* receives: if the script drops the promise (no await), cancellation cannot
* become an unhandled rejection (the app boot layer exits the process on
* those); if the script does await it, it still observes the rejection.
* become an unhandled rejection (which would kill the worker thread); if
* the script does await it, it still observes the rejection.
*/
private contain<T>(promise: Promise<T>): Promise<T> {
promise.catch(() => { /* consumed: see method contract — a dropped hook promise must not surface an unhandled rejection */ })
return promise
}
/**
* Register one `agent()` call promise for {@link quiesce} tracking; the
* entry drops when the call fully settles (which is AFTER its child's
* `dispose()` — the call wrapper disposes in its `finally`).
*/
private track<T>(promise: Promise<T>): Promise<T> {
this.inFlightAgents.add(promise)
const drop = (): void => { this.inFlightAgents.delete(promise) }
promise.then(drop, drop)
return promise
}
/**
* Settles once every `agent()` call — awaited or stray — has fully settled,
* INCLUDING each child's `dispose()`. The reap in {@link drive}'s finally
* aborts strays; this is the wait for those aborts to reach quiescence, so
* the engine's `dispose()` cannot return while a child is still winding
* down. Never rejects (the tracked promises' rejections are contained).
*/
async quiesce(): Promise<void> {
while (this.inFlightAgents.size > 0) {
await Promise.allSettled([...this.inFlightAgents])
}
}
private cancelledError(): WorkflowError {
// cancel() arms cancelError before any caller can observe isCancelled()
// === true; the fallback guards the type, not a reachable path.
@@ -375,28 +305,37 @@ export class WorkflowExecution {
// Re-check after the acquire: the await yields at least one microtask
// tick even when a slot is free, and a queued waiter resumes a tick
// after its release — a cancel() landing in either window must not
// start a child (it would carry an ALREADY-aborted signal, which a
// provider subscribing only to future abort events would never see).
// reach the host (which would refuse anyway, but the refusal reads as
// a start failure rather than the cancellation it is).
this.throwIfCancelled()
let run
let run: ChildHandle
try {
run = this.ctx.subagents.start(this.limits.provider, {
prompt: [{ type: 'text', text: rawPrompt }],
parent: this.parent,
signal: this.controller.signal,
...opts.schema !== undefined ? { outputSchema: opts.schema } : {},
...opts.model !== undefined ? { agentOptions: { model: opts.model } } : {},
run = await this.children.startAgent({
prompt: rawPrompt,
...opts.schema !== undefined ? { schema: opts.schema } : {},
...opts.model !== undefined ? { model: opts.model } : {},
})
} catch (error: unknown) {
throw new WorkflowError(`agent() could not start a child on provider "${this.limits.provider}": ${String(error)}`, 'AGENT_START', { cause: error })
// The host refuses starts once the run is cancelled — a refusal that
// races our own cancel state must read as the cancellation it is,
// not as a broken seam.
if (this.isCancelled()) throw this.cancelledError()
throw new WorkflowError(`agent() could not start a child: ${renderThrown(error)}`, 'AGENT_START', { cause: error })
}
const info: WorkflowAgentInfo = { seq, label, ...phase !== undefined ? { phase } : {}, childId: run.id }
// The start round-trip yields to the event loop, so a cancel CAN land
// between the host starting the child and this continuation running —
// wind the fresh child down instead of leaving it live behind a dead
// script.
if (this.isCancelled()) {
run.cancel(this.cancelReason)
await run.dispose()
throw this.cancelledError()
}
const info: WorkflowAgentInfo = { seq, label, ...phase !== undefined ? { phase } : {}, childId: AgentId(run.id) }
this.observer.agentStart(info)
// Cancellation bridges to run.cancel() as well as the request signal:
// the seam leaves a provider free to honor either channel, so the
// consumer must drive both. The signal cannot be aborted yet (the block
// since the post-acquire check is synchronous), so the listener always
// arms; `once` plus the finally removal keep it leak-free.
// Cancellation reaches the child through an explicit cancel RPC per
// child (the host also aborts its own per-run signal, but the seam
// leaves a provider free to honor either channel, so both are driven).
const onAbort = (): void => { run.cancel(this.cancelReason) }
this.controller.signal.addEventListener('abort', onAbort, { once: true })
try {
@@ -404,8 +343,8 @@ export class WorkflowExecution {
try {
result = await run.result
} catch (error: unknown) {
// The seam allows `result` to reject for an INFRASTRUCTURE fault
// distinct from a child that failed and resolved. Pair the
// A rejected child result is an INFRASTRUCTURE fault relayed by the
// host — distinct from a child that failed and resolved. Pair the
// lifecycle before propagating, and propagate FATAL: an ordinary
// throw would dissolve to a per-item null inside the combinators,
// and a broken provider must not read as a failed child.
@@ -510,9 +449,10 @@ export class WorkflowExecution {
try {
return await thunk()
} catch (error: unknown) {
// Hook failures are host WorkflowErrors; a fatal one is recognized by
// host `instanceof` — a script-built object can never pass it, so
// fatality cannot be forged (nor accidentally dissolved).
// Hook failures are WorkflowErrors built OUTSIDE the script's realm;
// fatality is recognized by `instanceof` against this realm's class —
// a script-built object can never pass it, so fatality cannot be
// forged (nor accidentally dissolved).
if (isFatalWorkflowError(error)) throw error
return null
}
@@ -544,8 +484,8 @@ export class WorkflowExecution {
return value
} catch (error: unknown) {
// An ordinary stage throw drops the ITEM to null and skips its
// remaining stages; a fatal host WorkflowError (see parallel()) kills
// the whole script.
// remaining stages; a fatal WorkflowError (see parallel()) kills the
// whole script.
if (isFatalWorkflowError(error)) throw error
return null
}
@@ -0,0 +1,210 @@
/**
* The worker-side half of the engine: {@link runWorkerSession} wires one
* MessagePort to one {@link WorkflowExecution} — hook progress and child
* starts go out as messages, run control and child lifecycle come back in —
* and posts the run's terminal result exactly once. Deliberately separated
* from the thread bootstrap (./worker.ts): the whole session is drivable
* in-process over a `MessageChannel`, which is where its unit coverage lives
* (code inside a real Worker is invisible to the main process's coverage).
*
* Startup handshake: the session posts `ready` and runs the script only
* after the host's `go` — without it, a cancellation racing the worker's
* boot could arrive AFTER the script's initial synchronous slice already
* ran, and a run cancelled before start must not execute the body at all.
* A `cancel` arriving instead of `go` still releases the gate: `drive()`
* sees the cancelled state and settles without running the body.
*
* @module @deepseek-ai/dsh-workflow-vm/session
*/
import type { MessagePort } from 'node:worker_threads'
import { assertNever } from '@deepseek-ai/dsh-llm'
import { HostToWorkerType, WorkerToHostType } from './protocol.ts'
import type { HostToWorkerMessage, WorkerToHostPayloads } from './protocol.ts'
import { renderThrown } from './realm.ts'
import { WorkflowExecution } from './runtime.ts'
import type { ExecutionObserver } from './runtime.ts'
import type {
ChildHandle,
ChildPort,
ChildResult,
ChildStartRequest,
WorkerInit,
} from './types.ts'
/** The book-keeping for one in-flight child RPC (keyed by callId). */
interface PendingChild {
started: PromiseWithResolvers<string>
settled: PromiseWithResolvers<ChildResult>
disposed: PromiseWithResolvers<void>
}
/** The typed post half of the port: each tag pairs with ITS payload from the map (a mismatch is a compile error at the call site). */
type Post = <T extends WorkerToHostType>(type: T, payload: WorkerToHostPayloads[T]) => void
/**
* The worker-side handle for one started child agent ({@link ChildHandle}):
* every member is an RPC to the host keyed by this call's `callId`, resolved
* by the session's message handler through the bridge's pending entry.
*/
class RpcChildHandle implements ChildHandle {
readonly result: Promise<ChildResult>
constructor(
private readonly post: Post,
private readonly callId: number,
private readonly entry: PendingChild,
readonly id: string,
) {
this.result = entry.settled.promise
}
cancel(reason?: string): void {
this.post(WorkerToHostType.ChildCancel, { callId: this.callId, reason })
}
dispose(): Promise<void> {
this.post(WorkerToHostType.ChildDispose, { callId: this.callId })
return this.entry.disposed.promise
}
}
/**
* The worker-side child-RPC bridge ({@link ChildPort}): allocates callIds,
* posts the start/cancel/dispose RPCs, and owns the per-call pending
* book-keeping the session's message handler settles via the `onChild*`
* entry points.
*/
class ChildRpcBridge implements ChildPort {
private nextCallId = 0
private readonly pending = new Map<number, PendingChild>()
constructor(private readonly post: Post) {}
async startAgent(request: ChildStartRequest): Promise<ChildHandle> {
this.nextCallId += 1
const callId = this.nextCallId
const entry: PendingChild = {
started: Promise.withResolvers<string>(),
settled: Promise.withResolvers<ChildResult>(),
disposed: Promise.withResolvers<void>(),
}
// Containment: when the start is refused (or the run torn down) the
// settled promise may never gain a consumer — it must not surface as an
// unhandled rejection and kill the worker.
entry.settled.promise.catch(() => { /* consumed: unconsumed child settlement after a refused start */ })
this.pending.set(callId, entry)
this.post(WorkerToHostType.ChildStart, { callId, request })
const childId = await entry.started.promise
return new RpcChildHandle(this.post, callId, entry, childId)
}
/** The host started the child; releases the `startAgent` await. */
onChildStarted(callId: number, childId: string): void {
this.pending.get(callId)?.started.resolve(childId)
}
/** The host refused the start; `startAgent` rejects with the rendered cause. */
onChildStartError(callId: number, rendered: string): void {
this.pending.get(callId)?.started.reject(new Error(rendered))
}
/** The child's terminal result arrived. */
onChildSettled(callId: number, result: ChildResult): void {
this.pending.get(callId)?.settled.resolve(result)
}
/** The child's `result` rejected host-side (an infrastructure fault, relayed as fatal). */
onChildFailed(callId: number, rendered: string): void {
this.pending.get(callId)?.settled.reject(new Error(rendered))
}
/** The host acked the dispose; the call's book-keeping is complete. */
onChildDisposed(callId: number): void {
const entry = this.pending.get(callId)
this.pending.delete(callId)
entry?.disposed.resolve()
}
}
/**
* Narrow the nullable `parentPort` the bootstrap reads from
* `node:worker_threads`.
* @param port - `parentPort` as imported (null on the main thread).
* @returns the port, non-null.
*/
export function requireParentPort(port: MessagePort | null): MessagePort {
if (port === null) throw new Error('the workflow worker entry must be loaded inside a worker thread (no parentPort)')
return port
}
/**
* Run one workflow script to settlement against `port`, posting the terminal
* result message exactly once; resolves after that post (stray children may
* still be winding down through the port — the host owns their teardown and
* ultimately terminates the thread). Never rejects: a constructor failure
* (unparseable body — host pre-parse makes this a Node-version-skew signal)
* is reported as an `error` result rather than dying without a result.
* @param port - the channel to the host (the real `parentPort`, or one side
* of an in-process `MessageChannel` in tests).
* @param init - the run payload the host provided as `workerData`.
*/
export async function runWorkerSession(port: MessagePort, init: WorkerInit): Promise<void> {
const post: Post = (type, payload) => {
port.postMessage({ type, ...payload })
}
const children = new ChildRpcBridge(post)
const observer: ExecutionObserver = {
phase: (title) => { post(WorkerToHostType.Phase, { title }) },
log: (message) => { post(WorkerToHostType.Log, { message }) },
agentStart: (info) => { post(WorkerToHostType.AgentStart, { info }) },
agentEnd: (info) => { post(WorkerToHostType.AgentEnd, { info }) },
}
let execution: WorkflowExecution
try {
execution = new WorkflowExecution(init.meta, init.body, init.args, init.limits, observer, children)
} catch (error: unknown) {
post(WorkerToHostType.Result, { result: { value: null, stopReason: 'error', error: renderThrown(error), agentsStarted: 0 } })
return
}
const gate = Promise.withResolvers<void>()
port.on('message', (message: HostToWorkerMessage) => {
switch (message.type) {
case HostToWorkerType.Go:
gate.resolve()
break
case HostToWorkerType.Cancel:
execution.cancel(message.reason)
// A cancel doubles as the gate release: drive() checks the cancelled
// state before running the body, so the script never executes.
gate.resolve()
break
case HostToWorkerType.ChildStarted:
children.onChildStarted(message.callId, message.childId)
break
case HostToWorkerType.ChildStartError:
children.onChildStartError(message.callId, message.rendered)
break
case HostToWorkerType.ChildSettled:
children.onChildSettled(message.callId, message.result)
break
case HostToWorkerType.ChildFailed:
children.onChildFailed(message.callId, message.rendered)
break
case HostToWorkerType.ChildDisposed:
children.onChildDisposed(message.callId)
break
/* v8 ignore next 2 -- closed engine-owned union; the arm only makes adding a message type a compile error */
default:
assertNever(message, 'host-to-worker message')
}
})
post(WorkerToHostType.Ready, {})
await gate.promise
const result = await execution.drive()
post(WorkerToHostType.Result, { result })
}
@@ -0,0 +1,97 @@
/**
* Non-protocol wire vocabulary for the worker-thread engine: the `workerData` init
* payload and the child-port interfaces the worker-side runtime consumes.
* The host⇄worker MESSAGE protocol lives in ./protocol.ts; everything here
* that a message transports (`ChildStartRequest`, `ChildResult`) is plain
* JSON data by construction, so the structured-clone hop never meets a value
* it cannot carry. Types only, per the package convention.
*
* @module @deepseek-ai/dsh-workflow-vm/types
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
import type { WorkflowMeta } from '@deepseek-ai/dsh-workflow'
/**
* The per-run limits the worker-side runtime enforces. The host keeps the
* knobs only it can act on (`provider`, `disposeGraceMs`).
*/
export interface WorkerLimits {
/** Concurrent `agent()` ceiling (already auto-resolved; ≥ 1). */
maxConcurrentAgents: number
/** Total `agent()` calls per run (the runaway-loop backstop). */
maxTotalAgents: number
/** Items accepted by one `parallel()`/`pipeline()` call. */
maxItemsPerCall: number
/** vm timeout for the script's initial synchronous slice (inside the worker). */
syncTimeoutMs: number
}
/** The `workerData` payload one run is initialized with (host → worker, once, at spawn). */
export interface WorkerInit {
/** The validated meta block (extracted host-side). */
meta: WorkflowMeta
/** The script body with the meta statement blanked (host-side `extractMeta`). */
body: string
/** The run's `args` value; the workerData structured clone is the copy that isolates the caller. */
args?: unknown
/** The worker-enforced limits. */
limits: WorkerLimits
}
/** What the worker asks the host to start for one `agent()` call (options already validated worker-side). */
export interface ChildStartRequest {
/** The child's prompt text. */
prompt: string
/** The structured-output schema, if the call passed one (already subset-checked). */
schema?: StructuredOutputSchema
/** The per-child model override, if the call passed one. */
model?: string
}
/**
* The JSON projection of a child's `SubagentResult` crossing the port. The
* seam's `stopReason` union is merge-extensible, so it degrades to `string`
* on the wire — the runtime only ever branches on `'completed'`.
*/
export interface ChildResult {
/** The child's final assistant output blocks. */
output: ContentBlock[]
/** The structured value, present iff the request carried a schema AND the provider honored it. */
structured?: unknown
/** Why the child run ended (`'completed'` is the only value the runtime branches on). */
stopReason: string
}
/**
* The worker-side handle for one started child — the RPC mirror of the
* subagent seam's run handle, reduced to what the runtime consumes.
*/
export interface ChildHandle {
/** The child agent's id (minted host-side by the subagent seam). */
readonly id: string
/**
* Resolves with the child's terminal {@link ChildResult}; REJECTS only when
* the host reports an infrastructure fault (`child-failed`) — a child that
* failed for its own reasons resolves with a non-`completed` stop reason.
*/
readonly result: Promise<ChildResult>
/** Ask the host to cancel the child (fire-and-forget). */
cancel(reason?: string): void
/** Ask the host to dispose the child; resolves on the host's ack. */
dispose(): Promise<void>
}
/**
* The worker-side port the runtime starts child agents through — the seam
* that lets the execution core stay ignorant of the thread boundary.
*/
export interface ChildPort {
/**
* Start one child agent on the host (the `agent()` hook's start half).
* @param request - the prompt and validated options.
* @returns the child handle; rejects when the host refuses the start.
*/
startAgent(request: ChildStartRequest): Promise<ChildHandle>
}
@@ -0,0 +1,18 @@
/**
* The worker-thread entry the engine spawns: bootstrap ./session.ts on the
* real `parentPort`. Deliberately a single statement — every piece of logic
* lives in `runWorkerSession`, which the unit suite drives in-process over a
* `MessageChannel` (code inside a real Worker is invisible to main-process
* coverage); loading this module on the main thread throws via
* `requireParentPort`, which is how the suite covers the file itself.
*
* @module @deepseek-ai/dsh-workflow-vm/worker
*/
import { parentPort, workerData } from 'node:worker_threads'
import { requireParentPort, runWorkerSession } from './session.ts'
import type { WorkerInit } from './types.ts'
// workerData is `any` at the node:worker_threads boundary; the engine is the
// only spawner and always provides a WorkerInit.
void runWorkerSession(requireParentPort(parentPort), workerData as WorkerInit)
@@ -0,0 +1,56 @@
import { existsSync } from 'node:fs'
import { rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const packageRoot = fileURLToPath(new URL('..', import.meta.url))
const builtIndex = join(packageRoot, 'lib', 'index.js')
const builtWorker = join(packageRoot, 'lib', 'worker.js')
const run = promisify(execFile)
/**
* The BUILT-output guard for the worker entry: every other suite runs
* unbuilt (src/ + tsx), so nothing else proves that `lib/index.js` resolves
* its sibling `lib/worker.js` and that the bundle boots a worker under plain
* node (no tsx loader). Keyless — a zero-agent script needs no provider —
* and self-skips until `pnpm run build` has produced the bundles.
*/
describe.skipIf(!existsSync(builtIndex) || !existsSync(builtWorker))('built worker entry (lib/worker.js)', () => {
it('the built engine spawns its built worker under plain node and completes a run', async () => {
// ESM resolves bare specifiers from the IMPORTING FILE's location, so the
// driver must live inside the package for its node_modules to apply — a
// temp-named file at the package root, removed on the way out.
const driver = join(packageRoot, `.built-worker-driver-${process.pid}.mjs`)
try {
await writeFile(driver, `
import { Context } from 'cordis'
import SubagentService from '@deepseek-ai/dsh-subagent'
import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-vm'
const ctx = new Context()
await ctx.plugin(SubagentService)
await ctx.plugin(WorkerWorkflowEngine, {})
const run = ctx.workflows.start({
script: "export const meta = { name: 'built-smoke', description: 'built worker smoke' }\\nreturn 6 * 7",
// A zero-agent script never touches the provider, so a bare id suffices.
parent: { id: 'built-smoke-parent', options: {} },
})
const result = await run.result
await run.dispose()
if (result.stopReason !== 'completed' || result.value !== 42) {
console.error('unexpected result: ' + JSON.stringify(result))
process.exit(1)
}
console.log('built-worker-smoke-ok')
`, 'utf8')
// Plain node — no tsx loader anywhere; the bundle must stand on its own.
const { stdout } = await run(process.execPath, [driver], { cwd: packageRoot, timeout: 60_000 })
expect(stdout).toContain('built-worker-smoke-ok')
} finally {
await rm(driver, { force: true })
}
}, 120_000)
})
@@ -11,15 +11,17 @@ import SubagentService from '@deepseek-ai/dsh-subagent'
import * as spawn from '@deepseek-ai/dsh-subagent-spawn'
import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import VmWorkflowEngine from '../src/index.ts'
import WorkerWorkflowEngine from '../src/index.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
/**
* The whole in-process stack, keyless: the vm engine drives the REAL spawn
* backend (with its structured runtime) on a real agent loop; the scripted
* mock MODEL is the only mocked boundary. This is the integration guard the
* per-hook unit tests (which stub the subagent seam) structurally cannot give.
* The whole in-process stack, keyless, with the script in a REAL worker
* thread: the engine drives the REAL spawn backend (with its
* structured runtime) on a real agent loop; the scripted mock MODEL is the
* only mocked boundary. This is the guard the unit suites structurally
* cannot give — the MessageChannel suite fakes the host, and the host suite
* stubs the subagent seam.
*/
async function setup(script: Script) {
const ctx = new Context()
@@ -33,7 +35,7 @@ async function setup(script: Script) {
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
await ctx.plugin(spawn, { providerName: 'spawn' })
await ctx.plugin(VmWorkflowEngine, {})
await ctx.plugin(WorkerWorkflowEngine, {})
ctx.llm.registerAdapter(['mock'], adapter)
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
return { ctx, parent, adapter }
@@ -0,0 +1,504 @@
import { describe, expect, it, vi } from 'vitest'
import { MessageChannel } from 'node:worker_threads'
import type { MessagePort } from 'node:worker_threads'
import { HostToWorkerType, WorkerToHostType } from '../src/protocol.ts'
import type { HostToWorkerMessage, WorkerToHostMessage } from '../src/protocol.ts'
import { requireParentPort, runWorkerSession } from '../src/session.ts'
import type { ChildResult, WorkerInit } from '../src/types.ts'
/** Default limits for in-process sessions (concurrency pinned; auto is machine-derived). */
function limits(overrides?: Partial<WorkerInit['limits']>): WorkerInit['limits'] {
return { maxConcurrentAgents: 8, maxTotalAgents: 1000, maxItemsPerCall: 4096, syncTimeoutMs: 5000, ...overrides }
}
/** Wrap a body in the minimal valid meta header (the session receives it pre-extracted). */
function init(body: string, args?: unknown, limitOverrides?: Partial<WorkerInit['limits']>): WorkerInit {
return {
meta: { name: 'test-flow', description: 'a test workflow' },
body,
...args !== undefined ? { args } : {},
limits: limits(limitOverrides),
}
}
/** One scripted host over the other end of a MessageChannel. */
interface FakeHost {
port: MessagePort
messages: WorkerToHostMessage[]
/** Messages of one type, as they arrive. */
ofType<T extends WorkerToHostMessage['type']>(type: T): Extract<WorkerToHostMessage, { type: T }>[]
send(message: HostToWorkerMessage): void
/** Resolves with the terminal result message. */
result(): Promise<Extract<WorkerToHostMessage, { type: 'result' }>['result']>
close(): void
}
interface FakeHostOptions {
/** Auto-respond to child-start: reply started + settled per child index. Omit a reply to leave the child pending. */
reply?: (request: { prompt: string; schema?: unknown; model?: string }, index: number) => ChildResult | undefined
/** Reject the start instead (child-start-error) when returning a string. */
refuse?: (index: number) => string | undefined
/** Auto-send `go` on `ready` (default true). */
go?: boolean
/** Manual mode: do NOT auto-answer child-start at all (the test scripts the replies). */
manual?: boolean
}
/**
* Drive runWorkerSession IN-PROCESS over a MessageChannel: this is where the
* worker-side files earn their coverage — code inside a real Worker is
* invisible to main-process coverage. The fake host mirrors the real host's
* protocol discipline (one started/start-error per start; settled/disposed
* follow).
*/
function fakeHost(options?: FakeHostOptions): FakeHost {
const channel = new MessageChannel()
const messages: WorkerToHostMessage[] = []
const resultGate = Promise.withResolvers<Extract<WorkerToHostMessage, { type: 'result' }>['result']>()
let childIndex = 0
channel.port1.on('message', (message: WorkerToHostMessage) => {
messages.push(message)
switch (message.type) {
case WorkerToHostType.Ready:
if (options?.go !== false) channel.port1.postMessage({ type: HostToWorkerType.Go } satisfies HostToWorkerMessage)
break
case WorkerToHostType.ChildStart: {
if (options?.manual) break
const index = childIndex
childIndex += 1
const refusal = options?.refuse?.(index)
if (refusal !== undefined) {
channel.port1.postMessage(
{ type: HostToWorkerType.ChildStartError, callId: message.callId, rendered: refusal } satisfies HostToWorkerMessage,
)
break
}
channel.port1.postMessage({ type: HostToWorkerType.ChildStarted, callId: message.callId, childId: `child-${index}` } satisfies HostToWorkerMessage)
const reply = options?.reply?.(message.request, index)
if (reply !== undefined) {
channel.port1.postMessage(
{ type: HostToWorkerType.ChildSettled, callId: message.callId, result: reply } satisfies HostToWorkerMessage,
)
}
break
}
case WorkerToHostType.ChildDispose:
channel.port1.postMessage({ type: HostToWorkerType.ChildDisposed, callId: message.callId } satisfies HostToWorkerMessage)
break
case WorkerToHostType.Result:
resultGate.resolve(message.result)
break
default:
break
}
})
return {
port: channel.port2,
messages,
ofType: type => messages.filter((message): message is never => message.type === type),
send: (message) => { channel.port1.postMessage(message) },
result: () => resultGate.promise,
close: () => { channel.port1.close() },
}
}
/** A completed text child result. */
function text(reply: string): ChildResult {
return { output: [{ type: 'text', text: reply }], stopReason: 'completed' }
}
describe('runWorkerSession over an in-process MessageChannel', () => {
it('runs a script end to end: ready/go handshake, phases, log, agents, result', async () => {
const host = fakeHost({ reply: (_request, index) => text(`answer-${index}`) })
const session = runWorkerSession(host.port, init(`
phase('Scan')
log('starting with ' + args.files.length + ' files')
const answers = await pipeline(args.files, (prev, item) => agent('read ' + item))
return { answers }
`, { files: ['a.ts', 'b.ts'] }))
const result = await host.result()
await session
expect(result.stopReason).toBe('completed')
expect(result.agentsStarted).toBe(2)
expect(result.value).toEqual({ answers: ['answer-0', 'answer-1'] })
expect(host.messages[0]!.type).toBe('ready')
expect(host.ofType(WorkerToHostType.Phase).map(m => m.title)).toEqual(['Scan'])
expect(host.ofType(WorkerToHostType.Log).map(m => m.message)).toEqual(['starting with 2 files'])
expect(host.ofType(WorkerToHostType.AgentStart).map(m => m.info.childId)).toEqual(['child-0', 'child-1'])
expect(host.ofType(WorkerToHostType.AgentEnd).every(m => m.info.outcome === 'completed')).toBe(true)
host.close()
})
it('agent({schema}) forwards the schema on the start request and returns the structured value', async () => {
const host = fakeHost({ reply: () => ({ output: [], structured: { files: ['x.ts'] }, stopReason: 'completed' }) })
void runWorkerSession(host.port, init(`
const found = await agent('list files', { schema: { type: 'object', properties: { files: { type: 'array', items: { type: 'string' } } } }, model: 'deepseek-v4-pro' })
return { first: found.files[0] }
`))
const result = await host.result()
expect(result.value).toEqual({ first: 'x.ts' })
const start = host.ofType(WorkerToHostType.ChildStart)[0]!
expect(start.request.schema).toEqual({ type: 'object', properties: { files: { type: 'array', items: { type: 'string' } } } })
expect(start.request.model).toBe('deepseek-v4-pro')
host.close()
})
it('a schema child completing WITHOUT a structured value resolves null with a failed outcome', async () => {
const host = fakeHost({ reply: () => text('prose, no structure') })
void runWorkerSession(host.port, init("return await agent('p', { schema: { type: 'object' } })"))
const result = await host.result()
expect(result.value).toBeNull()
expect(host.ofType(WorkerToHostType.AgentEnd)[0]!.info.outcome).toBe('failed')
host.close()
})
it('a child settling non-completed resolves null (scripts filter), never throwing into the script', async () => {
const host = fakeHost({ reply: (_request, index) => index === 0 ? { output: [], stopReason: 'error' } : text('ok') })
void runWorkerSession(host.port, init("return await parallel([() => agent('one'), () => agent('two')])"))
const result = await host.result()
expect(result.value).toEqual([null, 'ok'])
expect(host.ofType(WorkerToHostType.AgentEnd).map(m => m.info.outcome)).toEqual(expect.arrayContaining(['failed', 'completed']))
host.close()
})
it('a start refusal (child-start-error) is a fatal AGENT_START that kills the script through a combinator', async () => {
const host = fakeHost({ refuse: () => 'no provider here' })
void runWorkerSession(host.port, init("return await pipeline([1], () => agent('p'))"))
const result = await host.result()
expect(result.stopReason).toBe('error')
expect(result.error).toContain('agent() could not start a child')
expect(result.error).toContain('no provider here')
host.close()
})
it('a child-failed message (infrastructure rejection) is fatal AGENT_RESULT with the paired failed outcome', async () => {
const host = fakeHost()
void runWorkerSession(host.port, init(`
try { await agent('p'); return 'unreachable' } catch (e) { return { name: e.name, code: e.code, fatal: e.fatal } }
`))
await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) })
const callId = host.ofType(WorkerToHostType.ChildStart)[0]!.callId
host.send({ type: HostToWorkerType.ChildStarted, callId, childId: 'child-0' })
host.send({ type: HostToWorkerType.ChildFailed, callId, rendered: 'backend exploded' })
const result = await host.result()
expect(result.value).toMatchObject({ name: 'WorkflowError', code: 'AGENT_RESULT', fatal: true })
expect(host.ofType(WorkerToHostType.AgentEnd)[0]!.info.outcome).toBe('failed')
host.close()
})
it('cancel before go: the body never runs at all and the result is cancelled (a second cancel is a no-op)', async () => {
const host = fakeHost({ go: false })
const session = runWorkerSession(host.port, init("log('ran')\nreturn 123"))
await vi.waitFor(() => { expect(host.messages.some(m => m.type === WorkerToHostType.Ready)).toBe(true) })
host.send({ type: HostToWorkerType.Cancel, reason: 'aborted before start' })
// Idempotence: the first reason wins; a duplicate cancel changes nothing.
host.send({ type: HostToWorkerType.Cancel, reason: 'a later reason that must lose' })
const result = await host.result()
await session
expect(result.stopReason).toBe('cancelled')
expect(result.error).toContain('aborted before start')
expect(result.error).not.toContain('must lose')
expect(result.value).toBeNull()
expect(host.ofType(WorkerToHostType.Log)).toEqual([])
host.close()
})
it('a script with no return value resolves value: null', async () => {
const host = fakeHost({ reply: () => text('ok') })
void runWorkerSession(host.port, init("await agent('p')"))
const result = await host.result()
expect(result.stopReason).toBe('completed')
expect(result.value).toBeNull()
host.close()
})
it('cancel mid-run: in-flight children get cancel RPCs, hooks throw at entry, the run reports cancelled', async () => {
const host = fakeHost()
void runWorkerSession(host.port, init(`
phase('before')
try { await agent('x') } catch (e) {}
try { phase('after') } catch (e) {}
try { log('after') } catch (e) {}
try { await parallel([() => 'ran']) } catch (e) {}
try { await pipeline(['item'], p => p) } catch (e) {}
return 'survived by catching'
`))
await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) })
const callId = host.ofType(WorkerToHostType.ChildStart)[0]!.callId
host.send({ type: HostToWorkerType.ChildStarted, callId, childId: 'child-0' })
host.send({ type: HostToWorkerType.Cancel, reason: 'stop everything' })
// The real host settles the aborted child; mirror it.
host.send({ type: HostToWorkerType.ChildSettled, callId, result: { output: [], stopReason: 'aborted' } })
const result = await host.result()
expect(result.stopReason).toBe('cancelled')
expect(result.error).toContain('stop everything')
expect(host.ofType(WorkerToHostType.ChildCancel).map(m => m.callId)).toContain(callId)
expect(host.ofType(WorkerToHostType.AgentEnd)[0]!.info.outcome).toBe('cancelled')
// No post-cancel narration left the runtime (the hooks threw at entry).
expect(host.ofType(WorkerToHostType.Phase).map(m => m.title)).toEqual(['before'])
expect(host.ofType(WorkerToHostType.Log)).toEqual([])
host.close()
})
it('cancellation between a queued waiter and its slot: the waiter rejects without a child-start', async () => {
const host = fakeHost({ go: true })
void runWorkerSession(host.port, init(
"return await parallel([() => agent('a'), () => agent('b')])",
undefined,
{ maxConcurrentAgents: 1 },
))
await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) })
host.send({ type: HostToWorkerType.Cancel, reason: 'raced' })
const result = await host.result()
expect(result.stopReason).toBe('cancelled')
// Only the first agent ever reached the host.
expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1)
host.close()
})
it('a stray (never-awaited) agent is reaped after settlement: cancel + dispose RPCs flow, no unhandled rejection', async () => {
const unhandled: unknown[] = []
const onUnhandled = (reason: unknown): void => { unhandled.push(reason) }
process.on('unhandledRejection', onUnhandled)
try {
const host = fakeHost()
void runWorkerSession(host.port, init(`
agent('stray, never awaited')
return 'done without awaiting'
`))
const result = await host.result()
expect(result.stopReason).toBe('completed')
await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) })
const callId = host.ofType(WorkerToHostType.ChildStart)[0]!.callId
host.send({ type: HostToWorkerType.ChildStarted, callId, childId: 'child-0' })
host.send({ type: HostToWorkerType.ChildSettled, callId, result: { output: [], stopReason: 'aborted' } })
await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildDispose).map(m => m.callId)).toContain(callId) })
await new Promise(resolve => setTimeout(resolve, 20))
expect(unhandled).toEqual([])
host.close()
} finally {
process.off('unhandledRejection', onUnhandled)
}
})
it('an unparseable body settles an error result instead of dying without one (host pre-parse skew guard)', async () => {
const host = fakeHost()
await runWorkerSession(host.port, init('return ((('))
const result = await host.result()
expect(result.stopReason).toBe('error')
expect(result.error).toContain('does not parse')
expect(result.agentsStarted).toBe(0)
host.close()
})
it('a synchronous spin in the initial slice dies by the in-worker vm timeout', async () => {
const host = fakeHost()
void runWorkerSession(host.port, init('while (true) {}', undefined, { syncTimeoutMs: 50 }))
const result = await host.result()
expect(result.stopReason).toBe('error')
expect(result.error?.toLowerCase()).toContain('timed out')
host.close()
})
it('a non-JSON return value fails loud as RESULT_UNSERIALIZABLE', async () => {
const host = fakeHost()
void runWorkerSession(host.port, init('return { when: new Date(0) }'))
const result = await host.result()
expect(result.stopReason).toBe('error')
expect(result.error).toContain('not plain JSON data')
host.close()
})
it('tolerates replies for unknown callIds (a teardown race): nothing crashes, the run completes', async () => {
const host = fakeHost({ reply: () => text('fine') })
void runWorkerSession(host.port, init("return await agent('p')"))
host.send({ type: HostToWorkerType.ChildStarted, callId: 999, childId: 'ghost' })
host.send({ type: HostToWorkerType.ChildStartError, callId: 999, rendered: 'ghost' })
host.send({ type: HostToWorkerType.ChildSettled, callId: 999, result: text('ghost') })
host.send({ type: HostToWorkerType.ChildFailed, callId: 999, rendered: 'ghost' })
host.send({ type: HostToWorkerType.ChildDisposed, callId: 999 })
const result = await host.result()
expect(result.stopReason).toBe('completed')
expect(result.value).toBe('fine')
host.close()
})
it('caps and malformed hook arguments reject loud (the runtime runs unchanged inside the session)', async () => {
const cases: [string, string][] = [
['return await agent(42)', 'non-empty prompt string'],
["return await agent('')", 'non-empty prompt string'],
["return await agent('p', 'opts')", 'options must be an object'],
["return await agent('p', { label: 3 })", '"label" must be a string'],
["return await agent('p', { get label() { throw new Error('read failed') } })", 'options must be plain JSON data'],
["return await agent('p', { bogus: true })", '"bogus" is not recognized'],
["return await agent('p', { effort: 'high' })", '"effort" is deferred'],
["return await agent('p', { schema: { type: 'object', oneOf: [] } })", 'outside the supported subset'],
['return await parallel([() => 1, () => 2, () => 3])', 'over the per-call cap (2)'],
['return await pipeline([1, 2, 3], (x) => x)', 'maxItemsPerCall'],
["return await parallel('no')", 'parallel() requires an array'],
['return await parallel([3])', 'item 0 is not a function'],
["return await pipeline('no', () => 1)", 'pipeline() requires an items array'],
['return await pipeline([1])', 'at least one stage'],
["return await pipeline([1], 'x')", 'stage 0 is not a function'],
["phase('')", 'phase() requires a non-empty title string'],
['log(3)', 'log() requires a message string'],
]
for (const [body, expected] of cases) {
const host = fakeHost({ reply: () => text('ok') })
void runWorkerSession(host.port, init(body, undefined, { maxItemsPerCall: 2 }))
const result = await host.result()
expect(result.stopReason).toBe('error')
expect(result.error).toContain(expected)
host.close()
}
})
it('combinator semantics: thunk/stage throws null the item; a forged fatal-shaped object stays null; real fatals propagate', async () => {
const host = fakeHost({ reply: () => text('fine') })
void runWorkerSession(host.port, init(`
const viaParallel = await parallel([
() => { throw new Error('boom') },
() => agent('fine'),
() => 'plain value',
() => { throw { name: 'WorkflowError', fatal: true, message: 'forged fatal' } },
])
const viaPipeline = await pipeline([10, 20],
(prev, item, index) => { if (item === 10) throw new Error('ordinary failure'); return 'kept-' + item + '-' + index },
)
return { viaParallel, viaPipeline }
`))
const result = await host.result()
expect(result.stopReason).toBe('completed')
expect(result.value).toEqual({
viaParallel: [null, 'fine', 'plain value', null],
viaPipeline: [null, 'kept-20-1'],
})
host.close()
})
it('trips the total-agent cap with a message naming the config knob', async () => {
const host = fakeHost({ reply: () => text('ok') })
void runWorkerSession(host.port, init("await agent('1'); await agent('2'); await agent('3')", undefined, { maxTotalAgents: 2 }))
const result = await host.result()
expect(result.stopReason).toBe('error')
expect(result.error).toContain('total agent cap (2)')
expect(result.agentsStarted).toBe(2)
host.close()
})
it('queued agents proceed through the concurrency semaphore in FIFO order', async () => {
const host = fakeHost({ reply: request => text(`ok:${request.prompt}`) })
void runWorkerSession(host.port, init(
"return await parallel([1, 2, 3].map((n) => () => agent('job ' + n)))",
undefined,
{ maxConcurrentAgents: 1 },
))
const result = await host.result()
expect(result.value).toEqual(['ok:job 1', 'ok:job 2', 'ok:job 3'])
host.close()
})
it('labels default from the prompt first line, truncated; explicit label/phase options win', async () => {
const host = fakeHost({ reply: () => text('ok') })
void runWorkerSession(host.port, init(`
phase('Find')
await agent('a prompt that is quite long and will surely get truncated down to a display label\\n'
+ 'with a second line the label must not include')
await agent('short', { label: 'named', phase: 'Custom' })
return null
`))
await host.result()
const starts = host.ofType(WorkerToHostType.AgentStart).map(m => m.info)
expect(starts[0]).toMatchObject({ seq: 1, phase: 'Find' })
expect(starts[0]!.label.length).toBeLessThanOrEqual(48)
expect(starts[0]!.label).not.toContain('second line')
expect(starts[1]).toMatchObject({ seq: 2, label: 'named', phase: 'Custom' })
host.close()
})
it('non-text output blocks are filtered out of the text result', async () => {
const host = fakeHost({
reply: () => ({
output: [
{ type: 'text', text: 'first ' },
{ type: 'tool_call', id: 'c1', name: 'x', arguments: {} } as never,
{ type: 'text', text: 'second' },
],
stopReason: 'completed',
}),
})
void runWorkerSession(host.port, init("return await agent('p')"))
const result = await host.result()
expect(result.value).toBe('first second')
host.close()
})
it('a cancel landing DURING the start round-trip winds the fresh child down (cancel + dispose) and dies cancelled', async () => {
const host = fakeHost({ manual: true })
void runWorkerSession(host.port, init("return await agent('p')"))
await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) })
const callId = host.ofType(WorkerToHostType.ChildStart)[0]!.callId
// Cancel FIRST, then the (stale) started reply: the worker processes them
// in order, so the agent() continuation resumes already-cancelled — the
// window the real host cannot produce (it refuses starts once cancelled)
// but a teardown race can.
host.send({ type: HostToWorkerType.Cancel, reason: 'raced the start' })
host.send({ type: HostToWorkerType.ChildStarted, callId, childId: 'child-0' })
const result = await host.result()
expect(result.stopReason).toBe('cancelled')
await vi.waitFor(() => {
expect(host.ofType(WorkerToHostType.ChildCancel).map(m => m.callId)).toContain(callId)
expect(host.ofType(WorkerToHostType.ChildDispose).map(m => m.callId)).toContain(callId)
})
// The child never became an agent-start: it was wound down pre-lifecycle.
expect(host.ofType(WorkerToHostType.AgentStart)).toEqual([])
host.close()
})
it('a start refusal arriving after a cancel reads as the cancellation, not a broken seam', async () => {
const host = fakeHost({ manual: true })
void runWorkerSession(host.port, init(`
try { await agent('p'); return 'unreachable' } catch (e) { return { code: e.code } }
`))
await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) })
const callId = host.ofType(WorkerToHostType.ChildStart)[0]!.callId
host.send({ type: HostToWorkerType.Cancel, reason: 'stopping' })
host.send({ type: HostToWorkerType.ChildStartError, callId, rendered: 'workflow run cancelled: stopping' })
const result = await host.result()
// The run reports cancelled (the script died of CANCELLED, not AGENT_START).
expect(result.stopReason).toBe('cancelled')
host.close()
})
it('a child result rejection while cancelled pairs a cancelled agent-end, and the run reports cancelled', async () => {
const host = fakeHost({ manual: true })
void runWorkerSession(host.port, init("return await agent('doomed')"))
await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) })
const callId = host.ofType(WorkerToHostType.ChildStart)[0]!.callId
host.send({ type: HostToWorkerType.ChildStarted, callId, childId: 'child-0' })
await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.AgentStart).length).toBe(1) })
host.send({ type: HostToWorkerType.Cancel, reason: 'user aborted' })
host.send({ type: HostToWorkerType.ChildFailed, callId, rendered: 'backend crashed on abort' })
const result = await host.result()
expect(result.stopReason).toBe('cancelled')
expect(host.ofType(WorkerToHostType.AgentEnd)[0]!.info.outcome).toBe('cancelled')
host.close()
})
})
describe('the worker bootstrap', () => {
it('requireParentPort narrows a real port and throws on the main thread', () => {
const channel = new MessageChannel()
expect(requireParentPort(channel.port1)).toBe(channel.port1)
channel.port1.close()
expect(() => requireParentPort(null)).toThrow(/inside a worker thread/)
})
it('the entry module itself throws when loaded on the main thread (no parentPort)', async () => {
// This import EXECUTES ../src/worker.ts on the main thread, which is what
// covers the bootstrap file: requireParentPort throws before
// runWorkerSession is reached.
await expect(import('../src/worker.ts')).rejects.toThrow(/inside a worker thread/)
})
})
File diff suppressed because it is too large Load Diff
@@ -9,16 +9,14 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import SubagentService from '@deepseek-ai/dsh-subagent'
import * as Spawn from '@deepseek-ai/dsh-subagent-spawn'
import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow'
import { CallId } from '@deepseek-ai/dsh-llm'
import VmWorkflowEngine from '../src/index.ts'
import WorkerWorkflowEngine from '../src/index.ts'
/**
* With-key e2e for the workflow engine: a REAL script drives REAL spawn
* children against the live DeepSeek API — one plain child and one schema'd
* child through the real structured-output runtime — and the run's value,
* events, and child sessions are asserted from the outside (never the
* script's self-report alone). Key-gated (self-skips without
* With-key e2e: a REAL script in a REAL worker thread
* drives REAL spawn children against the live DeepSeek API — one plain child
* and one schema'd child through the real structured-output runtime — and
* the run's value, events, and child sessions are asserted from the outside
* (never the script's self-report alone). Key-gated (self-skips without
* DEEPSEEK_API_KEY).
*/
@@ -40,14 +38,13 @@ async function harness(): Promise<Context> {
await built.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] })
await built.plugin(SubagentService)
await built.plugin(Spawn, { providerName: 'spawn' })
await built.plugin(VmWorkflowEngine, { provider: 'spawn' })
await built.plugin(ToolWorkflow, {})
await built.plugin(WorkerWorkflowEngine, { provider: 'spawn' })
return built
}
const SCRIPT = `export const meta = {
name: 'e2e-arithmetic',
description: 'two real children: one prose, one structured',
name: 'e2e-worker-arithmetic',
description: 'two real children through a worker thread: one prose, one structured',
phases: [{ title: 'Ask' }, { title: 'Judge' }],
}
phase('Ask')
@@ -61,12 +58,12 @@ const judged = await agent(
)
return { prose, containsFour: judged === null ? null : judged.containsFour }`
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workflow engine with-key e2e', () => {
it('runs a two-phase script over real children, one through the structured runtime', async () => {
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('worker workflow engine with-key e2e', () => {
it('runs a two-phase script in a worker thread over real children, one through the structured runtime', async () => {
ctx = await harness()
const parentHandle = ctx.agents.create({
agentId: AgentId('wf-e2e-parent'),
sessionId: 'wf-e2e-session' as never,
agentId: AgentId('wf-worker-e2e-parent'),
sessionId: 'wf-worker-e2e-session' as never,
agentOptions: { model: 'deepseek-v4-flash' },
})
@@ -102,30 +99,4 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workflow engine with-key e2e', (
}
await parentHandle.dispose()
}, 240_000)
it('the workflow TOOL runs the same path through the real registry pipeline', async () => {
ctx = await harness()
const parentHandle = ctx.agents.create({
agentId: AgentId('wf-e2e-tool-parent'),
sessionId: 'wf-e2e-tool-session' as never,
agentOptions: { model: 'deepseek-v4-flash' },
})
const result = await ctx.tools.execute({
callId: CallId('wf-e2e-call'),
name: 'workflow',
arguments: {
script: `export const meta = { name: 'e2e-tool', description: 'one real child via the tool' }
const answer = await agent('Reply with exactly one word: the capital of France.')
return { answer }`,
},
agent: parentHandle.agent,
})
expect(result.isError).toBe(false)
const text = (result.content[0] as { text: string }).text
expect(text).toContain('workflow "e2e-tool" completed (1 agent)')
expect(text.toLowerCase()).toContain('paris')
await parentHandle.dispose()
}, 240_000)
})
@@ -0,0 +1,32 @@
import { defineConfig } from 'tsdown'
/**
* The engine ships two runtime entries: the engine service (index) and the
* worker-thread entry (worker) the engine spawns via `new Worker`. The
* entries are JS emitted by tsc under lib/types and are bundled as two
* single-entry passes so shared modules (realm, runtime, session) are inlined
* into each instead of split into a hash-named chunk (the worker entry must
* be a self-contained file the Worker constructor can load by path).
*/
export default defineConfig([
{
entry: ['lib/types/index.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
},
{
entry: ['lib/types/worker.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
},
])
+3
View File
@@ -1159,6 +1159,9 @@ importers:
cordis:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
tsx:
specifier: ^4.19.2
version: 4.22.4
vendor/cordis:
dependencies:
+14 -1
View File
@@ -105,12 +105,25 @@ const dshBinPackageFiles = [
'src',
] as const
const dshWorkerPackageFiles = [
'lib/index.js',
'lib/worker.js',
'lib/types/**/*.d.ts',
'lib/types/**/*.d.ts.map',
'src',
] as const
function sameStringList(actual: readonly string[] | undefined, expected: readonly string[]): boolean {
return !!actual && actual.length === expected.length && actual.every((value, index) => value === expected[index])
}
function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] {
return manifest.bin ? dshBinPackageFiles : dshPackageFiles
if (manifest.bin) return dshBinPackageFiles
// A declared "./worker" subpath export sanctions the one extra runtime
// bundle a worker-thread entry needs (and NodeNext/publint then validate
// that subpath's targets like any other export).
if (manifest.exports?.['./worker']) return dshWorkerPackageFiles
return dshPackageFiles
}
function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] {
+4
View File
@@ -310,6 +310,10 @@ function builtBinSmokeGate(): Gate {
'vitest.e2e.config.ts',
'packages/ui/stdio-agent/tests/built-bin.e2e.ts',
'packages/ui/acp-agent/tests/built-bin.e2e.ts',
// The workflow engine's built worker bundle: the only automated proof
// that lib/index.js resolves its sibling lib/worker.js under plain node
// (the e2e lane runs unbuilt, so this file self-skips there).
'packages/workflow/workflow-vm/tests/built-worker.e2e.ts',
], {
label: 'built-bin smoke',
needs: ['build'],