2026-07-05 13:29:35 +08:00
/**
2026-07-13 23:27:00 +08:00
* Worker-thread workflow engine. Each run executes its model-written script in
* an escapable vm context on a fresh worker and bridges `agent()` calls to host
* subagents. The thread prevents synchronous script work from blocking the host
* and permits forced termination, but it is containment rather than a security boundary.
2026-07-09 19:06:55 +08:00
* @module @deepseek-ai/dsh-workflow-workerthread
2026-07-05 13:29:35 +08:00
*/
import { randomUUID } from 'node:crypto'
import { availableParallelism } from 'node:os'
2026-07-09 18:39:31 +08:00
import * as vm from 'node:vm'
2026-07-05 13:29:35 +08:00
import type { Context } from 'cordis'
import z from 'schemastery'
2026-07-09 18:39:31 +08:00
import WorkflowService , { WorkflowError , WorkflowRunId } from '@deepseek-ai/dsh-workflow'
import type { WorkflowRun , WorkflowRunInfo , WorkflowStartRequest } from '@deepseek-ai/dsh-workflow'
import { WorkerRun } from './host.ts'
2026-07-09 20:09:10 +08:00
import { validateMeta } from './meta.ts'
2026-07-09 18:39:31 +08:00
import type { WorkerInit , WorkerLimits } from './types.ts'
2026-07-05 13:29:35 +08:00
2026-07-09 20:09:10 +08:00
export { validateMeta } from './meta.ts'
2026-07-05 13:29:35 +08:00
export { materializeFromRealm , MaterializeError } from './realm.ts'
2026-07-09 18:39:31 +08:00
export type {
ChildHandle ,
ChildPort ,
ChildResult ,
ChildStartRequest ,
WorkerInit ,
WorkerLimits ,
} from './types.ts'
2026-07-05 13:29:35 +08:00
/** 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
2026-07-09 20:09:10 +08:00
/** vm timeout for the script's initial synchronous slice, inside the worker (default 5000 ms). */
2026-07-05 13:29:35 +08:00
syncTimeoutMs? : number
2026-07-06 00:48:49 +08:00
/**
* How long after a cancellation an unsettled script may keep running before
2026-07-09 18:39:31 +08:00
* the run force-settles `cancelled` and its worker is TERMINATED (default
* 5000 ms); also bounds `dispose()`.
2026-07-06 00:48:49 +08:00
*/
2026-07-05 13:29:35 +08:00
disposeGraceMs? : number
}
type ResolvedConfig = Required < Config >
2026-07-09 20:09:10 +08:00
/** 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/
2026-07-05 13:29:35 +08:00
/**
2026-07-09 18:39:31 +08:00
* 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).
2026-07-09 20:09:10 +08:00
* 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.
2026-07-05 13:29:35 +08:00
*/
2026-07-09 18:39:31 +08:00
function assertBodyParses ( body : string , name : string ) : void {
2026-07-09 20:09:10 +08:00
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' )
}
2026-07-09 18:39:31 +08:00
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.
*/
2026-07-14 03:52:06 +08:00
class WorkerWorkflowEngine extends WorkflowService {
2026-07-05 13:29:35 +08:00
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
}
/**
2026-07-09 20:09:10 +08:00
* 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.
2026-07-05 13:29:35 +08:00
* @returns the live run (its `result` resolves when the script settles).
*/
start ( request : WorkflowStartRequest ) : WorkflowRun {
2026-07-09 20:09:10 +08:00
const meta = validateMeta ( request . meta )
assertBodyParses ( request . script , meta . name )
2026-07-05 13:29:35 +08:00
const id = WorkflowRunId ( randomUUID ( ) )
2026-07-12 22:41:59 +08:00
const info : WorkflowRunInfo = { id , meta }
2026-07-09 18:39:31 +08:00
const limits : WorkerLimits = {
2026-07-05 13:29:35 +08:00
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 ,
}
2026-07-09 18:39:31 +08:00
const init : WorkerInit = {
2026-07-05 13:29:35 +08:00
meta ,
2026-07-09 20:09:10 +08:00
body : request.script ,
2026-07-09 18:39:31 +08:00
. . . request . args !== undefined ? { args : request.args } : { } ,
2026-07-05 13:29:35 +08:00
limits ,
2026-07-09 18:39:31 +08:00
}
2026-07-12 08:57:05 +08:00
// Capture the dependency while this service call is still traced through
// the start() holder. Cordis strips the engine-provider shadow when it
// returns the SubagentService handle, so an already-returned run can keep
// starting children after an engine HMR unload removes ctx.workflows.
// Re-resolving `this.ctx.subagents` later from WorkerRun would instead walk
// the now-inactive engine fiber and break the seam's holder-owned lifetime.
const runCtx = this . ctx
const subagents = runCtx . subagents
2026-07-09 18:39:31 +08:00
const workerRun = new WorkerRun (
2026-07-12 08:57:05 +08:00
runCtx ,
subagents ,
2026-07-09 18:39:31 +08:00
id ,
2026-07-12 22:41:59 +08:00
meta ,
2026-07-09 18:39:31 +08:00
request . parent ,
init ,
this . config . provider ,
this . config . disposeGraceMs ,
2026-07-05 13:29:35 +08:00
{
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 ) } ,
} ,
2026-07-09 18:39:31 +08:00
request . signal ,
2026-07-05 13:29:35 +08:00
)
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.
2026-07-09 18:39:31 +08:00
void workerRun . result . then ( ( settled ) = > {
2026-07-05 13:29:35 +08:00
this . emitWorkflowEvent ( 'workflow/end' , info , {
stopReason : settled.stopReason ,
. . . settled . error !== undefined ? { error : settled.error } : { } ,
agentsStarted : settled.agentsStarted ,
} )
} )
2026-07-09 18:39:31 +08:00
return workerRun
2026-07-05 13:29:35 +08:00
}
}
2026-07-09 18:39:31 +08:00
export default WorkerWorkflowEngine