workflow: swap the engine's internals to node:worker_threads
In-place port of dsh-workflow-vm from the in-process node:vm execution
to one worker thread per run (the workflow-workerthread engine of
PR #215, adopted as THE engine): the script's vm context moves inside
the worker, agent() bridges to ctx.subagents over the message port
(host.ts/protocol.ts/session.ts/worker.ts are new; runtime.ts loses the
abandon channel — the host's grace timer force-settles and TERMINATES
instead), start() pre-parses the body host-side to keep the seam's
synchronous SCRIPT_PARSE throw, and a ready→go handshake keeps a run
cancelled before start from ever executing the body. start() no longer
blocks the host, termination is real, and the value boundary is
serialization by construction. The package keeps its name until the
follow-up rename commit; scripts see the identical hook surface, and
the seam-contract tests hardened ahead of this swap pass unchanged.
The run and child-RPC surfaces are class-shaped rather than literal
bundles: WorkerRun IMPLEMENTS the seam's WorkflowRun (id/meta are its
own clone, separate from event payloads') and start() returns the
instance directly — interface parity with the seam is compiler-checked;
worker-side, ChildRpcBridge (implements ChildPort; callId allocation +
pending book-keeping settled by onChild* entry points) and
RpcChildHandle (every member an RPC keyed by its callId) carry names in
stacks. ChildPort's method is startAgent — it names what it starts,
matching the script-side agent() hook and the agentsStarted /
workflow/agent-* vocabulary; the Child* type names deliberately stay
(the worker side is cordis- and subagent-free; these are reduced JSON
projections, not the seam's types).
Review findings from the reference PR are folded in rather than
re-introduced:
- cancel() drives BOTH child-cancel channels host-side: the request
signal aborts AND each registered child's explicit cancel() is
called — a worker wedged in a synchronous spin cannot relay its own
ChildCancel RPCs (regression: cancel-only provider + wedged worker).
- All host warn paths render through the total renderThrown; a child
dispose() rejecting a value whose coercion throws still acks
ChildDisposed instead of wedging the script's finally (regression).
- built-worker.e2e.ts is wired into builtBinSmokeGate and the AGENTS.md
CI sequence — the built lib/worker.js resolution contract now runs in
an automated gate.
- workflow/end payload pinned on the worker-death path (with the
cancelled and grace-force-settle pins riding the ported spec).
- Real-Worker scripted timing budgets widened (50-300ms → 150-1000ms)
for starved CI hosts.
Workspace plumbing: the "./worker" subpath export sanctions the second
runtime bundle (check-workspace-constraints), tsdown builds two
single-entry passes, tsx becomes a devDependency for the unbuilt worker
spawn.
2026-07-09 18:39:31 +08:00
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*
|
2026-07-09 19:06:55 +08:00
|
|
|
* @module @deepseek-ai/dsh-workflow-workerthread/protocol
|
workflow: swap the engine's internals to node:worker_threads
In-place port of dsh-workflow-vm from the in-process node:vm execution
to one worker thread per run (the workflow-workerthread engine of
PR #215, adopted as THE engine): the script's vm context moves inside
the worker, agent() bridges to ctx.subagents over the message port
(host.ts/protocol.ts/session.ts/worker.ts are new; runtime.ts loses the
abandon channel — the host's grace timer force-settles and TERMINATES
instead), start() pre-parses the body host-side to keep the seam's
synchronous SCRIPT_PARSE throw, and a ready→go handshake keeps a run
cancelled before start from ever executing the body. start() no longer
blocks the host, termination is real, and the value boundary is
serialization by construction. The package keeps its name until the
follow-up rename commit; scripts see the identical hook surface, and
the seam-contract tests hardened ahead of this swap pass unchanged.
The run and child-RPC surfaces are class-shaped rather than literal
bundles: WorkerRun IMPLEMENTS the seam's WorkflowRun (id/meta are its
own clone, separate from event payloads') and start() returns the
instance directly — interface parity with the seam is compiler-checked;
worker-side, ChildRpcBridge (implements ChildPort; callId allocation +
pending book-keeping settled by onChild* entry points) and
RpcChildHandle (every member an RPC keyed by its callId) carry names in
stacks. ChildPort's method is startAgent — it names what it starts,
matching the script-side agent() hook and the agentsStarted /
workflow/agent-* vocabulary; the Child* type names deliberately stay
(the worker side is cordis- and subagent-free; these are reduced JSON
projections, not the seam's types).
Review findings from the reference PR are folded in rather than
re-introduced:
- cancel() drives BOTH child-cancel channels host-side: the request
signal aborts AND each registered child's explicit cancel() is
called — a worker wedged in a synchronous spin cannot relay its own
ChildCancel RPCs (regression: cancel-only provider + wedged worker).
- All host warn paths render through the total renderThrown; a child
dispose() rejecting a value whose coercion throws still acks
ChildDisposed instead of wedging the script's finally (regression).
- built-worker.e2e.ts is wired into builtBinSmokeGate and the AGENTS.md
CI sequence — the built lib/worker.js resolution contract now runs in
an automated gate.
- workflow/end payload pinned on the worker-death path (with the
cancelled and grace-force-settle pins riding the ported spec).
- Real-Worker scripted timing budgets widened (50-300ms → 150-1000ms)
for starved CI hosts.
Workspace plumbing: the "./worker" subpath export sanctions the second
runtime bundle (check-workspace-constraints), tsdown builds two
single-entry passes, tsx becomes a devDependency for the unbuilt worker
spawn.
2026-07-09 18:39:31 +08:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
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: dispose a started child (answered by ChildDisposed). */
|
|
|
|
|
ChildDispose = 'child-dispose',
|
|
|
|
|
/** The run's single terminal result. */
|
|
|
|
|
Result = 'result',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** The payload each worker→host tag carries. */
|
|
|
|
|
export interface WorkerToHostPayloads {
|
|
|
|
|
/** Ready carries nothing. */
|
|
|
|
|
[WorkerToHostType.Ready]: Record<never, never>
|
|
|
|
|
/** The phase title, verbatim. */
|
|
|
|
|
[WorkerToHostType.Phase]: { title: string }
|
|
|
|
|
/** The logged message, verbatim. */
|
|
|
|
|
[WorkerToHostType.Log]: { message: string }
|
|
|
|
|
/** The call's sequence number, label, phase, and child id. */
|
|
|
|
|
[WorkerToHostType.AgentStart]: { info: WorkflowAgentInfo }
|
|
|
|
|
/** The call identity plus its outcome. */
|
|
|
|
|
[WorkerToHostType.AgentEnd]: { info: WorkflowAgentEndInfo }
|
|
|
|
|
/** The RPC correlation id and the prompt plus validated options. */
|
|
|
|
|
[WorkerToHostType.ChildStart]: { callId: number; request: ChildStartRequest }
|
|
|
|
|
/** The RPC correlation id 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',
|
2026-07-12 22:41:59 +08:00
|
|
|
/** Child RPC reply: the provider fulfilled with a ready run (exactly one start reply per ChildStart). */
|
workflow: swap the engine's internals to node:worker_threads
In-place port of dsh-workflow-vm from the in-process node:vm execution
to one worker thread per run (the workflow-workerthread engine of
PR #215, adopted as THE engine): the script's vm context moves inside
the worker, agent() bridges to ctx.subagents over the message port
(host.ts/protocol.ts/session.ts/worker.ts are new; runtime.ts loses the
abandon channel — the host's grace timer force-settles and TERMINATES
instead), start() pre-parses the body host-side to keep the seam's
synchronous SCRIPT_PARSE throw, and a ready→go handshake keeps a run
cancelled before start from ever executing the body. start() no longer
blocks the host, termination is real, and the value boundary is
serialization by construction. The package keeps its name until the
follow-up rename commit; scripts see the identical hook surface, and
the seam-contract tests hardened ahead of this swap pass unchanged.
The run and child-RPC surfaces are class-shaped rather than literal
bundles: WorkerRun IMPLEMENTS the seam's WorkflowRun (id/meta are its
own clone, separate from event payloads') and start() returns the
instance directly — interface parity with the seam is compiler-checked;
worker-side, ChildRpcBridge (implements ChildPort; callId allocation +
pending book-keeping settled by onChild* entry points) and
RpcChildHandle (every member an RPC keyed by its callId) carry names in
stacks. ChildPort's method is startAgent — it names what it starts,
matching the script-side agent() hook and the agentsStarted /
workflow/agent-* vocabulary; the Child* type names deliberately stay
(the worker side is cordis- and subagent-free; these are reduced JSON
projections, not the seam's types).
Review findings from the reference PR are folded in rather than
re-introduced:
- cancel() drives BOTH child-cancel channels host-side: the request
signal aborts AND each registered child's explicit cancel() is
called — a worker wedged in a synchronous spin cannot relay its own
ChildCancel RPCs (regression: cancel-only provider + wedged worker).
- All host warn paths render through the total renderThrown; a child
dispose() rejecting a value whose coercion throws still acks
ChildDisposed instead of wedging the script's finally (regression).
- built-worker.e2e.ts is wired into builtBinSmokeGate and the AGENTS.md
CI sequence — the built lib/worker.js resolution contract now runs in
an automated gate.
- workflow/end payload pinned on the worker-death path (with the
cancelled and grace-force-settle pins riding the ported spec).
- Real-Worker scripted timing budgets widened (50-300ms → 150-1000ms)
for starved CI hosts.
Workspace plumbing: the "./worker" subpath export sanctions the second
runtime bundle (check-workspace-constraints), tsdown builds two
single-entry passes, tsx becomes a devDependency for the unbuilt worker
spawn.
2026-07-09 18:39:31 +08:00
|
|
|
ChildStarted = 'child-started',
|
2026-07-12 22:41:59 +08:00
|
|
|
/** Child RPC reply: the provider's asynchronous start failed. */
|
workflow: swap the engine's internals to node:worker_threads
In-place port of dsh-workflow-vm from the in-process node:vm execution
to one worker thread per run (the workflow-workerthread engine of
PR #215, adopted as THE engine): the script's vm context moves inside
the worker, agent() bridges to ctx.subagents over the message port
(host.ts/protocol.ts/session.ts/worker.ts are new; runtime.ts loses the
abandon channel — the host's grace timer force-settles and TERMINATES
instead), start() pre-parses the body host-side to keep the seam's
synchronous SCRIPT_PARSE throw, and a ready→go handshake keeps a run
cancelled before start from ever executing the body. start() no longer
blocks the host, termination is real, and the value boundary is
serialization by construction. The package keeps its name until the
follow-up rename commit; scripts see the identical hook surface, and
the seam-contract tests hardened ahead of this swap pass unchanged.
The run and child-RPC surfaces are class-shaped rather than literal
bundles: WorkerRun IMPLEMENTS the seam's WorkflowRun (id/meta are its
own clone, separate from event payloads') and start() returns the
instance directly — interface parity with the seam is compiler-checked;
worker-side, ChildRpcBridge (implements ChildPort; callId allocation +
pending book-keeping settled by onChild* entry points) and
RpcChildHandle (every member an RPC keyed by its callId) carry names in
stacks. ChildPort's method is startAgent — it names what it starts,
matching the script-side agent() hook and the agentsStarted /
workflow/agent-* vocabulary; the Child* type names deliberately stay
(the worker side is cordis- and subagent-free; these are reduced JSON
projections, not the seam's types).
Review findings from the reference PR are folded in rather than
re-introduced:
- cancel() drives BOTH child-cancel channels host-side: the request
signal aborts AND each registered child's explicit cancel() is
called — a worker wedged in a synchronous spin cannot relay its own
ChildCancel RPCs (regression: cancel-only provider + wedged worker).
- All host warn paths render through the total renderThrown; a child
dispose() rejecting a value whose coercion throws still acks
ChildDisposed instead of wedging the script's finally (regression).
- built-worker.e2e.ts is wired into builtBinSmokeGate and the AGENTS.md
CI sequence — the built lib/worker.js resolution contract now runs in
an automated gate.
- workflow/end payload pinned on the worker-death path (with the
cancelled and grace-force-settle pins riding the ported spec).
- Real-Worker scripted timing budgets widened (50-300ms → 150-1000ms)
for starved CI hosts.
Workspace plumbing: the "./worker" subpath export sanctions the second
runtime bundle (check-workspace-constraints), tsdown builds two
single-entry passes, tsx becomes a devDependency for the unbuilt worker
spawn.
2026-07-09 18:39:31 +08:00
|
|
|
ChildStartError = 'child-start-error',
|
|
|
|
|
/** Child RPC: a started child's result RESOLVED (its JSON projection). */
|
|
|
|
|
ChildSettled = 'child-settled',
|
|
|
|
|
/** Child RPC: a started child's result REJECTED (an infrastructure fault, rendered). */
|
|
|
|
|
ChildFailed = 'child-failed',
|
|
|
|
|
/** Child RPC reply: a requested disposal completed. */
|
|
|
|
|
ChildDisposed = 'child-disposed',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** The payload each host→worker tag carries. */
|
|
|
|
|
export interface HostToWorkerPayloads {
|
|
|
|
|
/** Go carries nothing. */
|
|
|
|
|
[HostToWorkerType.Go]: Record<never, never>
|
|
|
|
|
/** The cancel reason, canonical for the whole run. */
|
|
|
|
|
[HostToWorkerType.Cancel]: { reason: string }
|
|
|
|
|
/** The RPC correlation id and the child agent's id (minted by the subagent seam). */
|
|
|
|
|
[HostToWorkerType.ChildStarted]: { callId: number; childId: string }
|
|
|
|
|
/** The RPC correlation id and the rendered start failure. */
|
|
|
|
|
[HostToWorkerType.ChildStartError]: { callId: number; rendered: string }
|
|
|
|
|
/** The RPC correlation id and the child's terminal result projection. */
|
|
|
|
|
[HostToWorkerType.ChildSettled]: { callId: number; result: ChildResult }
|
|
|
|
|
/** The RPC correlation id and the rendered infrastructure fault. */
|
|
|
|
|
[HostToWorkerType.ChildFailed]: { callId: number; rendered: string }
|
|
|
|
|
/** The RPC correlation id of the completed disposal. */
|
|
|
|
|
[HostToWorkerType.ChildDisposed]: { callId: number }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* One worker→host message of tag `T`; unparameterized, the closed union over
|
|
|
|
|
* every tag (a discriminated union — `switch` on `type` narrows).
|
|
|
|
|
*/
|
|
|
|
|
export type WorkerToHostMessage<T extends WorkerToHostType = WorkerToHostType> =
|
|
|
|
|
{ [K in T]: { type: K } & WorkerToHostPayloads[K] }[T]
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* One host→worker message of tag `T`; unparameterized, the closed union over
|
|
|
|
|
* every tag (a discriminated union — `switch` on `type` narrows).
|
|
|
|
|
*/
|
|
|
|
|
export type HostToWorkerMessage<T extends HostToWorkerType = HostToWorkerType> =
|
|
|
|
|
{ [K in T]: { type: K } & HostToWorkerPayloads[K] }[T]
|