1
0

index.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. /**
  2. * Worker-thread workflow engine. Each run executes its model-written script in
  3. * an escapable vm context on a fresh worker and bridges `agent()` calls to host
  4. * subagents. The thread prevents synchronous script work from blocking the host
  5. * and permits forced termination, but it is containment rather than a security boundary.
  6. * @module @deepseek-ai/dsh-workflow-workerthread
  7. */
  8. import { randomUUID } from 'node:crypto'
  9. import { availableParallelism } from 'node:os'
  10. import * as vm from 'node:vm'
  11. import type { Context } from 'cordis'
  12. import z from 'schemastery'
  13. import WorkflowService, { WorkflowError, WorkflowRunId } from '@deepseek-ai/dsh-workflow'
  14. import type { WorkflowRun, WorkflowRunInfo, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow'
  15. import { WorkerRun } from './host.ts'
  16. import { validateMeta } from './meta.ts'
  17. import type { WorkerInit, WorkerLimits } from './types.ts'
  18. export { validateMeta } from './meta.ts'
  19. export { HostToWorkerType, WorkerToHostType } from './protocol.ts'
  20. export type { HostToWorkerMessage, HostToWorkerPayloads, WorkerToHostMessage, WorkerToHostPayloads } from './protocol.ts'
  21. export { materializeFromRealm, MaterializeError } from './realm.ts'
  22. export { WorkflowExecution, type ExecutionObserver } from './runtime.ts'
  23. export { requireParentPort, runWorkerSession } from './session.ts'
  24. export type {
  25. ChildHandle,
  26. ChildPort,
  27. ChildResult,
  28. ChildStartRequest,
  29. WorkerInit,
  30. WorkerLimits,
  31. } from './types.ts'
  32. /** Plugin config (all optional — `static Config` supplies the defaults). */
  33. export interface Config {
  34. /** The `ctx.subagents` provider children run on (default `spawn`). */
  35. provider?: string
  36. /** Concurrent `agent()` ceiling; `0` (the default) auto-resolves to `min(16, max(1, cores - 2))`. */
  37. maxConcurrentAgents?: number
  38. /** Total `agent()` calls one run may start — the runaway-loop backstop (default 1000). */
  39. maxTotalAgents?: number
  40. /** Items accepted by a single `parallel()`/`pipeline()` call (default 4096). */
  41. maxItemsPerCall?: number
  42. /** vm timeout for the script's initial synchronous slice, inside the worker (default 5000 ms). */
  43. syncTimeoutMs?: number
  44. /**
  45. * How long after a cancellation an unsettled script may keep running before
  46. * the run force-settles `cancelled` and its worker is TERMINATED (default
  47. * 5000 ms); also bounds `dispose()`.
  48. */
  49. disposeGraceMs?: number
  50. }
  51. type ResolvedConfig = Required<Config>
  52. /** A body that still carries the Claude Code-style meta header (meta rides the seam as data here). */
  53. const META_STATEMENT = /^\s*export\s+const\s+meta\b/
  54. /**
  55. * Parse-check the body with the SAME wrapper the worker-side runtime
  56. * compiles, so `start()` keeps the seam's synchronous `SCRIPT_PARSE` throw
  57. * (the worker's own compile happens a thread away, after `start()` returned).
  58. * One redundant parse per run, bought deliberately for the contract. A body
  59. * opening with `export const meta` gets a pointed message instead of the
  60. * wrapper's bare SyntaxError — the model's likeliest authoring slip.
  61. */
  62. function assertBodyParses(body: string, name: string): void {
  63. if (META_STATEMENT.test(body)) {
  64. throw new WorkflowError('workflow meta rides the `meta` request field, not the script: remove the `export const meta = {...}` statement from the body', 'SCRIPT_PARSE')
  65. }
  66. try {
  67. // Parse only — the script object is discarded, nothing executes.
  68. void new vm.Script(`(async () => {\n${body}\n})()`, { filename: `workflow:${name}`, lineOffset: -1 })
  69. } catch (error: unknown) {
  70. throw new WorkflowError(`workflow script does not parse: ${String(error)}`, 'SCRIPT_PARSE', { cause: error })
  71. }
  72. }
  73. /**
  74. * The worker-thread engine service. `start()` validates the script up front
  75. * (meta + a host-side body parse) and returns a {@link WorkflowRun} whose
  76. * `result` never rejects; the `workflow/*` events fire around the run per
  77. * the seam contract.
  78. */
  79. export class WorkerWorkflowEngine extends WorkflowService {
  80. static inject = ['subagents']
  81. static Config: z<Config> = z.object({
  82. provider: z.string().default('spawn'),
  83. maxConcurrentAgents: z.natural().default(0),
  84. maxTotalAgents: z.natural().min(1).default(1000),
  85. maxItemsPerCall: z.natural().min(1).default(4096),
  86. syncTimeoutMs: z.natural().min(1).default(5000),
  87. disposeGraceMs: z.natural().default(5000),
  88. })
  89. private readonly config: ResolvedConfig
  90. constructor(ctx: Context, config: Config) {
  91. super(ctx)
  92. // schemastery (static Config) has already filled the defaulted fields;
  93. // the assertion records that resolution, not a hidden fallback.
  94. this.config = config as ResolvedConfig
  95. }
  96. /**
  97. * Validate and execute a workflow script in a fresh worker thread. Throws
  98. * {@link WorkflowError} synchronously (`META_INVALID` for a malformed meta
  99. * block, `SCRIPT_PARSE` for a body that does not compile) for a request
  100. * that cannot begin; once a run is returned, every failure resolves through
  101. * `result.stopReason` instead.
  102. * @param request - the script body, its meta data and `args`, the parent
  103. * agent, and an optional cancel signal.
  104. * @returns the live run (its `result` resolves when the script settles).
  105. */
  106. start(request: WorkflowStartRequest): WorkflowRun {
  107. const meta = validateMeta(request.meta)
  108. assertBodyParses(request.script, meta.name)
  109. const id = WorkflowRunId(randomUUID())
  110. const info: WorkflowRunInfo = { id, meta }
  111. const limits: WorkerLimits = {
  112. maxConcurrentAgents: this.config.maxConcurrentAgents === 0
  113. ? Math.min(16, Math.max(1, availableParallelism() - 2))
  114. : this.config.maxConcurrentAgents,
  115. maxTotalAgents: this.config.maxTotalAgents,
  116. maxItemsPerCall: this.config.maxItemsPerCall,
  117. syncTimeoutMs: this.config.syncTimeoutMs,
  118. }
  119. const init: WorkerInit = {
  120. meta,
  121. body: request.script,
  122. ...request.args !== undefined ? { args: request.args } : {},
  123. limits,
  124. }
  125. // Capture the dependency while this service call is still traced through
  126. // the start() holder. Cordis strips the engine-provider shadow when it
  127. // returns the SubagentService handle, so an already-returned run can keep
  128. // starting children after an engine HMR unload removes ctx.workflows.
  129. // Re-resolving `this.ctx.subagents` later from WorkerRun would instead walk
  130. // the now-inactive engine fiber and break the seam's holder-owned lifetime.
  131. const runCtx = this.ctx
  132. const subagents = runCtx.subagents
  133. const workerRun = new WorkerRun(
  134. runCtx,
  135. subagents,
  136. id,
  137. meta,
  138. request.parent,
  139. init,
  140. this.config.provider,
  141. this.config.disposeGraceMs,
  142. {
  143. phase: (title) => { this.emitWorkflowEvent('workflow/phase', info, title) },
  144. log: (message) => { this.emitWorkflowEvent('workflow/log', info, message) },
  145. agentStart: (agent) => { this.emitWorkflowEvent('workflow/agent-start', info, agent) },
  146. agentEnd: (agent) => { this.emitWorkflowEvent('workflow/agent-end', info, agent) },
  147. },
  148. request.signal,
  149. )
  150. this.emitWorkflowEvent('workflow/start', info)
  151. // `workflow/end` fires as the (never-rejecting) result settles, with the
  152. // outcome DATA only — the value stays with the run's holder.
  153. void workerRun.result.then((settled) => {
  154. this.emitWorkflowEvent('workflow/end', info, {
  155. stopReason: settled.stopReason,
  156. ...settled.error !== undefined ? { error: settled.error } : {},
  157. agentsStarted: settled.agentsStarted,
  158. })
  159. })
  160. return workerRun
  161. }
  162. }
  163. export default WorkerWorkflowEngine