Merge remote-tracking branch 'origin/master' into codex/skill-system

# Conflicts:
#	docs/architecture.md
#	docs/config-catalog.md
#	docs/module-graph.md
#	docs/rfc/INDEX.md
#	examples/acp-agent/tests/snapshots/text-turn/session.jsonl
#	packages/core/agent-core/src/index.ts
#	packages/core/tools/tests/gen-tool-catalog.spec.ts
#	packages/support/acp-snapshot/src/suite.ts
#	packages/ui/acp-agent/src/index.ts
This commit is contained in:
Yichen Jiang
2026-07-10 14:43:33 +08:00
192 changed files with 15401 additions and 5568 deletions
@@ -0,0 +1,473 @@
/**
* 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, `dispose()` host-drives every
* registered child's disposal immediately (a wedged worker can relay no
* dispose RPC, and child teardown must overlap the grace, not start after
* it), and the registry is what lets the host abort and dispose every
* survivor when the worker dies or is terminated mid-flight. The three
* paths share ONE disposal per child (memoized by callId; the seam's
* dispose() is idempotent anyway, the memo keeps the bookkeeping and the
* containment warn single). Lifecycle pairing is host-guaranteed the same
* way: every forwarded `agent-start` lives in a ledger, and a start the
* dead or terminated worker never paired is closed by a synthesized
* `agent-end` (outcome `'cancelled'`) before the run settles. 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-workerthread/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 { WorkflowAgentEndInfo, WorkflowAgentInfo, 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 (execArgv
* pinned empty — hermetic, like the environment).
*
* Both shapes spawn with an EMPTY environment (`env: {}`): the documented vm
* escape reaches `process`, and the harness's ambient credentials
* (`DEEPSEEK_API_KEY` et al.) must not ride along — the same stance as
* `dsh-code-runtime-worker`, stronger than the scrubbed env the
* defensive-patterns rule requires for spawned commands (a shell needs PATH;
* this worker needs nothing). Sole exception: the unbuilt shape forwards
* `TSX_TSCONFIG_PATH` when the parent carries it (loader plumbing the paths
* map depends on outside the repo cwd, not a secret). This closes the
* AMBIENT channel only — an escapee still holds process-wide privileges
* like fs access (the README's trust premise stands).
* @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, env: {}, execArgv: [] } }
}
// Lazy tsx resolution: only the unbuilt shape needs it, so the built
// bundle never requires tsx to be installed. TSX_TSCONFIG_PATH is the one
// variable forwarded through the scrub: tsx finds a tsconfig by searching
// UP from the worker's cwd, and a parent running with its cwd outside the
// repo (the ACP snapshot harness pins the tsconfig through this exact
// variable) would otherwise lose the dsh-* paths map and resolve workspace
// imports to unbuilt lib/ bundles. Loader plumbing, not a secret.
return {
entry: new URL('./worker.ts', import.meta.url),
options: {
workerData: init,
env: process.env.TSX_TSCONFIG_PATH === undefined ? {} : { TSX_TSCONFIG_PATH: process.env.TSX_TSCONFIG_PATH },
execArgv: ['--import', fileURLToPath(import.meta.resolve('tsx'))],
},
}
}
/**
* One live worker-engine run — the seam's {@link WorkflowRun}, returned by
* `start()` directly. Owns the Worker, the child registry, and the result
* settlement; `result` never rejects. `meta` is this handle's OWN clone
* (event payloads carry separate clones), so a consumer mutating it corrupts
* nothing.
*/
export class WorkerRun implements WorkflowRun {
/** Settles exactly once with the run's outcome; never rejects. */
readonly result: Promise<WorkflowResult>
private settleResolve!: (result: WorkflowResult) => void
private settled = false
private cancelReason: string | undefined
private graceTimer: NodeJS.Timeout | undefined
private readonly worker: Worker
/** Set on `exit`: the thread is gone, so posting has nowhere to go. */
private workerGone = false
/** Accepted `child-start` messages — the terminate-path `agentsStarted` (see module doc). */
private hostStarted = 0
/** Live children by callId; an entry leaves ONLY after its dispose settles (quiescence = empty). */
private readonly children = new Map<number, SubagentRun>()
/** In-flight child disposals by callId — the memo that gives every path (worker RPC, dispose(), reap) ONE shared disposal per child. */
private readonly childDisposals = new Map<number, Promise<void>>()
/** Started-but-not-ended agents by seq — the pairing ledger the HOST guarantees (see {@link endAgent}). */
private readonly liveAgents = new Map<number, WorkflowAgentInfo>()
private readonly quiescenceWaiters: (() => void)[] = []
/** The per-run abort fanout every child start request carries. */
private readonly controller = new AbortController()
private disposed: Promise<void> | undefined
constructor(
private readonly ctx: Context,
readonly id: WorkflowRunId,
readonly meta: WorkflowMeta,
private readonly parent: Agent,
init: WorkerInit,
private readonly provider: string,
private readonly disposeGraceMs: number,
private readonly observer: ExecutionObserver,
signal: AbortSignal | undefined,
) {
this.result = new Promise<WorkflowResult>((resolve) => { this.settleResolve = resolve })
// workerData rides the structured clone: args are plain JSON by the seam
// contract, so the clone is total and doubles as the caller-isolation
// copy (a clone failure throws loud out of start()).
const { entry, options } = resolveWorkerSpawn(init)
this.worker = new Worker(entry, options)
this.worker.on('message', (message: WorkerToHostMessage) => { this.onMessage(message) })
this.worker.on('error', (error) => { this.onWorkerDeath(`workflow worker failed: ${renderThrown(error)}`) })
/* v8 ignore next -- messageerror: not constructible from the engine's own protocol (every payload is JSON data) */
this.worker.on('messageerror', (error) => { this.onWorkerDeath(`workflow worker message failed to deserialize: ${renderThrown(error)}`) })
this.worker.on('exit', (code) => {
this.workerGone = true
this.onWorkerDeath(`workflow worker exited before the run settled (exit code ${code})`)
})
if (signal?.aborted) {
this.cancel('workflow start signal already aborted')
} else {
signal?.addEventListener('abort', () => { this.cancel('workflow signal aborted') }, { once: true })
}
}
/**
* Cancel the run: the worker is told (its hooks start throwing and the
* script dies at its next await), every host-side child is cancelled NOW on
* BOTH seam channels — the shared request signal aborts and each registered
* child's explicit `cancel()` is called (the seam leaves a provider free to
* honor either, and a worker wedged in a synchronous spin could not relay
* its own per-child cancel RPCs until far too late) — and the grace timer
* arms: a run still unsettled `disposeGraceMs` later force-settles
* `cancelled` and its worker is TERMINATED. Idempotent; the first reason
* wins.
* @param reason - human-readable cause (default `'workflow cancelled'`).
*/
cancel(reason?: string): void {
// A settled run has nothing left to cancel: without this guard the
// ordinary consumer path (await result, then dispose -> cancel) would arm
// a grace timer nothing ever clears, pinning the run and its Worker
// closure until the grace expires - a bounded leak per completed run.
if (this.settled || this.cancelReason !== undefined) return
this.cancelReason = reason ?? 'workflow cancelled'
this.post(HostToWorkerType.Cancel, { reason: this.cancelReason })
this.controller.abort(this.cancelReason)
// The explicit channel is driven host-side, not left to the worker: a
// provider honoring only run.cancel() must not wait on a wedged worker's
// ChildCancel relay (those later RPCs land as idempotent no-ops).
for (const run of this.children.values()) run.cancel(this.cancelReason)
this.graceTimer = setTimeout(() => {
// The worker may no longer speak (it is about to be terminated): pair
// every stranded start before the run settles, so ends precede
// workflow/end.
this.endStrandedAgents()
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. Host-drives every registered
* child's disposal IMMEDIATELY — a wedged worker can relay no dispose RPC,
* and deferring child teardown to the post-terminate reap would spend the
* whole grace waiting for a quiescence that cannot start, then return with
* the disposals still in flight — so child disposal overlaps the same
* grace the worker gets to settle (the worker's own dispose RPCs join the
* shared per-child disposal). Waits (at most the grace) for the result and
* child quiescence, then terminates the worker unconditionally — the
* thread never outlives its run — and reaps whatever children remain
* (their disposal is contained, not awaited past the grace, the same
* abandonment the seam documents for a slow-disposing child). Idempotent;
* safe on every path.
* @returns resolves when the run's resources are released or abandoned.
*/
dispose(): Promise<void> {
this.disposed ??= (async () => {
this.cancel('workflow disposed')
for (const [callId, run] of [...this.children]) void this.disposeChild(callId, run)
await Promise.race([
(async () => {
await this.result
await this.childQuiescence()
})(),
sleep(this.disposeGraceMs),
])
await this.worker.terminate()
this.reapChildren('workflow disposed')
})()
return this.disposed
}
/** Post one message to the worker (payload looked up from the tag's map entry), tolerating a thread that is already gone. */
private post<T extends HostToWorkerType>(type: T, payload: HostToWorkerPayloads[T]): void {
if (this.workerGone) return
try {
this.worker.postMessage({ type, ...payload })
} catch (error: unknown) {
// Only a teardown race can land here (every engine message is JSON
// data, so serialization cannot fail); there is nothing left to
// deliver to — log and move on.
/* v8 ignore next -- postMessage teardown race (a throw between exit and its event): not constructible in-process */
this.ctx.logger.warn(`workflow-workerthread: 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.liveAgents.set(message.info.seq, message.info)
this.observer.agentStart(message.info)
break
case WorkerToHostType.AgentEnd:
// NOT suppressed on cancel: cancelled children report their paired
// agent-end with outcome 'cancelled'. The gate (with the termination
// paths' synthesis) is what makes the one-pair-per-started-child
// contract hold on every stop path.
this.endAgent(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)
if (run === undefined) {
// Already disposed host-side (a dispose() drive or a death reap beat
// the RPC) — the ack is still owed (the worker-side wrapper awaits it).
this.post(HostToWorkerType.ChildDisposed, { callId })
return
}
// disposeChild never rejects (containment is inside), so the ack always follows.
void this.disposeChild(callId, run).then(() => { this.post(HostToWorkerType.ChildDisposed, { callId }) })
}
/**
* Start (or join) one registered child's disposal; the registry entry
* leaves when it settles. Memoized per callId: the worker's dispose RPC,
* the dispose() host drive, and the reap can all land on the same child —
* the child's `dispose()` runs once and every caller awaits that one
* settlement. A rejection is contained (the subagent seam's dispose() is
* not supposed to reject, but a backend that does anyway must not break
* quiescence): logged, and the child still leaves the registry.
* @param callId - the child's registry key.
* @param run - the registered child (the caller looked it up).
* @returns resolves when the disposal settled either way; never rejects.
*/
private disposeChild(callId: number, run: SubagentRun): Promise<void> {
let disposal = this.childDisposals.get(callId)
if (disposal === undefined) {
disposal = run.dispose().then(
() => { this.finishChild(callId) },
(error: unknown) => {
this.ctx.logger.warn(`workflow-workerthread: child dispose failed: ${renderThrown(error)}`)
this.finishChild(callId)
},
)
this.childDisposals.set(callId, disposal)
}
return disposal
}
/** Drop a child from the registry (and its disposal memo), releasing quiescence waiters at zero. */
private finishChild(callId: number): void {
this.children.delete(callId)
this.childDisposals.delete(callId)
if (this.children.size === 0) {
for (const waiter of this.quiescenceWaiters.splice(0)) waiter()
}
}
/** Resolves once the child registry is empty (every disposal settled). */
private childQuiescence(): Promise<void> {
if (this.children.size === 0) return Promise.resolve()
return new Promise((resolve) => { this.quiescenceWaiters.push(resolve) })
}
/** Abort + dispose every registered child (worker death / final teardown); disposal is contained, not awaited. */
private reapChildren(reason: string): void {
this.controller.abort(this.cancelReason ?? reason)
for (const [callId, run] of [...this.children]) {
run.cancel(this.cancelReason ?? reason)
void this.disposeChild(callId, run)
}
}
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')
// The thread is gone: no more worker-authored agent-ends can arrive —
// pair every stranded start (a start that crossed between the grace
// force-settle and this exit included) before the run settles.
this.endStrandedAgents()
// 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 })
}
/**
* The single agent-end emission gate: forwards `end` iff its start is still
* unpaired in the ledger, so every forwarded `workflow/agent-start` gets
* EXACTLY one `workflow/agent-end` — the worker's own report where it can
* speak, a host-synthesized one where it cannot ({@link endStrandedAgents}).
* @param end - the settlement to emit (worker-reported or synthesized).
*/
private endAgent(end: WorkflowAgentEndInfo): void {
/* v8 ignore next -- a real end still in flight across the grace force-settle: not orderable in-process */
if (!this.liveAgents.delete(end.seq)) return
this.observer.agentEnd(end)
}
/**
* Synthesize the missing `agent-end` for every started-but-unpaired agent,
* outcome `'cancelled'`: the reap cancels every child, and a real
* settlement racing the force-settle loses to the cancellation — the same
* first-wins override {@link onResult} applies to the run's own result.
* Called where the worker can no longer speak (the grace force-settle,
* worker death), BEFORE settleResult, so the paired ends reach observers
* before `workflow/end`.
*/
private endStrandedAgents(): void {
for (const info of [...this.liveAgents.values()]) {
this.endAgent({ ...info, outcome: 'cancelled' })
}
}
private cancelledResult(agentsStarted: number): WorkflowResult {
// cancel() is the only writer of cancelReason and every caller checks it
// first; the fallback guards the type, not a reachable path.
/* v8 ignore next */
const reason = this.cancelReason ?? 'workflow cancelled'
return { value: null, stopReason: 'cancelled', error: `workflow run cancelled: ${reason}`, agentsStarted }
}
/** First settle wins; disarms the grace timer. */
private settleResult(result: WorkflowResult): void {
if (this.settled) return
this.settled = true
clearTimeout(this.graceTimer)
this.settleResolve(result)
}
}
/** A plain timer sleep (the dispose grace); unref'd so it never holds the process open. */
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(resolve, ms)
timer.unref()
})
}
@@ -0,0 +1,203 @@
/**
* 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. 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:
*
* - `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).
*
* 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`).
*
* @module @deepseek-ai/dsh-workflow-workerthread
*/
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, { WorkflowError, WorkflowRunId } from '@deepseek-ai/dsh-workflow'
import type { WorkflowRun, WorkflowRunInfo, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow'
import { WorkerRun } from './host.ts'
import { validateMeta } from './meta.ts'
import type { WorkerInit, WorkerLimits } from './types.ts'
export { validateMeta } 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 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 {
/** The `ctx.subagents` provider children run on (default `spawn`). */
provider?: string
/** Concurrent `agent()` ceiling; `0` (the default) auto-resolves to `min(16, max(1, cores - 2))`. */
maxConcurrentAgents?: number
/** Total `agent()` calls one run may start — the runaway-loop backstop (default 1000). */
maxTotalAgents?: number
/** Items accepted by a single `parallel()`/`pipeline()` call (default 4096). */
maxItemsPerCall?: number
/** vm timeout for the script's initial synchronous slice, inside the worker (default 5000 ms). */
syncTimeoutMs?: number
/**
* How long after a cancellation an unsettled script may keep running before
* the run force-settles `cancelled` and its worker is TERMINATED (default
* 5000 ms); also bounds `dispose()`.
*/
disposeGraceMs?: number
}
type ResolvedConfig = Required<Config>
/** A body that still carries the Claude Code-style meta header (meta rides the seam as data here). */
const META_STATEMENT = /^\s*export\s+const\s+meta\b/
/**
* 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. A body
* opening with `export const meta` gets a pointed message instead of the
* wrapper's bare SyntaxError — the model's likeliest authoring slip.
*/
function assertBodyParses(body: string, name: string): void {
if (META_STATEMENT.test(body)) {
throw new WorkflowError('workflow meta rides the `meta` request field, not the script: remove the `export const meta = {...}` statement from the body', 'SCRIPT_PARSE')
}
try {
// Parse only — the script object is discarded, nothing executes.
void new vm.Script(`(async () => {\n${body}\n})()`, { filename: `workflow:${name}`, lineOffset: -1 })
} catch (error: unknown) {
throw new WorkflowError(`workflow script does not parse: ${String(error)}`, 'SCRIPT_PARSE', { cause: error })
}
}
/**
* The worker-thread engine service. `start()` validates the script up front
* (meta + a host-side body parse) and returns a {@link WorkflowRun} whose
* `result` never rejects; the `workflow/*` events fire around the run per
* the seam contract.
*/
export class WorkerWorkflowEngine extends WorkflowService {
static inject = ['subagents']
static Config: z<Config> = z.object({
provider: z.string().default('spawn'),
maxConcurrentAgents: z.natural().default(0),
maxTotalAgents: z.natural().min(1).default(1000),
maxItemsPerCall: z.natural().min(1).default(4096),
syncTimeoutMs: z.natural().min(1).default(5000),
disposeGraceMs: z.natural().default(5000),
})
private readonly config: ResolvedConfig
constructor(ctx: Context, config: Config) {
super(ctx)
// schemastery (static Config) has already filled the defaulted fields;
// the assertion records that resolution, not a hidden fallback.
this.config = config as ResolvedConfig
}
/**
* Validate and execute a workflow script in a fresh worker thread. Throws
* {@link WorkflowError} synchronously (`META_INVALID` for a malformed meta
* block, `SCRIPT_PARSE` for a body that does not compile) for a request
* that cannot begin; once a run is returned, every failure resolves through
* `result.stopReason` instead.
* @param request - the script body, its meta data and `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 = validateMeta(request.meta)
assertBodyParses(request.script, 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: 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,
}
const init: WorkerInit = {
meta,
body: request.script,
...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)
// `workflow/end` fires as the (never-rejecting) result settles, with the
// outcome DATA only — the value stays with the run's holder.
void workerRun.result.then((settled) => {
this.emitWorkflowEvent('workflow/end', info, {
stopReason: settled.stopReason,
...settled.error !== undefined ? { error: settled.error } : {},
agentsStarted: settled.agentsStarted,
})
})
return workerRun
}
}
export default WorkerWorkflowEngine
@@ -0,0 +1,85 @@
/**
* Meta validation: check the caller-provided {@link WorkflowMeta} DATA against
* the shape contract and reject everything else loud, every violation named.
* Meta arrives as plain JSON through the seam (the model-facing tool carries
* it as a schema-validated object parameter) — the engine never evaluates
* script text to obtain it, so no script-controlled code can run on the host
* here (an evaluated meta literal could smuggle getters that spin the host
* outside any vm timeout, the exact escape the worker thread exists to
* prevent).
*
* @module @deepseek-ai/dsh-workflow-workerthread/meta
*/
import { WorkflowError } from '@deepseek-ai/dsh-workflow'
import type { WorkflowMeta, WorkflowPhase } from '@deepseek-ai/dsh-workflow'
/** Collect shape violations for a meta value (plain JSON data by the seam contract). */
function validateMetaShape(meta: unknown): { meta?: WorkflowMeta; violations: string[] } {
const violations: string[] = []
if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) {
return { violations: ['meta must be an object'] }
}
const record = meta as Record<string, unknown>
const known = new Set(['name', 'description', 'whenToUse', 'phases'])
for (const key of Object.keys(record)) {
if (!known.has(key)) violations.push(`meta.${key} is not a recognized field (name/description/whenToUse/phases)`)
}
if (typeof record.name !== 'string' || record.name.length === 0) violations.push('meta.name must be a non-empty string')
if (typeof record.description !== 'string' || record.description.length === 0) violations.push('meta.description must be a non-empty string')
if (record.whenToUse !== undefined && typeof record.whenToUse !== 'string') violations.push('meta.whenToUse must be a string')
const phases: WorkflowPhase[] = []
if (record.phases !== undefined) {
if (!Array.isArray(record.phases)) {
violations.push('meta.phases must be an array')
} else {
record.phases.forEach((phase, index) => {
if (typeof phase !== 'object' || phase === null || Array.isArray(phase)) {
violations.push(`meta.phases[${index}] must be an object`)
return
}
const entry = phase as Record<string, unknown>
for (const key of Object.keys(entry)) {
if (!['title', 'detail', 'model'].includes(key)) violations.push(`meta.phases[${index}].${key} is not a recognized field`)
}
if (typeof entry.title !== 'string' || entry.title.length === 0) violations.push(`meta.phases[${index}].title must be a non-empty string`)
if (entry.detail !== undefined && typeof entry.detail !== 'string') violations.push(`meta.phases[${index}].detail must be a string`)
if (entry.model !== undefined && typeof entry.model !== 'string') violations.push(`meta.phases[${index}].model must be a string`)
if (violations.length === 0) {
phases.push({
title: entry.title as string,
...entry.detail !== undefined ? { detail: entry.detail as string } : {},
...entry.model !== undefined ? { model: entry.model as string } : {},
})
}
})
}
}
if (violations.length > 0) return { violations }
return {
violations,
meta: {
name: record.name as string,
description: record.description as string,
...record.whenToUse !== undefined ? { whenToUse: record.whenToUse as string } : {},
...record.phases !== undefined ? { phases } : {},
},
}
}
/**
* Validate a caller-provided meta value against the {@link WorkflowMeta}
* contract. Throws `META_INVALID` naming every violation (unknown fields,
* missing/mistyped `name`/`description`, malformed `phases`); the returned
* meta is a NORMALIZED copy built from the validated fields, so the engine
* never aliases the caller's object.
* @param value - the meta data from the start request (plain JSON by the seam contract).
* @returns the validated, normalized meta block.
*/
export function validateMeta(value: unknown): WorkflowMeta {
const { meta, violations } = validateMetaShape(value)
if (meta === undefined) {
throw new WorkflowError(`invalid meta: ${violations.join('; ')}`, 'META_INVALID')
}
return meta
}
@@ -0,0 +1,114 @@
/**
* 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-workerthread/protocol
*/
import type { WorkflowAgentEndInfo, WorkflowAgentInfo, WorkflowResult } from '@deepseek-ai/dsh-workflow'
import type { ChildResult, ChildStartRequest } from './types.ts'
/** Message tags the worker sends the host (the wire values are the tag strings). */
export enum WorkerToHostType {
/** The startup handshake: the session is listening and awaits {@link HostToWorkerType.Go}. */
Ready = 'ready',
/** Observer narration: a `phase(title)` call. */
Phase = 'phase',
/** Observer narration: a `log(message)` call. */
Log = 'log',
/** Observer lifecycle: one `agent()` call started a child. */
AgentStart = 'agent-start',
/** Observer lifecycle: one `agent()` call settled. */
AgentEnd = 'agent-end',
/** Child RPC: start a child on the host (answered by ChildStarted or ChildStartError). */
ChildStart = 'child-start',
/** Child RPC: cancel a started child (fire-and-forget). */
ChildCancel = 'child-cancel',
/** Child RPC: dispose a started child (answered by ChildDisposed). */
ChildDispose = 'child-dispose',
/** The run's single terminal result. */
Result = 'result',
}
/** The payload each worker→host tag carries. */
export interface WorkerToHostPayloads {
/** Ready carries nothing. */
[WorkerToHostType.Ready]: Record<never, never>
/** The phase title, verbatim. */
[WorkerToHostType.Phase]: { title: string }
/** The logged message, verbatim. */
[WorkerToHostType.Log]: { message: string }
/** The call's sequence number, label, phase, and child id. */
[WorkerToHostType.AgentStart]: { info: WorkflowAgentInfo }
/** The call identity plus its outcome. */
[WorkerToHostType.AgentEnd]: { info: WorkflowAgentEndInfo }
/** The RPC correlation id and the prompt plus validated options. */
[WorkerToHostType.ChildStart]: { callId: number; request: ChildStartRequest }
/** The RPC correlation id and the cancel reason (undefined = unspecified). */
[WorkerToHostType.ChildCancel]: { callId: number; reason: string | undefined }
/** The RPC correlation id of the child to dispose. */
[WorkerToHostType.ChildDispose]: { callId: number }
/** The run's terminal outcome. */
[WorkerToHostType.Result]: { result: WorkflowResult }
}
/** Message tags the host sends the worker (the wire values are the tag strings). */
export enum HostToWorkerType {
/** Releases the startup gate: run the script body. */
Go = 'go',
/** Cancel the run: hooks start throwing and the script dies at its next await. */
Cancel = 'cancel',
/** Child RPC reply: the start succeeded (exactly one of ChildStarted/ChildStartError per ChildStart). */
ChildStarted = 'child-started',
/** Child RPC reply: the start was refused or threw. */
ChildStartError = 'child-start-error',
/** Child RPC: a started child's result RESOLVED (its JSON projection). */
ChildSettled = 'child-settled',
/** Child RPC: a started child's result REJECTED (an infrastructure fault, rendered). */
ChildFailed = 'child-failed',
/** Child RPC reply: a requested disposal completed. */
ChildDisposed = 'child-disposed',
}
/** The payload each host→worker tag carries. */
export interface HostToWorkerPayloads {
/** Go carries nothing. */
[HostToWorkerType.Go]: Record<never, never>
/** The cancel reason, canonical for the whole run. */
[HostToWorkerType.Cancel]: { reason: string }
/** The RPC correlation id and the child agent's id (minted by the subagent seam). */
[HostToWorkerType.ChildStarted]: { callId: number; childId: string }
/** The RPC correlation id and the rendered start failure. */
[HostToWorkerType.ChildStartError]: { callId: number; rendered: string }
/** The RPC correlation id and the child's terminal result projection. */
[HostToWorkerType.ChildSettled]: { callId: number; result: ChildResult }
/** The RPC correlation id and the rendered infrastructure fault. */
[HostToWorkerType.ChildFailed]: { callId: number; rendered: string }
/** The RPC correlation id of the completed disposal. */
[HostToWorkerType.ChildDisposed]: { callId: number }
}
/**
* One worker→host message of tag `T`; unparameterized, the closed union over
* every tag (a discriminated union — `switch` on `type` narrows).
*/
export type WorkerToHostMessage<T extends WorkerToHostType = WorkerToHostType> =
{ [K in T]: { type: K } & WorkerToHostPayloads[K] }[T]
/**
* One host→worker message of tag `T`; unparameterized, the closed union over
* every tag (a discriminated union — `switch` on `type` narrows).
*/
export type HostToWorkerMessage<T extends HostToWorkerType = HostToWorkerType> =
{ [K in T]: { type: K } & HostToWorkerPayloads[K] }[T]
@@ -0,0 +1,174 @@
/**
* 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
* this boundary guards against BUGGY scripts, not hostile ones. It rejects
* loud what JSON would silently mangle — functions, symbols, bigints,
* non-finite numbers, nested `undefined`, cycles, sparse arrays, exotic
* prototypes — because accepted-then-ignored is this repo's banned failure
* mode. It does NOT defend against adversarial values: the walk reads
* 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 — 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 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-workerthread/realm
*/
/** Thrown by {@link materializeFromRealm}; the caller wraps it into the right `WorkflowError` code. */
export class MaterializeError extends Error {
constructor(public readonly path: string, public readonly reason: string) {
super(`${path}: ${reason}`)
this.name = 'MaterializeError'
}
}
/**
* Render a thrown value to failure text without ever throwing: prefer the
* `stack` (host or realm — a realm error's `stack` is a plain string read),
* fall back to `message`, then `String()`. Reading those properties MAY run
* script code (a getter, `toString`) — accepted under the module's trust
* premise; if that code itself throws, a fixed label is returned instead.
* @param error - the thrown value, of any shape and any realm.
* @returns human-readable text for the failure report; prefers the stack.
*/
export function renderThrown(error: unknown): string {
try {
const stack = (error as { stack?: unknown } | null | undefined)?.stack
if (typeof stack === 'string' && stack.length > 0) return stack
const message = (error as { message?: unknown } | null | undefined)?.message
if (typeof message === 'string' && message.length > 0) return message
return String(error)
} catch {
// A throwing accessor/toString on the thrown value — rendering must be
// total (drive()'s never-reject contract), so fall back to a fixed label.
return '[unrenderable thrown value]'
}
}
/**
* Whether an object's prototype chain is data-shaped: `null`, or a prototype
* whose own prototype is `null` (the realm's `Object.prototype` — which we
* cannot compare by identity across realms). A `Date`/`Map`/class instance
* has a longer chain and is rejected.
*/
function hasPlainPrototype(value: object): boolean {
const proto: unknown = Object.getPrototypeOf(value)
if (proto === null) return true
return Object.getPrototypeOf(proto) === null
}
/**
* Copy `value` (typically from the vm realm) into plain host JSON data.
* Throws {@link MaterializeError} naming the offending path for anything JSON
* cannot carry losslessly. Properties are read ordinarily — a getter runs and
* its RESULT is materialized; a read that throws surfaces as a
* {@link MaterializeError} carrying the rendered failure. `undefined` is
* accepted only at the ROOT (a script with no `return` value) — the caller
* decides what it means; an `undefined` nested INSIDE a container is a
* violation.
* @param value - the realm value to materialize.
* @param root - the path label for the root value (error messages).
* @returns the host-realm copy (plain objects/arrays/scalars only).
*/
export function materializeFromRealm(value: unknown, root = 'value'): unknown {
if (value === undefined) return undefined
try {
return materialize(value, root, new Set())
} catch (error: unknown) {
if (error instanceof MaterializeError) throw error
// A property read ran script code that threw; total-ize it so callers can
// keep the narrow MaterializeError contract.
throw new MaterializeError(root, `reading the value threw: ${renderThrown(error)}`)
}
}
function materialize(value: unknown, path: string, seen: Set<object>): unknown {
switch (typeof value) {
case 'boolean':
case 'string':
return value
case 'number': {
if (!Number.isFinite(value)) throw new MaterializeError(path, 'non-finite numbers are not JSON data')
return value
}
case 'bigint':
throw new MaterializeError(path, 'bigints are not JSON data')
case 'function':
throw new MaterializeError(path, 'functions cannot cross the workflow value boundary')
case 'symbol':
throw new MaterializeError(path, 'symbols cannot cross the workflow value boundary')
case 'undefined':
throw new MaterializeError(path, 'undefined is not JSON data')
case 'object':
break
}
if (value === null) return null
const objectValue: object = value
if (seen.has(objectValue)) throw new MaterializeError(path, 'circular references are not JSON data')
seen.add(objectValue)
try {
if (Array.isArray(objectValue)) return materializeArray(objectValue, path, seen)
return materializeObject(objectValue, path, seen)
} finally {
seen.delete(objectValue)
}
}
function materializeArray(value: unknown[], path: string, seen: Set<object>): unknown[] {
const out: unknown[] = []
for (let index = 0; index < value.length; index++) {
if (!(index in value)) throw new MaterializeError(`${path}[${index}]`, 'sparse arrays are not JSON data')
out.push(materialize(value[index], `${path}[${index}]`, seen))
}
// Own enumerable props beyond the indices (e.g. `arr.total = 3`) would be
// silently dropped by JSON — reject them instead.
for (const key of Object.keys(value)) {
const index = Number(key)
if (!Number.isInteger(index) || index < 0 || index >= value.length) {
throw new MaterializeError(`${path}.${key}`, 'arrays with non-index properties are not JSON data')
}
}
if (Object.getOwnPropertySymbols(value).length > 0) {
throw new MaterializeError(path, 'symbol-keyed properties cannot cross the workflow value boundary')
}
return out
}
function materializeObject(value: object, path: string, seen: Set<object>): Record<string, unknown> {
if (!hasPlainPrototype(value)) {
throw new MaterializeError(path, 'only plain objects and arrays are JSON data (exotic prototype)')
}
if (Object.getOwnPropertySymbols(value).length > 0) {
throw new MaterializeError(path, 'symbol-keyed properties cannot cross the workflow value boundary')
}
const out: Record<string, unknown> = {}
// Object.keys = own enumerable string keys, matching JSON.stringify's
// property selection exactly (non-enumerable props never reach JSON output).
for (const key of Object.keys(value)) {
// defineProperty, never assignment: a "__proto__" key must become an OWN
// data property of the copy, not a prototype mutation.
Object.defineProperty(out, key, {
value: materialize((value as Record<string, unknown>)[key], `${path}.${key}`, seen),
enumerable: true,
writable: true,
configurable: true,
})
}
return out
}
@@ -0,0 +1,522 @@
/**
* 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
* 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, 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-workerthread/runtime
*/
import * as vm from 'node:vm'
import { AgentId } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { assertSupportedOutputSchema, OutputSchemaError } from '@deepseek-ai/dsh-tools'
import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
import { isFatalWorkflowError, WorkflowError } from '@deepseek-ai/dsh-workflow'
import type {
WorkflowAgentEndInfo,
WorkflowAgentInfo,
WorkflowMeta,
WorkflowResult,
} from '@deepseek-ai/dsh-workflow'
import { materializeFromRealm, MaterializeError, renderThrown } from './realm.ts'
import type { ChildHandle, ChildPort, WorkerLimits } from './types.ts'
/** 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
agentStart(info: WorkflowAgentInfo): void
agentEnd(info: WorkflowAgentEndInfo): void
}
/** The `agent()` options the script may pass; everything else rejects loud. */
const SUPPORTED_AGENT_OPTIONS = new Set(['label', 'phase', 'schema', 'model'])
/** Deferred Claude Code options we name explicitly in the rejection message. */
const DEFERRED_AGENT_OPTIONS = new Set(['effort', 'isolation', 'agentType'])
/** Flatten a child's final output blocks to text (the non-schema `agent()` result). */
function outputText(blocks: ContentBlock[]): string {
return blocks
.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
.map(block => block.text)
.join('')
}
/** A short display label derived from the prompt when the script passes none. */
function defaultLabel(prompt: string): string {
const newline = prompt.indexOf('\n')
const line = newline === -1 ? prompt : prompt.slice(0, newline)
return line.length <= 48 ? line : `${line.slice(0, 47)}`
}
/**
* 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). */
private started = 0
private activeSlots = 0
private readonly slotWaiters: { resolve(): void; reject(error: unknown): void }[] = []
private cancelReason: string | undefined
private cancelError: WorkflowError | undefined
private readonly controller = new AbortController()
private currentPhase: string | undefined
private readonly context: vm.Context
private readonly compiled: vm.Script
constructor(
meta: WorkflowMeta,
body: string,
args: unknown,
private readonly limits: WorkerLimits,
private readonly observer: ExecutionObserver,
private readonly children: ChildPort,
) {
// Compile FIRST: a body syntax error must throw out of the constructor
// 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.
try {
this.compiled = new vm.Script(`(async () => {\n${body}\n})()`, {
filename: `workflow:${meta.name}`,
lineOffset: -1,
})
} catch (error: unknown) {
throw new WorkflowError(`workflow script does not parse: ${String(error)}`, 'SCRIPT_PARSE', { cause: error })
}
this.context = vm.createContext({}, { name: `workflow:${meta.name}` })
const globals: Record<string, unknown> = {
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) },
// 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)) {
// Data properties on the contextified global; frozen shape not required —
// a script overwriting its own hooks only sabotages itself.
;(this.context as Record<string, unknown>)[key] = typeof value === 'function' ? Object.freeze(value) : value
}
}
/**
* Whether the run has been cancelled. A METHOD, not an inline property
* 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 {
return this.cancelReason !== undefined
}
/**
* Shared hook entry guard: after {@link cancel}, EVERY hook throws
* `CANCELLED` at its next call — cancellation is the next HOOK boundary,
* not just the next `agent()`, so a script that caught one cancelled
* rejection cannot keep emitting progress through `phase`/`log` or enter a
* combinator.
*/
private throwIfCancelled(): void {
if (this.isCancelled()) throw this.cancelledError()
}
/**
* 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 cancel RPCs. Required: every caller (the session's cancel
* message, drive()'s settle-reap) has a concrete reason.
*/
cancel(reason: string): void {
if (this.cancelReason !== undefined) return
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())
}
/**
* 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. After settlement, any stray children a script fired without
* awaiting are cancelled (their `agent()` wrappers dispose them via RPC).
* @returns the settled outcome — this promise NEVER rejects (the seam's
* `result`-never-rejects contract); every failure maps to a variant.
*/
async drive(): Promise<WorkflowResult> {
try {
// Cancelled before the body ever ran (an already-aborted start signal,
// relayed by the host before its `go`): the script must not execute at
// all, let alone report `completed`.
if (this.isCancelled()) throw this.cancelledError()
const scriptPromise = this.compiled.runInContext(this.context, { timeout: this.limits.syncTimeoutMs }) as Promise<unknown>
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.
if (this.isCancelled()) throw this.cancelledError()
const value = raw === undefined ? null : this.materializeResult(raw)
return { value, stopReason: 'completed', agentsStarted: this.started }
} catch (error: unknown) {
// Any failure after cancel() reports `cancelled` with the canonical
// reason — the reject path mirrors the resolve path's post-settle check.
if (this.isCancelled()) {
return { value: null, stopReason: 'cancelled', error: this.cancelledError().message, agentsStarted: this.started }
}
// 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 — 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')
}
}
/**
* 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 (which would kill the worker thread); if
* the script does await it, it still observes the rejection.
*/
private contain<T>(promise: Promise<T>): Promise<T> {
promise.catch(() => { /* consumed: see method contract — a dropped hook promise must not surface an unhandled rejection */ })
return promise
}
private cancelledError(): WorkflowError {
// cancel() arms cancelError before any caller can observe isCancelled()
// === true; the fallback guards the type, not a reachable path.
/* v8 ignore next */
return this.cancelError ?? new WorkflowError('workflow run cancelled', 'CANCELLED')
}
/** Materialize the script's return value; violations become RESULT_UNSERIALIZABLE. */
private materializeResult(raw: unknown): unknown {
try {
return materializeFromRealm(raw, 'workflow result')
} catch (error: unknown) {
/* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */
if (!(error instanceof MaterializeError)) throw error
throw new WorkflowError(
`the workflow's return value is not plain JSON data — ${error.message}. Return only JSON-serializable objects/arrays/scalars.`,
'RESULT_UNSERIALIZABLE',
{ cause: error },
)
}
}
/**
* Acquire one concurrency slot (FIFO). Cancellation rejects QUEUED waiters
* (see {@link cancel}); the callers guard their own entry and post-acquire
* windows, so no cancelled-precheck is duplicated here.
*/
private acquireSlot(): Promise<void> {
if (this.activeSlots < this.limits.maxConcurrentAgents) {
this.activeSlots += 1
return Promise.resolve()
}
return new Promise<void>((resolve, reject) => {
this.slotWaiters.push({
resolve: () => {
this.activeSlots += 1
resolve()
},
reject,
})
})
}
private releaseSlot(): void {
this.activeSlots -= 1
const next = this.slotWaiters.shift()
if (next) next.resolve()
}
/** The `agent(prompt, opts)` hook. */
private async agent(rawPrompt: unknown, rawOpts: unknown): Promise<unknown> {
this.throwIfCancelled()
if (typeof rawPrompt !== 'string' || rawPrompt.length === 0) {
throw new WorkflowError('agent() requires a non-empty prompt string', 'INVALID_ARGUMENT')
}
const opts = this.readAgentOptions(rawOpts)
if (this.started >= this.limits.maxTotalAgents) {
throw new WorkflowError(
`this run reached its total agent cap (${this.limits.maxTotalAgents}) — a runaway-loop backstop; raise maxTotalAgents in the engine config if the scale is intentional`,
'AGENT_CAP',
)
}
this.started += 1
const seq = this.started
const label = opts.label ?? defaultLabel(rawPrompt)
const phase = opts.phase ?? this.currentPhase
await this.acquireSlot()
try {
// 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
// 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: ChildHandle
try {
run = await this.children.startAgent({
prompt: rawPrompt,
...opts.schema !== undefined ? { schema: opts.schema } : {},
...opts.model !== undefined ? { model: opts.model } : {},
})
} catch (error: unknown) {
// 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 })
}
// 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 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 {
let result
try {
result = await run.result
} catch (error: unknown) {
// 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.
if (this.isCancelled()) {
this.observer.agentEnd({ ...info, outcome: 'cancelled' })
throw this.cancelledError()
}
this.observer.agentEnd({ ...info, outcome: 'failed' })
throw new WorkflowError(`child agent run failed: ${renderThrown(error)}`, 'AGENT_RESULT', { cause: error })
}
if (result.stopReason === 'completed') {
if (opts.schema !== undefined) {
// The provider honored outputSchema (capability-gated at start), so
// a completed run without a structured value is a child failure.
if (result.structured === undefined) {
this.observer.agentEnd({ ...info, outcome: 'failed' })
return null
}
this.observer.agentEnd({ ...info, outcome: 'completed' })
return result.structured
}
this.observer.agentEnd({ ...info, outcome: 'completed' })
return outputText(result.output)
}
// A cancelled RUN kills the script; a child that failed for its own
// reasons resolves null (scripts .filter(Boolean) per the CC contract).
if (this.isCancelled()) {
this.observer.agentEnd({ ...info, outcome: 'cancelled' })
throw this.cancelledError()
}
this.observer.agentEnd({ ...info, outcome: 'failed' })
return null
} finally {
this.controller.signal.removeEventListener('abort', onAbort)
await run.dispose()
}
} finally {
this.releaseSlot()
}
}
/** Materialize + validate the `agent()` options bag from the realm. */
private readAgentOptions(rawOpts: unknown): { label?: string; phase?: string; model?: string; schema?: StructuredOutputSchema } {
if (rawOpts === undefined) return {}
let opts: unknown
try {
opts = materializeFromRealm(rawOpts, 'agent() options')
} catch (error: unknown) {
/* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */
if (!(error instanceof MaterializeError)) throw error
throw new WorkflowError(`agent() options must be plain JSON data — ${error.message}`, 'INVALID_ARGUMENT', { cause: error })
}
if (typeof opts !== 'object' || opts === null || Array.isArray(opts)) {
throw new WorkflowError('agent() options must be an object', 'INVALID_ARGUMENT')
}
const record = opts as Record<string, unknown>
for (const key of Object.keys(record)) {
if (SUPPORTED_AGENT_OPTIONS.has(key)) continue
if (DEFERRED_AGENT_OPTIONS.has(key)) {
throw new WorkflowError(`agent() option "${key}" is deferred and not supported by this engine (supported: label, phase, schema, model)`, 'UNSUPPORTED_OPTION')
}
throw new WorkflowError(`agent() option "${key}" is not recognized (supported: label, phase, schema, model)`, 'UNSUPPORTED_OPTION')
}
for (const key of ['label', 'phase', 'model'] as const) {
if (record[key] !== undefined && typeof record[key] !== 'string') {
throw new WorkflowError(`agent() option "${key}" must be a string`, 'INVALID_ARGUMENT')
}
}
let schema: StructuredOutputSchema | undefined
if (record.schema !== undefined) {
try {
assertSupportedOutputSchema(record.schema)
schema = record.schema
} catch (error: unknown) {
/* v8 ignore next -- defensive rethrow arm: assertSupportedOutputSchema only throws OutputSchemaError */
if (!(error instanceof OutputSchemaError)) throw error
throw new WorkflowError(`agent() schema is outside the supported subset — ${error.message}`, 'UNSUPPORTED_SCHEMA', { cause: error })
}
}
return {
...record.label !== undefined ? { label: record.label as string } : {},
...record.phase !== undefined ? { phase: record.phase as string } : {},
...record.model !== undefined ? { model: record.model as string } : {},
...schema !== undefined ? { schema } : {},
}
}
/** The `parallel(thunks)` hook: each thunk caught → `null`; fatal errors propagate. */
private async parallel(rawThunks: unknown): Promise<unknown[]> {
this.throwIfCancelled()
if (!Array.isArray(rawThunks)) {
throw new WorkflowError('parallel() requires an array of zero-argument functions', 'INVALID_ARGUMENT')
}
this.assertItemCap(rawThunks.length, 'parallel()')
const thunks = rawThunks.map((thunk, index) => {
if (typeof thunk !== 'function') {
throw new WorkflowError(`parallel() item ${index} is not a function`, 'INVALID_ARGUMENT')
}
return thunk as () => unknown
})
return Promise.all(thunks.map(async (thunk) => {
try {
return await thunk()
} catch (error: unknown) {
// 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
}
}))
}
/** The `pipeline(items, ...stages)` hook: per-item stage chains, NO cross-stage barrier. */
private async pipeline(rawItems: unknown, rawStages: unknown[]): Promise<unknown[]> {
this.throwIfCancelled()
if (!Array.isArray(rawItems)) {
throw new WorkflowError('pipeline() requires an items array', 'INVALID_ARGUMENT')
}
this.assertItemCap(rawItems.length, 'pipeline()')
if (rawStages.length === 0) {
throw new WorkflowError('pipeline() requires at least one stage function', 'INVALID_ARGUMENT')
}
const stages = rawStages.map((stage, index) => {
if (typeof stage !== 'function') {
throw new WorkflowError(`pipeline() stage ${index} is not a function`, 'INVALID_ARGUMENT')
}
return stage as (previous: unknown, item: unknown, index: number) => unknown
})
return Promise.all(rawItems.map(async (item: unknown, index) => {
let value: unknown = item
try {
for (const stage of stages) {
value = await stage(value, item, index)
}
return value
} catch (error: unknown) {
// An ordinary stage throw drops the ITEM to null and skips its
// remaining stages; a fatal WorkflowError (see parallel()) kills the
// whole script.
if (isFatalWorkflowError(error)) throw error
return null
}
}))
}
private assertItemCap(length: number, hook: string): void {
if (length > this.limits.maxItemsPerCall) {
throw new WorkflowError(
`${hook} received ${length} items — over the per-call cap (${this.limits.maxItemsPerCall}); split the work or raise maxItemsPerCall in the engine config`,
'ITEM_CAP',
)
}
}
/** The `phase(title)` hook: sets the current label for subsequent `agent()` calls and notifies observers. */
private phase(title: unknown): void {
this.throwIfCancelled()
if (typeof title !== 'string' || title.length === 0) {
throw new WorkflowError('phase() requires a non-empty title string', 'INVALID_ARGUMENT')
}
this.currentPhase = title
this.observer.phase(title)
}
/** The `log(message)` hook: narration to observers. */
private log(message: unknown): void {
this.throwIfCancelled()
if (typeof message !== 'string') {
throw new WorkflowError('log() requires a message string', 'INVALID_ARGUMENT')
}
this.observer.log(message)
}
}
@@ -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-workerthread/session
*/
import type { MessagePort } from 'node:worker_threads'
import { assertNever } from '@deepseek-ai/dsh-llm'
import { HostToWorkerType, WorkerToHostType } from './protocol.ts'
import type { HostToWorkerMessage, WorkerToHostPayloads } from './protocol.ts'
import { renderThrown } from './realm.ts'
import { WorkflowExecution } from './runtime.ts'
import type { ExecutionObserver } from './runtime.ts'
import type {
ChildHandle,
ChildPort,
ChildResult,
ChildStartRequest,
WorkerInit,
} from './types.ts'
/** The book-keeping for one in-flight child RPC (keyed by callId). */
interface PendingChild {
started: PromiseWithResolvers<string>
settled: PromiseWithResolvers<ChildResult>
disposed: PromiseWithResolvers<void>
}
/** The typed post half of the port: each tag pairs with ITS payload from the map (a mismatch is a compile error at the call site). */
type Post = <T extends WorkerToHostType>(type: T, payload: WorkerToHostPayloads[T]) => void
/**
* The worker-side handle for one started child agent ({@link ChildHandle}):
* every member is an RPC to the host keyed by this call's `callId`, resolved
* by the session's message handler through the bridge's pending entry.
*/
class RpcChildHandle implements ChildHandle {
readonly result: Promise<ChildResult>
constructor(
private readonly post: Post,
private readonly callId: number,
private readonly entry: PendingChild,
readonly id: string,
) {
this.result = entry.settled.promise
}
cancel(reason?: string): void {
this.post(WorkerToHostType.ChildCancel, { callId: this.callId, reason })
}
dispose(): Promise<void> {
this.post(WorkerToHostType.ChildDispose, { callId: this.callId })
return this.entry.disposed.promise
}
}
/**
* The worker-side child-RPC bridge ({@link ChildPort}): allocates callIds,
* posts the start/cancel/dispose RPCs, and owns the per-call pending
* book-keeping the session's message handler settles via the `onChild*`
* entry points.
*/
class ChildRpcBridge implements ChildPort {
private nextCallId = 0
private readonly pending = new Map<number, PendingChild>()
constructor(private readonly post: Post) {}
async startAgent(request: ChildStartRequest): Promise<ChildHandle> {
this.nextCallId += 1
const callId = this.nextCallId
const entry: PendingChild = {
started: Promise.withResolvers<string>(),
settled: Promise.withResolvers<ChildResult>(),
disposed: Promise.withResolvers<void>(),
}
// Containment: when the start is refused (or the run torn down) the
// settled promise may never gain a consumer — it must not surface as an
// unhandled rejection and kill the worker.
entry.settled.promise.catch(() => { /* consumed: unconsumed child settlement after a refused start */ })
this.pending.set(callId, entry)
this.post(WorkerToHostType.ChildStart, { callId, request })
const childId = await entry.started.promise
return new RpcChildHandle(this.post, callId, entry, childId)
}
/** The host started the child; releases the `startAgent` await. */
onChildStarted(callId: number, childId: string): void {
this.pending.get(callId)?.started.resolve(childId)
}
/** The host refused the start; `startAgent` rejects with the rendered cause. */
onChildStartError(callId: number, rendered: string): void {
this.pending.get(callId)?.started.reject(new Error(rendered))
}
/** The child's terminal result arrived. */
onChildSettled(callId: number, result: ChildResult): void {
this.pending.get(callId)?.settled.resolve(result)
}
/** The child's `result` rejected host-side (an infrastructure fault, relayed as fatal). */
onChildFailed(callId: number, rendered: string): void {
this.pending.get(callId)?.settled.reject(new Error(rendered))
}
/** The host acked the dispose; the call's book-keeping is complete. */
onChildDisposed(callId: number): void {
const entry = this.pending.get(callId)
this.pending.delete(callId)
entry?.disposed.resolve()
}
}
/**
* Narrow the nullable `parentPort` the bootstrap reads from
* `node:worker_threads`.
* @param port - `parentPort` as imported (null on the main thread).
* @returns the port, non-null.
*/
export function requireParentPort(port: MessagePort | null): MessagePort {
if (port === null) throw new Error('the workflow worker entry must be loaded inside a worker thread (no parentPort)')
return port
}
/**
* Run one workflow script to settlement against `port`, posting the terminal
* result message exactly once; resolves after that post (stray children may
* still be winding down through the port — the host owns their teardown and
* ultimately terminates the thread). Never rejects: a constructor failure
* (unparseable body — host pre-parse makes this a Node-version-skew signal)
* is reported as an `error` result rather than dying without a result.
* @param port - the channel to the host (the real `parentPort`, or one side
* of an in-process `MessageChannel` in tests).
* @param init - the run payload the host provided as `workerData`.
*/
export async function runWorkerSession(port: MessagePort, init: WorkerInit): Promise<void> {
const post: Post = (type, payload) => {
port.postMessage({ type, ...payload })
}
const children = new ChildRpcBridge(post)
const observer: ExecutionObserver = {
phase: (title) => { post(WorkerToHostType.Phase, { title }) },
log: (message) => { post(WorkerToHostType.Log, { message }) },
agentStart: (info) => { post(WorkerToHostType.AgentStart, { info }) },
agentEnd: (info) => { post(WorkerToHostType.AgentEnd, { info }) },
}
let execution: WorkflowExecution
try {
execution = new WorkflowExecution(init.meta, init.body, init.args, init.limits, observer, children)
} catch (error: unknown) {
post(WorkerToHostType.Result, { result: { value: null, stopReason: 'error', error: renderThrown(error), agentsStarted: 0 } })
return
}
const gate = Promise.withResolvers<void>()
port.on('message', (message: HostToWorkerMessage) => {
switch (message.type) {
case HostToWorkerType.Go:
gate.resolve()
break
case HostToWorkerType.Cancel:
execution.cancel(message.reason)
// A cancel doubles as the gate release: drive() checks the cancelled
// state before running the body, so the script never executes.
gate.resolve()
break
case HostToWorkerType.ChildStarted:
children.onChildStarted(message.callId, message.childId)
break
case HostToWorkerType.ChildStartError:
children.onChildStartError(message.callId, message.rendered)
break
case HostToWorkerType.ChildSettled:
children.onChildSettled(message.callId, message.result)
break
case HostToWorkerType.ChildFailed:
children.onChildFailed(message.callId, message.rendered)
break
case HostToWorkerType.ChildDisposed:
children.onChildDisposed(message.callId)
break
/* v8 ignore next 2 -- closed engine-owned union; the arm only makes adding a message type a compile error */
default:
assertNever(message, 'host-to-worker message')
}
})
post(WorkerToHostType.Ready, {})
await gate.promise
const result = await execution.drive()
post(WorkerToHostType.Result, { result })
}
@@ -0,0 +1,97 @@
/**
* Non-protocol wire vocabulary for the worker-thread engine: the `workerData` init
* payload and the child-port interfaces the worker-side runtime consumes.
* The host⇄worker MESSAGE protocol lives in ./protocol.ts; everything here
* that a message transports (`ChildStartRequest`, `ChildResult`) is plain
* JSON data by construction, so the structured-clone hop never meets a value
* it cannot carry. Types only, per the package convention.
*
* @module @deepseek-ai/dsh-workflow-workerthread/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 (plain data off the start request, validated host-side). */
meta: WorkflowMeta
/** The plain-JS script body, exactly as the start request carried it. */
body: string
/** The run's `args` value; the workerData structured clone is the copy that isolates the caller. */
args?: unknown
/** The worker-enforced limits. */
limits: WorkerLimits
}
/** What the worker asks the host to start for one `agent()` call (options already validated worker-side). */
export interface ChildStartRequest {
/** The child's prompt text. */
prompt: string
/** The structured-output schema, if the call passed one (already subset-checked). */
schema?: StructuredOutputSchema
/** The per-child model override, if the call passed one. */
model?: string
}
/**
* The JSON projection of a child's `SubagentResult` crossing the port. The
* seam's `stopReason` union is merge-extensible, so it degrades to `string`
* on the wire — the runtime only ever branches on `'completed'`.
*/
export interface ChildResult {
/** The child's final assistant output blocks. */
output: ContentBlock[]
/** The structured value, present iff the request carried a schema AND the provider honored it. */
structured?: unknown
/** Why the child run ended (`'completed'` is the only value the runtime branches on). */
stopReason: string
}
/**
* The worker-side handle for one started child — the RPC mirror of the
* subagent seam's run handle, reduced to what the runtime consumes.
*/
export interface ChildHandle {
/** The child agent's id (minted host-side by the subagent seam). */
readonly id: string
/**
* Resolves with the child's terminal {@link ChildResult}; REJECTS only when
* the host reports an infrastructure fault (`child-failed`) — a child that
* failed for its own reasons resolves with a non-`completed` stop reason.
*/
readonly result: Promise<ChildResult>
/** Ask the host to cancel the child (fire-and-forget). */
cancel(reason?: string): void
/** Ask the host to dispose the child; resolves on the host's ack. */
dispose(): Promise<void>
}
/**
* The worker-side port the runtime starts child agents through — the seam
* that lets the execution core stay ignorant of the thread boundary.
*/
export interface ChildPort {
/**
* Start one child agent on the host (the `agent()` hook's start half).
* @param request - the prompt and validated options.
* @returns the child handle; rejects when the host refuses the start.
*/
startAgent(request: ChildStartRequest): Promise<ChildHandle>
}
@@ -0,0 +1,18 @@
/**
* The worker-thread entry the engine spawns: bootstrap ./session.ts on the
* real `parentPort`. Deliberately a single statement — every piece of logic
* lives in `runWorkerSession`, which the unit suite drives in-process over a
* `MessageChannel` (code inside a real Worker is invisible to main-process
* coverage); loading this module on the main thread throws via
* `requireParentPort`, which is how the suite covers the file itself.
*
* @module @deepseek-ai/dsh-workflow-workerthread/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)