Six verified A-findings from the code-stage review, each with a regression test: - parallel()/pipeline() resolved to HOST arrays inside the vm realm, exposing host Array.prototype to scripts; combinator results are now realm-built (in-realm Array.from bound at context setup). - materializeFromRealm ran proxy traps (ownKeys/getOwnPropertyDescriptor/ getPrototypeOf) during the descriptor walk — realm code on the host stack, outside the vm timeout, escaping as raw errors; proxies (root, nested, and in the prototype position) are now rejected trap-free via util.types.isProxy before any inspection. - an already-aborted signal or an immediate cancel() no longer reports 'completed' for a hook-free script: drive() checks cancellation before running the body and again when the script settles. - dispose() now waits (bounded by disposeGraceMs) for stray agent() children to FINISH disposing, not just for the script to settle: every agent() call is tracked and quiesce() drains the in-flight set. - workflow/* event payloads were live mutable aliases shared across emissions; emitWorkflowEvent now hands each listener its own structural clone. - the structured-output turn-continuation veto is now prepend: true, so an earlier-registered force-continue listener cannot short-circuit it. Docs updated in the same change (READMEs, core-data-structures/workflow.md, the dynamic-workflows RFC, regenerated cordis catalogs).
4.6 KiB
@deepseek-ai/dsh-workflow-vm
The first WorkflowService 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.
The script contract it executes
- Meta extraction (
extractMeta): a string/comment-aware brace scanner finds the leadingexport const metaliteral (template interpolation rejected — the literal must be pure), evaluates it ALONE in an empty timed vm context, materializes the result to plain JSON data, validates the shape (name/descriptionrequired; unknown fields rejected loud), and blanks the statement line-preservingly so error stacks keep the script's own line numbers. - Hooks:
agent(prompt, {label, phase, schema, model})(schema = the structured-output subset, forwarded asoutputSchema; result = validated object, or final text without a schema; a failed child resolvesnull),parallel(thunks),pipeline(items, ...stages)with NO cross-stage barrier and(prev, item, index)stage callbacks,phase(title),log(message), and theargsglobal. Anything else —effort/isolation/agentType, unknown options, malformed arguments, schemas outside the subset — throws a FATALWorkflowErrorthatparallel/pipelinere-throw rather than nulling (see the seam README's failure discipline). - Determinism bans:
Date.now(),Math.random(), and arglessnew Date()throw (kept even though resume is deferred, so scripts stay resume-compatible); no timers, filesystem, or Node APIs exist in the context.
Realm discipline
Values ENTERING the host (the meta literal, hook options/schemas, the script's return) are materialized by materializeFromRealm: a descriptor walk that never invokes accessors and rejects loud everything JSON cannot carry (accessors, exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested undefined, and proxies — rejected via the trap-free util.types.isProxy BEFORE any inspection could run a realm-side trap on the host stack), copying into host containers via defineProperty so a "__proto__" key becomes a data property, never a prototype mutation. Values ENTERING the realm (args, agent() results) are rebuilt INSIDE the realm through the context's own JSON.parse, and the arrays parallel/pipeline resolve to are realm-built, so the script never holds an object whose prototype chain reaches host intrinsics.
Limits, cancellation, 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. 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 (the app boot layer exits the process on those).
Documented limitations (the accepted cost of the in-process mechanism; the seam exists so a worker-thread/isolated-vm engine can swap in): vm is NOT a security boundary — scripts are model-written, the same trust level as the model's bash access — and the vm timeout covers only the initial synchronous slice, so a pathological synchronous spin after the first await 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).
Config
| Key | Default | Meaning |
|---|---|---|
provider |
spawn |
The ctx.subagents provider children run on. |
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. |