diff --git a/AGENTS.md b/AGENTS.md index fc33e2f049..eb34d404a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index 68daecbe37..2b6503cd45 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -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() diff --git a/packages/workflow/workflow-vm/README.md b/packages/workflow/workflow-vm/README.md index e29073c2b3..9a0ab21fef 100644 --- a/packages/workflow/workflow-vm/README.md +++ b/packages/workflow/workflow-vm/README.md @@ -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()`. | diff --git a/packages/workflow/workflow-vm/package.json b/packages/workflow/workflow-vm/package.json index 8b1217acc0..a174893b9c 100644 --- a/packages/workflow/workflow-vm/package.json +++ b/packages/workflow/workflow-vm/package.json @@ -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" } } diff --git a/packages/workflow/workflow-vm/src/host.ts b/packages/workflow/workflow-vm/src/host.ts new file mode 100644 index 0000000000..b329e0c7d2 --- /dev/null +++ b/packages/workflow/workflow-vm/src/host.ts @@ -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 + 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() + private readonly quiescenceWaiters: (() => void)[] = [] + /** The per-run abort fanout every child start request carries. */ + private readonly controller = new AbortController() + private disposed: Promise | 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((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 { + 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(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 { + 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 { + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms) + timer.unref() + }) +} diff --git a/packages/workflow/workflow-vm/src/index.ts b/packages/workflow/workflow-vm/src/index.ts index c1300427a2..1b65381bfb 100644 --- a/packages/workflow/workflow-vm/src/index.ts +++ b/packages/workflow/workflow-vm/src/index.ts @@ -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 /** - * 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 = 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 = 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 | undefined - return { - id, - meta: structuredClone(meta), - result, - cancel(reason?: string): void { - execution.cancel(reason) - }, - dispose: (): Promise => { - // 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 { - return new Promise((resolve) => { - const timer = setTimeout(resolve, ms) - timer.unref() - }) -} - -export default VmWorkflowEngine +export default WorkerWorkflowEngine diff --git a/packages/workflow/workflow-vm/src/protocol.ts b/packages/workflow/workflow-vm/src/protocol.ts new file mode 100644 index 0000000000..293676706e --- /dev/null +++ b/packages/workflow/workflow-vm/src/protocol.ts @@ -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 + /** 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 + /** 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 = + { [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 = + { [K in T]: { type: K } & HostToWorkerPayloads[K] }[T] + diff --git a/packages/workflow/workflow-vm/src/realm.ts b/packages/workflow/workflow-vm/src/realm.ts index f5a2665626..017de76069 100644 --- a/packages/workflow/workflow-vm/src/realm.ts +++ b/packages/workflow/workflow-vm/src/realm.ts @@ -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 */ diff --git a/packages/workflow/workflow-vm/src/runtime.ts b/packages/workflow/workflow-vm/src/runtime.ts index c4e5d1c651..3005520fa0 100644 --- a/packages/workflow/workflow-vm/src/runtime.ts +++ b/packages/workflow/workflow-vm/src/runtime.ts @@ -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>() - /** 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((_, 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 = { - 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)[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 { 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 - // 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(promise: Promise): Promise { 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(promise: Promise): Promise { - 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 { - 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 } diff --git a/packages/workflow/workflow-vm/src/session.ts b/packages/workflow/workflow-vm/src/session.ts new file mode 100644 index 0000000000..131f760ff9 --- /dev/null +++ b/packages/workflow/workflow-vm/src/session.ts @@ -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 + settled: PromiseWithResolvers + disposed: PromiseWithResolvers +} + +/** 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 = (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 + + 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 { + 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() + + constructor(private readonly post: Post) {} + + async startAgent(request: ChildStartRequest): Promise { + this.nextCallId += 1 + const callId = this.nextCallId + const entry: PendingChild = { + started: Promise.withResolvers(), + settled: Promise.withResolvers(), + disposed: Promise.withResolvers(), + } + // 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 { + 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() + 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 }) +} diff --git a/packages/workflow/workflow-vm/src/types.ts b/packages/workflow/workflow-vm/src/types.ts new file mode 100644 index 0000000000..c87fc4668c --- /dev/null +++ b/packages/workflow/workflow-vm/src/types.ts @@ -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 + /** 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 +} + +/** + * 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 +} diff --git a/packages/workflow/workflow-vm/src/worker.ts b/packages/workflow/workflow-vm/src/worker.ts new file mode 100644 index 0000000000..3b20600d7d --- /dev/null +++ b/packages/workflow/workflow-vm/src/worker.ts @@ -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) diff --git a/packages/workflow/workflow-vm/tests/built-worker.e2e.ts b/packages/workflow/workflow-vm/tests/built-worker.e2e.ts new file mode 100644 index 0000000000..9bdd3865bb --- /dev/null +++ b/packages/workflow/workflow-vm/tests/built-worker.e2e.ts @@ -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) +}) diff --git a/packages/workflow/workflow-vm/tests/integration.spec.ts b/packages/workflow/workflow-vm/tests/integration.spec.ts index 3572131a8a..e53ea518bc 100644 --- a/packages/workflow/workflow-vm/tests/integration.spec.ts +++ b/packages/workflow/workflow-vm/tests/integration.spec.ts @@ -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[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 } diff --git a/packages/workflow/workflow-vm/tests/session.spec.ts b/packages/workflow/workflow-vm/tests/session.spec.ts new file mode 100644 index 0000000000..00051ad56a --- /dev/null +++ b/packages/workflow/workflow-vm/tests/session.spec.ts @@ -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'] { + 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 { + 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(type: T): Extract[] + send(message: HostToWorkerMessage): void + /** Resolves with the terminal result message. */ + result(): Promise['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['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/) + }) +}) diff --git a/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts b/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts index bb685f536f..7264877f48 100644 --- a/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts +++ b/packages/workflow/workflow-vm/tests/workflow-vm.spec.ts @@ -6,14 +6,17 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentCapabilities, SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import type { WorkflowResult, WorkflowResultInfo, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow' -import * as vmEngineModule from '../src/index.ts' -import VmWorkflowEngine, { type Config } from '../src/index.ts' +import * as workerEngineModule from '../src/index.ts' +import WorkerWorkflowEngine, { type Config } from '../src/index.ts' /** A minimal parent stand-in: the engine only threads it through to the provider. */ function fakeParent(): Agent { return { id: AgentId('workflow-parent'), options: {} } as unknown as Agent } +/** The vm-context escape hatch, spelled once: real Worker tests use it to make the WORKER misbehave. */ +const ESCAPE = "globalThis.constructor.constructor('return process')()" + /** One controllable child run: the test (or auto mode) settles it. */ interface ControlledRun { request: SubagentStartRequest @@ -25,13 +28,11 @@ interface ControlledRun { /** * A scripted in-test provider over the REAL SubagentService registry: `auto` * settles each run via the reply function on a microtask; `manual` piles runs - * up in `runs` for the test to settle (concurrency/cancellation tests). A run - * aborts (settles `aborted`) when the request signal fires, like the real - * in-process backends. + * up in `runs` for the test to settle. A run aborts (settles `aborted`) when + * the request signal fires, like the real in-process backends. */ class StubProvider implements SubagentProvider { readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true } - // Context contract: stub children start fresh, mirroring the spawn backend. readonly inheritsParentContext = false readonly runs: ControlledRun[] = [] @@ -64,7 +65,6 @@ class StubProvider implements SubagentProvider { controlled.disposed = true return Promise.resolve() } - // A slow-winding child (quiescence tests): disposal completes late. return new Promise((resolve) => { setTimeout(() => { controlled.disposed = true @@ -99,8 +99,8 @@ async function setup(options?: SetupOptions) { ctx.subagents.registerProvider(provider) // A fixed concurrency ceiling: the auto-resolved default is machine-derived // (cores - 2, floored at 1), so tests that expect N children in flight - // would wedge on small CI runners. Tests about the ceiling override it. - await ctx.plugin(VmWorkflowEngine, { provider: 'stub', maxConcurrentAgents: 8, ...options?.config }) + // would wedge on small CI runners. + await ctx.plugin(WorkerWorkflowEngine, { provider: 'stub', maxConcurrentAgents: 8, ...options?.config }) return { ctx, provider, parent: fakeParent() } } @@ -120,8 +120,8 @@ async function run(ctx: Context, parent: Agent, source: string, args?: unknown): } describe('dsh-workflow-vm', () => { - describe('script execution', () => { - it('runs a script end-to-end: agent() text results, phases, log, args, return value', async () => { + describe('script execution over a real worker thread', () => { + it('runs a script end-to-end: agent() text results, phases, log, args, return value, events', async () => { const { ctx, parent, provider } = await setup({ reply: (_request, index) => text(`answer-${index}`) }) const events: [string, unknown[]][] = [] for (const name of ['workflow/start', 'workflow/phase', 'workflow/log', 'workflow/agent-start', 'workflow/agent-end', 'workflow/end'] as const) { @@ -152,32 +152,12 @@ describe('dsh-workflow-vm', () => { expect('value' in end).toBe(false) }) - it('agent-start/end events carry seq, label (defaulted from the prompt), phase, and outcome', async () => { - const { ctx, parent } = await setup() - const starts: unknown[] = [] - const ends: unknown[] = [] - ctx.on('workflow/agent-start', (_info, agent) => starts.push(agent)) - ctx.on('workflow/agent-end', (_info, agent) => ends.push(agent)) - await run(ctx, parent, script(` - 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 - `)) - expect(starts[0]).toMatchObject({ seq: 1, phase: 'Find', childId: 'stub-child-0' }) - expect((starts[0] as { label: string }).label.length).toBeLessThanOrEqual(48) - expect((starts[0] as { label: string }).label).not.toContain('second line') - expect(starts[1]).toMatchObject({ seq: 2, label: 'named', phase: 'Custom' }) - expect(ends[0]).toMatchObject({ seq: 1, outcome: 'completed' }) - }) - - it('agent({schema}) forwards outputSchema to the provider and returns the structured value into the realm', async () => { + it('agent({schema, model}) forwards outputSchema and agentOptions to the provider across the thread', async () => { const { ctx, parent, provider } = await setup({ reply: () => ({ output: [], structured: { files: ['x.ts', 'y.ts'] }, stopReason: 'completed' }), }) const result = await run(ctx, parent, script(` - const found = await agent('list files', { schema: { type: 'object', properties: { files: { type: 'array', items: { type: 'string' } } }, required: ['files'] } }) + const found = await agent('list files', { model: 'deepseek-v4-pro', schema: { type: 'object', properties: { files: { type: 'array', items: { type: 'string' } } }, required: ['files'] } }) return { first: found.files[0], count: found.files.length } `)) expect(result.value).toEqual({ first: 'x.ts', count: 2 }) @@ -186,271 +166,25 @@ describe('dsh-workflow-vm', () => { properties: { files: { type: 'array', items: { type: 'string' } } }, required: ['files'], }) - }) - - it('model option maps to agentOptions.model on the start request', async () => { - const { ctx, parent, provider } = await setup() - await run(ctx, parent, script("return await agent('p', { model: 'deepseek-v4-pro' })")) expect(provider.runs[0]!.request.agentOptions).toEqual({ model: 'deepseek-v4-pro' }) + expect(provider.runs[0]!.request.parent).toBeDefined() }) - it('a failed child resolves null (scripts filter), never throwing into the script', async () => { - const { ctx, parent } = await setup({ - reply: (_request, index) => index === 0 ? { output: [], stopReason: 'error' } : text('ok'), - }) - const result = await run(ctx, parent, script(` - const results = await parallel([() => agent('one'), () => agent('two')]) - return results - `)) - expect(result.value).toEqual([null, 'ok']) - }) - - it('a schema run that completes WITHOUT a structured value is a child failure (null + failed outcome)', async () => { - const { ctx, parent } = await setup({ reply: () => text('prose, no structure') }) - const ends: unknown[] = [] - ctx.on('workflow/agent-end', (_info, agent) => ends.push(agent)) - const result = await run(ctx, parent, script(` - return await agent('p', { schema: { type: 'object' } }) - `)) - expect(result.value).toBeNull() - expect(ends[0]).toMatchObject({ outcome: 'failed' }) - }) - - it('a script with no return value resolves value: null', async () => { + it('a fatal hook error inside the worker kills the script and reports the error', async () => { const { ctx, parent } = await setup() - const result = await run(ctx, parent, script("await agent('p')")) - expect(result.stopReason).toBe('completed') - expect(result.value).toBeNull() - }) - - it('a returned promise/thenable resolves per async-JS semantics before materialization', async () => { - const { ctx, parent } = await setup() - // Load-bearing ergonomics: forgetting await on the final hook call works. - expect((await run(ctx, parent, script("return agent('x')"))).value).toBe('stub reply') - // A hand-built thenable is assimilated by the async return — the - // RESOLUTION is the script's return value (standard JavaScript), and the - // realm-boundary guard applies to that resolution, not the thenable. - expect((await run(ctx, parent, script('return { value: 1, then(resolve) { resolve({ ok: true }) } }'))).value).toEqual({ ok: true }) - const nonJson = await run(ctx, parent, script('return { then(resolve) { resolve({ bad: new Date(0) }) } }')) - expect(nonJson.stopReason).toBe('error') - expect(nonJson.error).toContain('not plain JSON data') - }) - }) - - describe('combinator semantics', () => { - it('pipeline has NO cross-stage barrier: a fast item finishes stage 2 while a slow item holds stage 1', async () => { - const { ctx, parent, provider } = await setup({ manual: true }) - const handle = ctx.workflows.start({ - script: script(` - const out = await pipeline(['slow', 'fast'], - (prev, item) => agent('s1 ' + item), - (prev, item) => agent('s2 ' + item + ' after ' + prev), - ) - return out - `), - parent: fakeParent(), - }) - // Both items enter stage 1 concurrently. - await vi.waitFor(() => { expect(provider.runs.length).toBe(2) }) - // Settle only the FAST item's stage 1 → its stage 2 starts with no barrier. - provider.runs[1]!.settle(text('fast-1')) - await vi.waitFor(() => { expect(provider.runs.length).toBe(3) }) - expect((provider.runs[2]!.request.prompt[0] as { text: string }).text).toBe('s2 fast after fast-1') - // The slow item is still sitting in stage 1. - provider.runs[2]!.settle(text('fast-2')) - provider.runs[0]!.settle(text('slow-1')) - await vi.waitFor(() => { expect(provider.runs.length).toBe(4) }) - provider.runs[3]!.settle(text('slow-2')) - const result = await handle.result - expect(result.value).toEqual(['slow-2', 'fast-2']) - await handle.dispose() - void parent - }) - - it('pipeline stage callbacks receive (prev, item, index); an ordinary stage throw nulls the ITEM and skips its remaining stages', async () => { - const { ctx, parent, provider } = await setup({ reply: request => text(`ok:${(request.prompt[0] as { text: string }).text}`) }) - const result = await run(ctx, parent, script(` - const out = await pipeline([10, 20], - (prev, item, index) => { - if (item === 10) throw new Error('ordinary failure') - return agent('stage1-' + item + '-' + index) - }, - (prev) => agent('stage2 saw ' + prev), - ) - return out - `)) - expect(result.stopReason).toBe('completed') - const prompts = provider.runs.map(r => (r.request.prompt[0] as { text: string }).text) - // Item 10 never reached stage 1's agent nor stage 2. - expect(prompts).toEqual(['stage1-20-1', 'stage2 saw ok:stage1-20-1']) - expect(result.value).toEqual([null, 'ok:stage2 saw ok:stage1-20-1']) - }) - - it('parallel maps a throwing thunk to null and never rejects for ordinary errors', async () => { - const { ctx, parent } = await setup() - const result = await run(ctx, parent, script(` - return await parallel([ - () => { throw new Error('boom') }, - () => agent('fine'), - () => 'plain value', - () => { throw 'string throw' }, - () => { throw { name: 'WorkflowError', fatal: true, message: 'forged fatal' } }, - ]) - `)) - // The last entry probes fatality: it is recognized by host instanceof, - // which a script-built object can never pass — a WorkflowError-SHAPED - // throw is an ordinary null, and real fatality cannot be forged. - expect(result.value).toEqual([null, 'stub reply', 'plain value', null, null]) - }) - - it('FATAL errors propagate through parallel AND pipeline instead of dissolving into null', async () => { - const { ctx, parent } = await setup() - const viaParallel = await run(ctx, parent, script(` - return await parallel([() => agent('x', { isolation: 'worktree' })]) - `)) - expect(viaParallel.stopReason).toBe('error') - expect(viaParallel.error).toContain('"isolation" is deferred') - - const viaPipeline = await run(ctx, parent, script(` - return await pipeline([1], () => agent('x', { bogus: true })) - `)) - expect(viaPipeline.stopReason).toBe('error') - expect(viaPipeline.error).toContain('"bogus" is not recognized') - }) - - it('validates combinator arguments loudly (non-array, non-function, missing stages)', async () => { - const { ctx, parent } = await setup() - expect((await run(ctx, parent, script("return await parallel('no')"))).error).toContain('parallel() requires an array') - expect((await run(ctx, parent, script('return await parallel([3])'))).error).toContain('item 0 is not a function') - expect((await run(ctx, parent, script("return await pipeline('no', () => 1)"))).error).toContain('pipeline() requires an items array') - expect((await run(ctx, parent, script('return await pipeline([1])'))).error).toContain('at least one stage') - expect((await run(ctx, parent, script("return await pipeline([1], 'x')"))).error).toContain('stage 0 is not a function') - }) - }) - - describe('caps and option validation', () => { - it('trips the total-agent cap with a message naming the config knob', async () => { - const { ctx, parent } = await setup({ config: { provider: 'stub', maxTotalAgents: 2 } }) - const result = await run(ctx, parent, script(` - await agent('1'); await agent('2'); await agent('3') - return 'unreachable' - `)) + const result = await run(ctx, parent, script("return await parallel([() => agent('x', { isolation: 'worktree' })])")) expect(result.stopReason).toBe('error') - expect(result.error).toContain('total agent cap (2)') - expect(result.error).toContain('maxTotalAgents') - expect(result.agentsStarted).toBe(2) + expect(result.error).toContain('"isolation" is deferred') }) - it('trips the per-call item cap for parallel and pipeline', async () => { - const { ctx, parent } = await setup({ config: { provider: 'stub', maxItemsPerCall: 2 } }) - expect((await run(ctx, parent, script('return await parallel([() => 1, () => 2, () => 3])'))).error) - .toContain('over the per-call cap (2)') - expect((await run(ctx, parent, script('return await pipeline([1, 2, 3], (x) => x)'))).error) - .toContain('maxItemsPerCall') - }) - - it('enforces the concurrency ceiling: never more than maxConcurrentAgents children in flight', async () => { - const { ctx, parent, provider } = await setup({ manual: true, config: { provider: 'stub', maxConcurrentAgents: 2 } }) - const handle = ctx.workflows.start({ - script: script("return await parallel([1, 2, 3, 4, 5].map((n) => () => agent('job ' + n)))"), - parent, - }) - // Only 2 children may exist until one settles. - await vi.waitFor(() => { expect(provider.runs.length).toBe(2) }) - await new Promise(resolve => setTimeout(resolve, 20)) - expect(provider.runs.length).toBe(2) - // Settle children in arrival order; after each settle at most ONE more - // child may enter — the window never exceeds the ceiling. - for (let index = 0; index < 5; index++) { - await vi.waitFor(() => { expect(provider.runs.length).toBeGreaterThan(index) }) - expect(provider.runs.length).toBeLessThanOrEqual(Math.min(index + 2, 5)) - provider.runs[index]!.settle(text(`r${index}`)) - } - const result = await handle.result - expect(result.stopReason).toBe('completed') - expect(result.agentsStarted).toBe(5) - expect(result.value).toEqual(['r0', 'r1', 'r2', 'r3', 'r4']) - await handle.dispose() - }) - - it('rejects malformed agent() arguments and option types loudly', async () => { - const { ctx, parent } = await setup() - expect((await run(ctx, parent, script('return await agent(42)'))).error).toContain('non-empty prompt string') - expect((await run(ctx, parent, script("return await agent('')"))).error).toContain('non-empty prompt string') - expect((await run(ctx, parent, script("return await agent('p', 'opts')"))).error).toContain('options must be an object') - expect((await run(ctx, parent, script("return await agent('p', { label: 3 })"))).error).toContain('"label" must be a string') - expect((await run(ctx, parent, script("return await agent('p', { effort: 'high' })"))).error).toContain('"effort" is deferred') - }) - - it('rejects options whose property reads throw (materialization is loud, not silent)', async () => { - const { ctx, parent } = await setup() - const result = await run(ctx, parent, script("return await agent('p', { get label() { throw new Error('read failed') } })")) - expect(result.stopReason).toBe('error') - expect(result.error).toContain('options must be plain JSON data') - expect(result.error).toContain('read failed') - }) - - it('validates phase() and log() arguments loudly', async () => { - const { ctx, parent } = await setup() - expect((await run(ctx, parent, script('phase(3)'))).error).toContain('phase() requires a non-empty title string') - expect((await run(ctx, parent, script("phase('')"))).error).toContain('phase() requires a non-empty title string') - expect((await run(ctx, parent, script('log(3)'))).error).toContain('log() requires a message string') - }) - - it('rejects an unsupported schema via the shared subset assertion (UNSUPPORTED_SCHEMA)', async () => { - const { ctx, parent } = await setup() - const result = await run(ctx, parent, script("return await agent('p', { schema: { type: 'object', oneOf: [] } })")) - expect(result.stopReason).toBe('error') - expect(result.error).toContain('outside the supported subset') - expect(result.error).toContain('oneOf') - }) - - it('wraps a provider start failure as a fatal AGENT_START error (a missing provider cannot dissolve into null)', async () => { + it('a provider start failure crosses back as a fatal AGENT_START error', async () => { const { ctx, parent } = await setup({ config: { provider: 'nonexistent' } }) const result = await run(ctx, parent, script("return await pipeline([1], () => agent('p'))")) expect(result.stopReason).toBe('error') - expect(result.error).toContain('could not start a child on provider "nonexistent"') - }) - }) - - describe('the value boundary', () => { - it('args are cloned at start: a script scribbling on them cannot mutate the caller\'s object', async () => { - const { ctx, parent } = await setup() - const hostArgs = { files: ['a.ts'], nested: { deep: [1, 2] } } - const result = await run(ctx, parent, script(` - args.files.push('b.ts') - return { count: args.files.length, deep: args.nested.deep[1] } - `), hostArgs) - expect(result.value).toEqual({ count: 2, deep: 2 }) - // The caller's object is untouched (the engine cloned args host-side). - expect(hostArgs.files).toEqual(['a.ts']) + expect(result.error).toContain('agent() could not start a child') }) - it('scalar/null args pass through directly; absent args leave the global undefined', async () => { - const { ctx, parent } = await setup() - expect((await run(ctx, parent, script('return args * 2'), 21)).value).toBe(42) - expect((await run(ctx, parent, script('return args === null'), null)).value).toBe(true) - expect((await run(ctx, parent, script('return typeof args'))).value).toBe('undefined') - }) - - it('hook failures reach the script as HOST WorkflowErrors: fields readable, in-realm instanceof Error is false', async () => { - const { ctx, parent } = await setup() - const result = await run(ctx, parent, script(` - try { - await agent('p', { bogus: true }) - return 'unreachable' - } catch (e) { - // The documented consequence of the trust premise: hook errors are - // host objects, so realm instanceof is false — read the fields. - return { isRealmError: e instanceof Error, name: e.name, code: e.code, fatal: e.fatal, message: e.message } - } - `)) - expect(result.stopReason).toBe('completed') - expect(result.value).toMatchObject({ isRealmError: false, name: 'WorkflowError', code: 'UNSUPPORTED_OPTION', fatal: true }) - expect((result.value as { message: string }).message).toContain('"bogus" is not recognized') - }) - - it('a rejecting provider result is an infrastructure fault: fatal AGENT_RESULT, agent-end paired, no combinator dissolve', async () => { + it('a child result REJECTION crosses back as a fatal AGENT_RESULT error (a broken provider is not a failed child)', async () => { const ctx = new Context() await ctx.plugin(SubagentService) const provider: SubagentProvider = { @@ -465,184 +199,90 @@ describe('dsh-workflow-vm', () => { }), } ctx.subagents.registerProvider(provider) - await ctx.plugin(VmWorkflowEngine, { provider: 'rejecting' }) - const ends: unknown[] = [] - ctx.on('workflow/agent-end', (_info, agent) => { ends.push(agent) }) - // Direct await: the script reads the typed fields (a host object, so - // realm instanceof is false — same as every hook failure). - const direct = await run(ctx, fakeParent(), script(` + await ctx.plugin(WorkerWorkflowEngine, { provider: 'rejecting', maxConcurrentAgents: 2 }) + const result = await run(ctx, fakeParent(), script(` try { await agent('p'); return 'unreachable' } catch (e) { return { name: e.name, code: e.code, fatal: e.fatal, message: e.message } } `)) - expect(direct.value).toMatchObject({ name: 'WorkflowError', code: 'AGENT_RESULT', fatal: true }) - expect((direct.value as { message: string }).message).toContain('backend exploded') - // The child's lifecycle stays paired even though result never resolved. - expect(ends).toEqual([expect.objectContaining({ seq: 1, outcome: 'failed' })]) - // Through a combinator the fault PROPAGATES (fatal) — a broken provider - // must not dissolve into the per-item null and read as a failed child. - const throughParallel = await run(ctx, fakeParent(), script("return await parallel([() => agent('p')])")) - expect(throughParallel.stopReason).toBe('error') - expect(throughParallel.error).toContain('backend exploded') + expect(result.value).toMatchObject({ name: 'WorkflowError', code: 'AGENT_RESULT', fatal: true }) + expect((result.value as { message: string }).message).toContain('backend exploded') }) - it('phase()/log() throw host WorkflowErrors synchronously on misuse', async () => { - const { ctx, parent } = await setup() - const result = await run(ctx, parent, script(` - try { phase(3) } catch (e) { - if (e.name !== 'WorkflowError') throw e - } - try { log(3) } catch (e) { - return { name: e.name, message: e.message } - } - `)) - expect(result.value).toMatchObject({ name: 'WorkflowError' }) - expect((result.value as { message: string }).message).toContain('log() requires') + it('a child whose dispose() rejects cannot wedge the script (the host acks anyway)', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const provider: SubagentProvider = { + name: 'bad-dispose', + capabilities: { outputSchema: true, depthLimit: true, toolFilter: true }, + inheritsParentContext: false, + start: () => ({ + id: AgentId('bad-dispose-child'), + result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }), + cancel: () => { /* settled already */ }, + dispose: () => Promise.reject(new Error('dispose exploded')), + }), + } + ctx.subagents.registerProvider(provider) + await ctx.plugin(WorkerWorkflowEngine, { provider: 'bad-dispose', maxConcurrentAgents: 2 }) + const result = await run(ctx, fakeParent(), script("return await agent('p')")) + expect(result.stopReason).toBe('completed') + expect(result.value).toBe('fine') }) - it('a returned value whose property reads throw fails loud as RESULT_UNSERIALIZABLE', async () => { - const { ctx, parent } = await setup() - const result = await run(ctx, parent, script(` - return { get a() { throw new Error('read failed') } } - `)) - expect(result.stopReason).toBe('error') - expect(result.error).toContain('not plain JSON data') - expect(result.error).toContain('read failed') - }) - - it('a non-JSON return value fails loud as RESULT_UNSERIALIZABLE', async () => { - const { ctx, parent } = await setup() - const withDate = await run(ctx, parent, script('return { when: new Date(0) }')) - expect(withDate.stopReason).toBe('error') - expect(withDate.error).toContain('not plain JSON data') - const withFn = await run(ctx, parent, script('return { fn: () => 1 }')) - expect(withFn.error).toContain('not plain JSON data') - }) - - it('kills a synchronous spin in the initial slice via the vm timeout', async () => { - const { ctx, parent } = await setup({ config: { provider: 'stub', syncTimeoutMs: 50 } }) - const result = await run(ctx, parent, script('while (true) {}')) - expect(result.stopReason).toBe('error') - expect(result.error?.toLowerCase()).toContain('timed out') + it('a child dispose() rejecting an UNRENDERABLE value still acks — the containment warn is total', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const provider: SubagentProvider = { + name: 'coercion-trap-dispose', + capabilities: { outputSchema: true, depthLimit: true, toolFilter: true }, + inheritsParentContext: false, + start: () => ({ + id: AgentId('trap-child'), + result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }), + cancel: () => { /* settled already */ }, + // The rejection VALUE's own coercion throws: a warn built with bare + // String(error) would itself throw, skipping the ChildDisposed ack + // and wedging the script's finally until the grace/terminate path. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- the non-Error rejection IS the scenario under test + dispose: () => Promise.reject({ toString: () => { throw new Error('coercion trap') } }), + }), + } + ctx.subagents.registerProvider(provider) + await ctx.plugin(WorkerWorkflowEngine, { provider: 'coercion-trap-dispose', maxConcurrentAgents: 2 }) + const result = await run(ctx, fakeParent(), script("return await agent('p')")) + expect(result.stopReason).toBe('completed') + expect(result.value).toBe('fine') }) }) - describe('lifecycle: parse errors, cancellation, disposal', () => { - it('start() throws synchronously for an unparseable script or invalid meta', async () => { + describe('lifecycle: parse errors, cancellation, termination, disposal', () => { + it('start() throws synchronously for an unparseable script or invalid meta (host-side pre-parse)', async () => { const { ctx, parent } = await setup() expect(() => ctx.workflows.start({ script: 'const x = 1', parent })).toThrow(/must begin with/) expect(() => ctx.workflows.start({ script: script('return ((('), parent })).toThrow(/does not parse/) }) - it('cancel() aborts in-flight children and settles the run cancelled', async () => { + it('cancel() aborts in-flight children (signal AND cancel RPC) and settles the run cancelled', async () => { const { ctx, parent, provider } = await setup({ manual: true }) - const ends: WorkflowResultInfo[] = [] - ctx.on('workflow/end', (_info, result) => { ends.push(result) }) + const ends: unknown[] = [] + ctx.on('workflow/agent-end', (_info, agent) => { ends.push(agent) }) + const runEnds: WorkflowResultInfo[] = [] + ctx.on('workflow/end', (_info, result) => { runEnds.push(result) }) const handle = ctx.workflows.start({ script: script("return await agent('long job')"), parent }) await vi.waitFor(() => { expect(provider.runs.length).toBe(1) }) handle.cancel('user stopped it') const result = await handle.result expect(result.stopReason).toBe('cancelled') expect(result.error).toContain('user stopped it') + await handle.dispose() expect(provider.runs[0]!.disposed).toBe(true) + expect(ends).toEqual([expect.objectContaining({ seq: 1, outcome: 'cancelled' })]) // workflow/end is an observer's only death signal: it fires for a // cancelled run too, mirroring the settled outcome data. - expect(ends).toEqual([{ stopReason: 'cancelled', error: result.error, agentsStarted: 1 }]) - await handle.dispose() + expect(runEnds).toEqual([{ stopReason: 'cancelled', error: result.error, agentsStarted: result.agentsStarted }]) }) - it('cancellation bridges to run.cancel() on every in-flight child, not just the request signal', async () => { - const { ctx, parent, provider } = await setup({ manual: true }) - const handle = ctx.workflows.start({ - script: script("return await parallel([() => agent('a'), () => agent('b')])"), - parent, - }) - await vi.waitFor(() => { expect(provider.runs.length).toBe(2) }) - handle.cancel('bridged') - expect((await handle.result).stopReason).toBe('cancelled') - // The seam leaves a provider free to honor run.cancel() rather than the - // request signal, so the engine must drive BOTH channels per child. - expect(provider.runs.map(r => r.cancelled)).toEqual(['bridged', 'bridged']) - await handle.dispose() - }) - - it('a provider whose result REJECTS on abort still gets a paired cancelled agent-end, and the run reports cancelled', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - // The seam allows result to reject for infrastructure faults; a backend - // that tears down uncleanly on abort exercises the rejection path WHILE - // the run is cancelled — which must stay a cancellation, not AGENT_RESULT. - const provider: SubagentProvider = { - name: 'reject-on-abort', - capabilities: { outputSchema: true, depthLimit: true, toolFilter: true }, - inheritsParentContext: false, - start: request => ({ - id: AgentId('crashing-child'), - result: new Promise((_, reject) => { - request.signal?.addEventListener('abort', () => { reject(new Error('backend crashed on abort')) }, { once: true }) - }), - cancel: () => { /* the signal listener above is the teardown */ }, - dispose: () => Promise.resolve(), - }), - } - ctx.subagents.registerProvider(provider) - await ctx.plugin(VmWorkflowEngine, { provider: 'reject-on-abort' }) - const starts: unknown[] = [] - const ends: unknown[] = [] - ctx.on('workflow/agent-start', (_info, agent) => { starts.push(agent) }) - ctx.on('workflow/agent-end', (_info, agent) => { ends.push(agent) }) - const handle = ctx.workflows.start({ script: script("return await agent('doomed')"), parent: fakeParent() }) - await vi.waitFor(() => { expect(starts.length).toBe(1) }) - handle.cancel('user aborted') - const result = await handle.result - expect(result.stopReason).toBe('cancelled') - expect(result.error).toContain('user aborted') - expect(ends).toEqual([expect.objectContaining({ seq: 1, outcome: 'cancelled' })]) - await handle.dispose() - }) - - it('after cancellation EVERY hook throws at entry — phase/log/parallel/pipeline, not just agent()', async () => { - const { ctx, parent, provider } = await setup({ manual: true }) - let cancelled = false - const postCancel: string[] = [] - ctx.on('workflow/phase', (_info, title) => { if (cancelled) postCancel.push(`phase:${title}`) }) - ctx.on('workflow/log', (_info, message) => { if (cancelled) postCancel.push(`log:${message}`) }) - const handle = ctx.workflows.start({ - // The script survives each throw by catching, so every guarded hook is - // actually ATTEMPTED after the cancel; the run still reports cancelled. - script: script(` - 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' - `), - parent, - }) - await vi.waitFor(() => { expect(provider.runs.length).toBe(1) }) - cancelled = true - handle.cancel('stop everything') - const result = await handle.result - expect(result.stopReason).toBe('cancelled') - // No post-cancel progress ever reached observers, and no child started. - expect(postCancel).toEqual([]) - expect(provider.runs.length).toBe(1) - await handle.dispose() - }) - - it('an already-aborted request signal cancels before any child starts', async () => { - const { ctx, parent, provider } = await setup({ manual: true }) - const controller = new AbortController() - controller.abort() - const handle = ctx.workflows.start({ script: script("return await agent('never')"), parent, signal: controller.signal }) - const result = await handle.result - expect(result.stopReason).toBe('cancelled') - expect(provider.runs.length).toBe(0) - await handle.dispose() - }) - - it('an already-aborted signal cancels a HOOK-FREE script: the body never runs at all', async () => { - const { ctx, parent } = await setup() + it('an already-aborted request signal cancels before the body ever runs (the go handshake holds it)', async () => { + const { ctx, parent, provider } = await setup() const controller = new AbortController() controller.abort() const logs: string[] = [] @@ -652,151 +292,80 @@ describe('dsh-workflow-vm', () => { expect(result.stopReason).toBe('cancelled') expect(result.value).toBeNull() expect(logs).toEqual([]) + expect(provider.runs.length).toBe(0) await handle.dispose() }) - it('cancel() right after start() reports cancelled even when the script needed no hooks', async () => { - const { ctx, parent } = await setup() - const handle = ctx.workflows.start({ script: script('return 123'), parent }) - handle.cancel('changed my mind') - const result = await handle.result - expect(result.stopReason).toBe('cancelled') - expect(result.value).toBeNull() - expect(result.error).toContain('changed my mind') - await handle.dispose() - }) - - it('an agent() call AFTER a mid-run cancel rejects at entry — no child ever starts', async () => { + it('cancel() right after start() cancels before the body runs; the signal aborting mid-run cancels like cancel()', async () => { const { ctx, parent, provider } = await setup({ manual: true }) - const handle = ctx.workflows.start({ - script: script(` - await agent('first') - return await agent('second') - `), - parent, - }) - await vi.waitFor(() => { expect(provider.runs.length).toBe(1) }) - // Same synchronous block: the first child settles completed, then the - // cancel lands BEFORE the script's continuation can call agent() again. - provider.runs[0]!.settle(text('first done')) - handle.cancel('mid-run') - const result = await handle.result - expect(result.stopReason).toBe('cancelled') - expect(provider.runs.length).toBe(1) - await handle.dispose() - }) + const first = ctx.workflows.start({ script: script("return await agent('never')"), parent }) + // No-reason cancel: the canonical default reason must ride the result. + first.cancel() + const firstResult = await first.result + expect(firstResult.stopReason).toBe('cancelled') + expect(firstResult.error).toContain('workflow cancelled') + expect(provider.runs.length).toBe(0) + await first.dispose() - it('the signal aborting mid-run cancels like cancel()', async () => { - const { ctx, parent, provider } = await setup({ manual: true }) const controller = new AbortController() - const handle = ctx.workflows.start({ script: script("return await agent('job')"), parent, signal: controller.signal }) + const second = ctx.workflows.start({ script: script("return await agent('job')"), parent, signal: controller.signal }) await vi.waitFor(() => { expect(provider.runs.length).toBe(1) }) controller.abort() - const result = await handle.result - expect(result.stopReason).toBe('cancelled') - await handle.dispose() + expect((await second.result).stopReason).toBe('cancelled') + await second.dispose() }) - it('reports a non-Error script throw (a thrown string) faithfully', async () => { - const { ctx, parent } = await setup() - const result = await run(ctx, parent, script("throw 'plain string failure'")) - expect(result.stopReason).toBe('error') - expect(result.error).toContain('plain string failure') - }) - - it('a script Error surfaces its stack, carrying the script line numbers (lineOffset)', async () => { - const { ctx, parent } = await setup() - const result = await run(ctx, parent, script("throw new Error('with stack')")) - expect(result.stopReason).toBe('error') - // Line 1 is the blanked meta statement; the throw sits on line 2. - expect(result.error).toContain('workflow:test-flow:2') - }) - - it('an object throw with neither stack nor message stringifies', async () => { - const { ctx, parent } = await setup() - const result = await run(ctx, parent, script('throw { code: 42 }')) - expect(result.stopReason).toBe('error') - expect(result.error).toBe('[object Object]') - }) - - it('falls back to the message for an Error whose stack was stripped', async () => { - const { ctx, parent } = await setup() - const result = await run(ctx, parent, script(` - const e = new Error('stackless failure') - e.stack = undefined - throw e - `)) - expect(result.stopReason).toBe('error') - expect(result.error).toBe('stackless failure') - }) - - it('cancel() in the same frame as start(): the awaited slot tick cannot start a child', async () => { + it('a child-start racing the host cancel is refused: no child starts after cancellation', async () => { const { ctx, parent, provider } = await setup({ manual: true }) - // agent() enters during start()'s synchronous slice and suspends on the - // acquireSlot await (one microtask tick even with a free slot); the - // synchronous cancel below lands in that tick. Without the post-acquire - // re-check the continuation would start a child carrying an ALREADY- - // aborted signal — which the stub provider (subscribing only to future - // abort events, like a real backend) would never settle, leaking it. - const handle = ctx.workflows.start({ script: script("return await agent('never')"), parent }) - handle.cancel('immediately after start') + // Cancel from INSIDE the log listener: the worker has already posted + // its child-start (queued right behind the log message), so the host + // processes it with cancelReason set — the refusal arm no real-world + // timing can hit reliably. (The closure runs only after `handle` below + // is initialized — the listener fires on the worker's first message.) + ctx.on('workflow/log', () => { handle.cancel('cancelled from the log listener') }) + const handle = ctx.workflows.start({ script: script("log('mark')\nreturn await agent('late')"), parent }) const result = await handle.result expect(result.stopReason).toBe('cancelled') expect(provider.runs.length).toBe(0) await handle.dispose() }) - it('a waiter resumed by a release RACING a cancel still dies at the post-acquire check', async () => { - const { ctx, parent, provider } = await setup({ manual: true, config: { provider: 'stub', maxConcurrentAgents: 1 } }) + it('post-cancel narration is suppressed host-side, and completion racing a cancel reports cancelled', async () => { + const { ctx, parent } = await setup() + const narration: string[] = [] + ctx.on('workflow/log', (_info, message) => { narration.push(message) }) + ctx.on('workflow/phase', (_info, title) => { narration.push(`phase:${title}`) }) const handle = ctx.workflows.start({ - script: script("return await parallel([() => agent('a'), () => agent('b')])"), + // The sync spin keeps the worker's loop busy so the cancel message + // cannot be processed before the script settles `completed` — the + // worker posts a completed result that must LOSE to the in-flight + // host cancellation. The trailing narration exercises host-side + // suppression: posted pre-cancel-processing worker-side, arriving + // post-cancel host-side. + script: script(` + log('started') + const end = Date.now() + 1000 + while (Date.now() < end) {} + phase('late phase') + log('late log') + return 'done' + `), parent, }) - await vi.waitFor(() => { expect(provider.runs.length).toBe(1) }) - // Same synchronous block: b is still a QUEUED waiter when the cancel - // lands, so cancel() rejects it outright; together with the immediate- - // cancel test above (the resumed-waiter tick), no post-cancel path can - // reach subagents.start. - provider.runs[0]!.settle(text('a-done')) - handle.cancel('raced') + await vi.waitFor(() => { expect(narration).toContain('started') }) + handle.cancel('raced the completion') const result = await handle.result expect(result.stopReason).toBe('cancelled') - expect(provider.runs.length).toBe(1) + expect(result.error).toContain('raced the completion') + expect(narration).toEqual(['started']) await handle.dispose() - }) + }, 15_000) - it('a dropped agent() promise cannot become an unhandled rejection when cancellation lands', async () => { - const unhandled: unknown[] = [] - const onUnhandled = (reason: unknown): void => { unhandled.push(reason) } - process.on('unhandledRejection', onUnhandled) - try { - const { ctx, parent, provider } = await setup({ manual: true }) - const handle = ctx.workflows.start({ - script: script(` - agent('dropped, never awaited') - return await agent('awaited') - `), - parent, - }) - await vi.waitFor(() => { expect(provider.runs.length).toBe(2) }) - handle.cancel() - await handle.result - await handle.dispose() - // Let any stray rejection reach the process hook before asserting. - await new Promise(resolve => setTimeout(resolve, 20)) - expect(unhandled).toEqual([]) - } finally { - process.off('unhandledRejection', onUnhandled) - } - }) - - it('cancel() force-settles the result of a script parked on a promise no hook owns', async () => { - const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 30 } }) - const ends: WorkflowResultInfo[] = [] - ctx.on('workflow/end', (_info, result) => { ends.push(result) }) + it('cancel() force-settles a script parked on a promise no hook owns, and TERMINATES its worker', async () => { + const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 50 } }) + const runEnds: WorkflowResultInfo[] = [] + ctx.on('workflow/end', (_info, result) => { runEnds.push(result) }) const handle = ctx.workflows.start({ - // No hooks involved: an unsettleable await cancellation cannot reject - // — the abandon grace is the only thing that can settle this run. script: script("await new Promise(() => {})\nreturn 'unreachable'"), parent, }) @@ -805,31 +374,20 @@ describe('dsh-workflow-vm', () => { expect(result.stopReason).toBe('cancelled') expect(result.error).toContain('user aborted') // The grace force-settle fires workflow/end exactly like an ordinary - // settlement — an abandoned script's death still reaches observers. - expect(ends).toEqual([{ stopReason: 'cancelled', error: result.error, agentsStarted: 0 }]) + // settlement — a terminated script's death still reaches observers. + expect(runEnds).toEqual([{ stopReason: 'cancelled', error: result.error, agentsStarted: 0 }]) await handle.dispose() }) - it('a never-settling returned thenable is abandoned the same way', async () => { - const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 30 } }) - const handle = ctx.workflows.start({ script: script('return { then() {} }'), parent }) - handle.cancel() - expect((await handle.result).stopReason).toBe('cancelled') - await handle.dispose() - }) - - it('dispose() abandons a stuck script after the grace instead of hanging (result settles cancelled)', async () => { - const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 30 } }) + it('dispose() on a stuck script returns within the grace instead of hanging (result settles cancelled)', async () => { + const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 50 } }) const handle = ctx.workflows.start({ script: script("await new Promise(() => {})\nreturn 'unreachable'"), parent, }) const before = Date.now() await handle.dispose() - expect(Date.now() - before).toBeLessThan(1000) - // The abandon that freed dispose() also settled result — a consumer - // still awaiting it (the tool does, before its disposing finally) is - // released rather than wedged forever. + expect(Date.now() - before).toBeLessThan(2000) const result = await handle.result expect(result.stopReason).toBe('cancelled') }) @@ -842,25 +400,28 @@ describe('dsh-workflow-vm', () => { await handle.dispose() }) - it('strays: children fired without await are aborted once the script settles', async () => { - const { ctx, parent, provider } = await setup({ manual: true }) - const handle = ctx.workflows.start({ - script: script(` - agent('stray') - return 'done without awaiting' - `), - parent, - }) - const result = await handle.result - expect(result.stopReason).toBe('completed') - await vi.waitFor(() => { - expect(provider.runs.length).toBe(1) - expect(provider.runs[0]!.disposed).toBe(true) - }) - await handle.dispose() + it('a settled run arms NO grace timer: disposing a completed run must not pin it for disposeGraceMs', async () => { + // A distinctive grace so the spy can tell the cancel-path grace timer + // apart from every other timeout in flight. + const GRACE = 44_444 + const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: GRACE } }) + const handle = ctx.workflows.start({ script: script('return 1'), parent }) + await handle.result + const spy = vi.spyOn(globalThis, 'setTimeout') + try { + await handle.dispose() + // dispose()'s own bounded-wait sleep is the ONLY grace-sized timer + // allowed here; before the settled guard, cancel() armed a second one + // that nothing would ever clear (the run was already settled), keeping + // the WorkerRun/Worker closure alive until the grace expired. + const graceTimers = spy.mock.calls.filter(call => call[1] === GRACE) + expect(graceTimers.length).toBe(1) + } finally { + spy.mockRestore() + } }) - it('dispose() waits for a stray child to FINISH disposing (quiescence), not just the script settle', async () => { + it('strays: children fired without await are aborted once the script settles, and dispose() waits for their disposal', async () => { const { ctx, parent, provider } = await setup({ manual: true, disposeDelayMs: 40 }) const handle = ctx.workflows.start({ script: script(` @@ -871,12 +432,222 @@ describe('dsh-workflow-vm', () => { }) const result = await handle.result expect(result.stopReason).toBe('completed') - expect(provider.runs.length).toBe(1) + await vi.waitFor(() => { expect(provider.runs.length).toBe(1) }) await handle.dispose() // Not a waitFor: by the time dispose() returns, the slow child disposal - // must already be complete. + // must already be complete (host-side registry quiescence). expect(provider.runs[0]!.disposed).toBe(true) }) + + it('the settle-reap fires the request signal too: a provider honoring ONLY the signal winds its stray down promptly', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const aborted: string[] = [] + const provider: SubagentProvider = { + name: 'signal-only', + capabilities: { outputSchema: true, depthLimit: true, toolFilter: true }, + inheritsParentContext: false, + start: (request) => { + let settle!: (result: SubagentResult) => void + const result = new Promise((resolve) => { settle = resolve }) + request.signal?.addEventListener('abort', () => { + aborted.push(String(request.signal?.reason)) + settle({ output: [], stopReason: 'aborted' }) + }, { once: true }) + return { + id: AgentId('signal-only-child'), + result, + // The seam leaves a provider free to honor EITHER cancel channel; + // this one deliberately ignores run.cancel() — only the request + // signal can wind it down. + cancel: () => { /* signal-only by design */ }, + dispose: () => Promise.resolve(), + } + }, + } + ctx.subagents.registerProvider(provider) + await ctx.plugin(WorkerWorkflowEngine, { provider: 'signal-only', maxConcurrentAgents: 2 }) + const handle = ctx.workflows.start({ + script: script(` + agent('stray, never awaited') + return 'done' + `), + parent: fakeParent(), + }) + const result = await handle.result + expect(result.stopReason).toBe('completed') + // BEFORE dispose(): the settlement itself must have aborted the signal — + // without it this child would stay live until dispose's terminate. + await vi.waitFor(() => { expect(aborted).toEqual(['workflow settled']) }) + await handle.dispose() + }) + + it("cancel() drives each child's explicit cancel() host-side: a wedged worker cannot delay it", async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + let starts = 0 + const cancelled: string[] = [] + const provider: SubagentProvider = { + name: 'cancel-only', + capabilities: { outputSchema: true, depthLimit: true, toolFilter: true }, + inheritsParentContext: false, + start: () => { + starts += 1 + return { + id: AgentId('cancel-only-child'), + result: new Promise(() => { /* only cancel() ends this child */ }), + // Deliberately ignores the request signal — the seam leaves a + // provider free to honor ONLY the explicit cancel() channel. + cancel: (reason?: string) => { cancelled.push(reason ?? 'cancelled') }, + dispose: () => Promise.resolve(), + } + }, + } + ctx.subagents.registerProvider(provider) + // A deliberately huge grace: if only the grace/terminate reap could + // reach this child, the assertion below would time out first. + await ctx.plugin(WorkerWorkflowEngine, { provider: 'cancel-only', maxConcurrentAgents: 2, disposeGraceMs: 30_000 }) + const handle = ctx.workflows.start({ + // The stray child's start RPC reaches the host, then the script wedges + // its own worker in a synchronous spin: the worker cannot process the + // Cancel message, so it can relay NO ChildCancel RPC — only the host's + // own children loop can deliver the explicit cancel in time. + script: script(` + agent('wedged child') + const end = Date.now() + 1500 + while (Date.now() < end) {} + return 'raced' + `), + parent: fakeParent(), + }) + await vi.waitFor(() => { expect(starts).toBe(1) }) + handle.cancel('stop now') + await vi.waitFor(() => { expect(cancelled).toEqual(['stop now']) }, { timeout: 800 }) + // The wedged worker's own completion loses to the in-flight cancel. + const result = await handle.result + expect(result.stopReason).toBe('cancelled') + await handle.dispose() + }, 15_000) + }) + + describe('worker death', () => { + it('a worker that exits before settling reports an error result and reaps its children', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + // The child's dispose() REJECTS on top of the worker death: the reap + // must contain it (warn, not crash) while still emptying the registry. + const cancelled: string[] = [] + const provider: SubagentProvider = { + name: 'doomed', + capabilities: { outputSchema: true, depthLimit: true, toolFilter: true }, + inheritsParentContext: false, + start: () => ({ + id: AgentId('doomed-child'), + result: new Promise(() => { /* never settles; the reap is the teardown */ }), + cancel: (reason?: string) => { cancelled.push(reason ?? 'cancelled') }, + dispose: () => Promise.reject(new Error('dispose exploded during reap')), + }), + } + ctx.subagents.registerProvider(provider) + await ctx.plugin(WorkerWorkflowEngine, { provider: 'doomed', maxConcurrentAgents: 2 }) + const runEnds: WorkflowResultInfo[] = [] + ctx.on('workflow/end', (_info, result) => { runEnds.push(result) }) + const handle = ctx.workflows.start({ + // The stray child's start RPC reaches the host, then the script kills + // its own worker through the documented vm escape — the host must + // settle `error` with the exit diagnostics and wind the child down. + script: script(` + agent('doomed') + const proc = ${ESCAPE} + const st = globalThis.constructor.constructor('return setTimeout')() + await new Promise(resolve => st(resolve, 200)) + proc.exit(7) + `), + parent: fakeParent(), + }) + const result = await handle.result + expect(result.stopReason).toBe('error') + expect(result.error).toContain('exit code 7') + expect(result.agentsStarted).toBe(1) + // A worker death is a stop reason like any other: workflow/end fires + // with the error outcome — for a bus observer it is the only obituary. + expect(runEnds).toEqual([{ stopReason: 'error', error: result.error, agentsStarted: 1 }]) + await vi.waitFor(() => { expect(cancelled.length).toBe(1) }) + await handle.dispose() + }, 15_000) + + it('an uncaught exception inside the worker surfaces as an error result and reaps the in-flight child', async () => { + const { ctx, parent, provider } = await setup({ manual: true }) + const handle = ctx.workflows.start({ + script: script(` + agent('in flight when the worker dies') + const proc = ${ESCAPE} + const st = globalThis.constructor.constructor('return setTimeout')() + await new Promise(resolve => st(resolve, 200)) + proc.nextTick(() => { throw new Error('worker blew up') }) + await new Promise(() => {}) + `), + parent, + }) + const result = await handle.result + expect(result.stopReason).toBe('error') + expect(result.error).toContain('worker blew up') + // The reap wound the stray child down (cancel + a CLEAN dispose). + await vi.waitFor(() => { + expect(provider.runs.length).toBe(1) + expect(provider.runs[0]!.disposed).toBe(true) + }) + await handle.dispose() + }, 15_000) + + it('a dispose ack racing the worker death is dropped, not crashed (post after exit)', async () => { + // Slow child disposal: the ack resolves only AFTER the worker died, so + // it has nowhere to go and must be dropped silently (the workerGone + // guard in post()). + const { ctx, parent, provider } = await setup({ disposeDelayMs: 300 }) + const handle = ctx.workflows.start({ + // The STRAY child settles instantly, so its wrapper starts the slow + // host-side disposal concurrently while the script goes on to kill + // its own worker — the ack then resolves into a dead thread. + script: script(` + agent('stray, never awaited') + const proc = ${ESCAPE} + const st = globalThis.constructor.constructor('return setTimeout')() + await new Promise(resolve => st(resolve, 150)) + proc.exit(5) + `), + parent, + }) + const result = await handle.result + expect(result.stopReason).toBe('error') + expect(result.error).toContain('exit code 5') + await vi.waitFor(() => { expect(provider.runs[0]!.disposed).toBe(true) }) + await handle.dispose() + }, 15_000) + + it('a worker death AFTER a cancel reports cancelled, not error', async () => { + const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 60_000 } }) + const handle = ctx.workflows.start({ + script: script(` + const proc = ${ESCAPE} + const st = globalThis.constructor.constructor('return setTimeout')() + log('armed') + await new Promise(resolve => st(resolve, 400)) + proc.exit(3) + `), + parent, + }) + const logs: string[] = [] + ctx.on('workflow/log', (_info, message) => { logs.push(message) }) + await vi.waitFor(() => { expect(logs).toContain('armed') }) + handle.cancel('stop it') + // The grace is deliberately huge: only the worker's own death (exit 3, + // unreachable by the cancel — the script ignores hooks) settles this. + const result = await handle.result + expect(result.stopReason).toBe('cancelled') + expect(result.error).toContain('stop it') + await handle.dispose() + }, 15_000) }) describe('service surface', () => { @@ -887,7 +658,6 @@ describe('dsh-workflow-vm', () => { const first = ctx.workflows.start({ script: script('return 1'), parent }) const second = ctx.workflows.start({ script: script('return 2'), parent }) expect(first.id).not.toBe(second.id) - // Mutating a listener's snapshot cannot corrupt the holder's view. eventMeta!.meta.name = 'corrupted' expect(second.meta.name).toBe('test-flow') await Promise.all([first.result, second.result]) @@ -895,38 +665,24 @@ describe('dsh-workflow-vm', () => { await second.dispose() }) - it('a listener mutating one event payload cannot corrupt later events (per-emission snapshots)', async () => { - const { ctx, parent } = await setup() - const ends: unknown[] = [] - let endInfo: WorkflowRunInfo | undefined - ctx.on('workflow/agent-start', (info, agent) => { - agent.seq = 999 - agent.label = 'HACKED' - info.meta.name = 'HACKED' - }) - ctx.on('workflow/agent-end', (info, agent) => { - ends.push(agent) - endInfo = info - }) - await run(ctx, parent, script("return await agent('job', { label: 'honest' })")) - expect(ends[0]).toMatchObject({ seq: 1, label: 'honest', outcome: 'completed' }) - expect(endInfo!.meta.name).toBe('test-flow') - }) - - it('unregisters ctx.workflows when the engine fiber is disposed (HMR safety)', async () => { + it('unregisters ctx.workflows when the engine fiber is disposed (HMR safety), and default config runs (auto concurrency)', async () => { const ctx = new Context() await ctx.plugin(SubagentService) - const fiber = await ctx.plugin(VmWorkflowEngine, {}) + const fiber = await ctx.plugin(WorkerWorkflowEngine, {}) expect(ctx.get('workflows')).toBeDefined() + // A zero-agent run through the DEFAULT config exercises the auto + // concurrency resolution (cores - 2, capped) in start(). + const result = await run(ctx, fakeParent(), script('return 6 * 7')) + expect(result.value).toBe(42) await fiber.dispose() expect(ctx.get('workflows')).toBeUndefined() }) it('has the class-plugin export shape (default = the engine service class)', () => { - expect(vmEngineModule.default).toBe(VmWorkflowEngine) + expect(workerEngineModule.default).toBe(WorkerWorkflowEngine) const loader = Object.create(Loader.prototype) as Loader - const unwrapped: unknown = loader.unwrapExports(vmEngineModule) - expect(unwrapped).toBe(VmWorkflowEngine) + const unwrapped: unknown = loader.unwrapExports(workerEngineModule) + expect(unwrapped).toBe(WorkerWorkflowEngine) }) }) }) diff --git a/packages/workflow/workflow-vm/tests/workflow.e2e.ts b/packages/workflow/workflow-vm/tests/workflow.e2e.ts index c3f959d072..76cfd73d33 100644 --- a/packages/workflow/workflow-vm/tests/workflow.e2e.ts +++ b/packages/workflow/workflow-vm/tests/workflow.e2e.ts @@ -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 { 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) }) diff --git a/packages/workflow/workflow-vm/tsdown.config.ts b/packages/workflow/workflow-vm/tsdown.config.ts new file mode 100644 index 0000000000..3102a36c1c --- /dev/null +++ b/packages/workflow/workflow-vm/tsdown.config.ts @@ -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, + }, +]) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f236c67bf2..e3e1892e35 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index f579169ab0..2d12a66d8f 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -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[] { diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 6c844f842a..25e08cf8d2 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -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'],