runtime.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487
  1. /**
  2. * Per-run worker-side vm hooks, child RPC, concurrency/caps, cancellation, and result shaping; it
  3. * never touches Cordis. Script values leaving the realm are materialized as plain JSON before
  4. * messaging. Values entering the trusted model-written realm are passed directly; `args` alone is
  5. * cloned so script mutation cannot alter initialization data. See `./realm.ts` for the trust model.
  6. *
  7. * Fatal workflow errors—bad hook arguments, unsupported schemas/options, caps, start failures, and
  8. * cancellation—propagate through combinators. Only child failures and ordinary stage errors become
  9. * per-item nulls. Every returned promise has a rejection consumer so dropped script promises cannot
  10. * kill the worker. A cancelled script that never settles emits nothing; the host force-settles the
  11. * run within grace and terminates the thread.
  12. * @module @deepseek-ai/dsh-workflow-workerthread/runtime
  13. */
  14. import * as vm from 'node:vm'
  15. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  16. import { SessionId } from '@deepseek-ai/dsh-session'
  17. import { assertObjectJsonSchema, JsonSchemaError } from '@deepseek-ai/dsh-tools'
  18. import type { ObjectJsonSchema } from '@deepseek-ai/dsh-tools'
  19. import { isFatalWorkflowError, WorkflowError } from '@deepseek-ai/dsh-workflow'
  20. import type {
  21. WorkflowAgentEndInfo,
  22. WorkflowAgentInfo,
  23. WorkflowMeta,
  24. WorkflowResult,
  25. } from '@deepseek-ai/dsh-workflow'
  26. import { materializeFromRealm, MaterializeError, renderThrown } from './realm.ts'
  27. import type { ChildHandle, ChildPort, WorkerLimits } from './types.ts'
  28. /** The observers the execution reports progress through (the session posts them to the host). */
  29. export interface ExecutionObserver {
  30. phase(title: string): void
  31. log(message: string): void
  32. agentStart(info: WorkflowAgentInfo): void
  33. agentEnd(info: WorkflowAgentEndInfo): void
  34. }
  35. /** The `agent()` options the script may pass; everything else rejects loud. */
  36. const SUPPORTED_AGENT_OPTIONS = new Set(['label', 'phase', 'schema', 'provider', 'model'])
  37. /** Deferred Claude Code options we name explicitly in the rejection message. */
  38. const DEFERRED_AGENT_OPTIONS = new Set(['effort', 'isolation', 'agentType'])
  39. /** Flatten a child's final output blocks to text (the non-schema `agent()` result). */
  40. function outputText(blocks: ContentBlock[]): string {
  41. return blocks
  42. .filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
  43. .map(block => block.text)
  44. .join('')
  45. }
  46. /** A short display label derived from the prompt when the script passes none. */
  47. function defaultLabel(prompt: string): string {
  48. const newline = prompt.indexOf('\n')
  49. const line = newline === -1 ? prompt : prompt.slice(0, newline)
  50. return line.length <= 48 ? line : `${line.slice(0, 47)}…`
  51. }
  52. /**
  53. * One live script execution inside the worker. Constructed per run by the
  54. * session; `drive()` is called exactly once and NEVER rejects — every failure
  55. * becomes a {@link WorkflowResult} with a non-`completed` stop reason. The
  56. * host owns cancellation and cleanup of any dropped child work.
  57. */
  58. export class WorkflowExecution {
  59. /** 1-based count of `agent()` calls started (the `agentsStarted` result field). */
  60. private started = 0
  61. private activeSlots = 0
  62. private readonly slotWaiters: { resolve(): void; reject(error: unknown): void }[] = []
  63. private cancelReason: string | undefined
  64. private cancelError: WorkflowError | undefined
  65. private currentPhase: string | undefined
  66. private readonly context: vm.Context
  67. private readonly compiled: vm.Script
  68. constructor(
  69. meta: WorkflowMeta,
  70. body: string,
  71. args: unknown,
  72. private readonly limits: WorkerLimits,
  73. private readonly observer: ExecutionObserver,
  74. private readonly children: ChildPort,
  75. ) {
  76. // Compile FIRST: a body syntax error must throw out of the constructor
  77. // before any realm state exists. The host pre-parses the identical
  78. // wrapper, so under one Node version this throw is unreachable in
  79. // production — the session still maps it to an error result defensively.
  80. // lineOffset compensates for the wrapper line, so stack traces carry the
  81. // script's own line numbers.
  82. try {
  83. this.compiled = new vm.Script(`(async () => {\n${body}\n})()`, {
  84. filename: `workflow:${meta.name}`,
  85. lineOffset: -1,
  86. })
  87. } catch (error: unknown) {
  88. throw new WorkflowError(`workflow script does not parse: ${String(error)}`, 'SCRIPT_PARSE', { cause: error })
  89. }
  90. this.context = vm.createContext({}, { name: `workflow:${meta.name}` })
  91. const globals: Record<string, unknown> = {
  92. agent: (prompt: unknown, opts?: unknown) => this.contain(this.agent(prompt, opts)),
  93. parallel: (thunks: unknown) => this.contain(this.parallel(thunks)),
  94. pipeline: (items: unknown, ...stages: unknown[]) => this.contain(this.pipeline(items, stages)),
  95. phase: (title: unknown) => { this.phase(title) },
  96. log: (message: unknown) => { this.log(message) },
  97. // workerData already performed the real cross-thread structured clone.
  98. args,
  99. }
  100. for (const [key, value] of Object.entries(globals)) {
  101. // Data properties on the contextified global; frozen shape not required —
  102. // a script overwriting its own hooks only sabotages itself.
  103. ;(this.context as Record<string, unknown>)[key] = typeof value === 'function' ? Object.freeze(value) : value
  104. }
  105. }
  106. /**
  107. * Whether the run has been cancelled. A METHOD, not an inline property
  108. * read: `cancel()` mutates `cancelReason` concurrently (the session's
  109. * message handler), and an inline read after an `await` gets narrowed by
  110. * control flow into an always-false comparison.
  111. */
  112. private isCancelled(): boolean {
  113. return this.cancelReason !== undefined
  114. }
  115. /**
  116. * Shared hook entry guard: after {@link cancel}, EVERY hook throws
  117. * `CANCELLED` at its next call — cancellation is the next HOOK boundary,
  118. * not just the next `agent()`, so a script that caught one cancelled
  119. * rejection cannot keep emitting progress through `phase`/`log` or enter a
  120. * combinator.
  121. */
  122. private throwIfCancelled(): void {
  123. if (this.isCancelled()) throw this.cancelledError()
  124. }
  125. /**
  126. * Cancel the run: waiting `agent()` slots reject and every future hook call
  127. * throws `CANCELLED` — the script dies at its next await. A script that
  128. * never settles anyway (parked on a promise no hook owns) is the HOST's
  129. * problem: its grace timer force-settles the run and terminates the
  130. * worker. Idempotent; the first reason wins.
  131. * @param reason - human-readable cause carried on the CANCELLED error. The
  132. * host independently aborts the required signal shared by every child.
  133. */
  134. cancel(reason: string): void {
  135. if (this.cancelReason !== undefined) return
  136. this.cancelReason = reason
  137. this.cancelError = new WorkflowError(`workflow run cancelled: ${this.cancelReason}`, 'CANCELLED')
  138. for (const waiter of this.slotWaiters.splice(0)) waiter.reject(this.cancelledError())
  139. }
  140. /**
  141. * Run the script to settlement. Resolves — never rejects — with the run's
  142. * {@link WorkflowResult}: the materialized return value on `completed`, the
  143. * failure message on `error`, and `cancelled` when the script died of
  144. * cancellation. This method only chooses the result; the session publishes
  145. * it and the host owns terminal child cancellation.
  146. * @returns the settled outcome — this promise NEVER rejects (the seam's
  147. * `result`-never-rejects contract); every failure maps to a variant.
  148. */
  149. async drive(): Promise<WorkflowResult> {
  150. try {
  151. // Cancelled before the body ever ran (an already-aborted start signal,
  152. // relayed by the host before its `go`): the script must not execute at
  153. // all, let alone report `completed`.
  154. if (this.isCancelled()) throw this.cancelledError()
  155. const scriptPromise = this.compiled.runInContext(this.context, { timeout: this.limits.syncTimeoutMs }) as Promise<unknown>
  156. const raw: unknown = await this.contain(Promise.resolve(scriptPromise))
  157. // Cancelled while the body ran: a script that settled without touching
  158. // another hook (or without any) must still report `cancelled` — the
  159. // holder asked for cancellation and `completed` would be a lie.
  160. if (this.isCancelled()) throw this.cancelledError()
  161. const value = raw === undefined ? null : this.materializeResult(raw)
  162. return { value, stopReason: 'completed', agentsStarted: this.started }
  163. } catch (error: unknown) {
  164. // Any failure after cancel() reports `cancelled` with the canonical
  165. // reason — the reject path mirrors the resolve path's post-settle check.
  166. if (this.isCancelled()) {
  167. return { value: null, stopReason: 'cancelled', error: this.cancelledError().message, agentsStarted: this.started }
  168. }
  169. // renderThrown is total (thrown values of any realm), so this arm
  170. // cannot throw — drive() resolving is the `result` never-rejects seam
  171. // contract.
  172. return { value: null, stopReason: 'error', error: renderThrown(error), agentsStarted: this.started }
  173. }
  174. }
  175. /**
  176. * Attach a no-op rejection consumer WITHOUT changing what the caller
  177. * receives: if the script drops the promise (no await), cancellation cannot
  178. * become an unhandled rejection (which would kill the worker thread); if
  179. * the script does await it, it still observes the rejection.
  180. */
  181. private contain<T>(promise: Promise<T>): Promise<T> {
  182. promise.catch(() => { /* consumed: see method contract — a dropped hook promise must not surface an unhandled rejection */ })
  183. return promise
  184. }
  185. private cancelledError(): WorkflowError {
  186. // cancel() arms cancelError before any caller can observe isCancelled()
  187. // === true; the fallback guards the type, not a reachable path.
  188. /* v8 ignore next */
  189. return this.cancelError ?? new WorkflowError('workflow run cancelled', 'CANCELLED')
  190. }
  191. /** Materialize the script's return value; violations become RESULT_UNSERIALIZABLE. */
  192. private materializeResult(raw: unknown): unknown {
  193. try {
  194. return materializeFromRealm(raw, 'workflow result')
  195. } catch (error: unknown) {
  196. /* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */
  197. if (!(error instanceof MaterializeError)) throw error
  198. throw new WorkflowError(
  199. `the workflow's return value is not plain JSON data — ${error.message}. Return only JSON-serializable objects/arrays/scalars.`,
  200. 'RESULT_UNSERIALIZABLE',
  201. { cause: error },
  202. )
  203. }
  204. }
  205. /**
  206. * Acquire one concurrency slot (FIFO). Cancellation rejects QUEUED waiters
  207. * (see {@link cancel}); the callers guard their own entry and post-acquire
  208. * windows, so no cancelled-precheck is duplicated here.
  209. */
  210. private acquireSlot(): Promise<void> {
  211. if (this.activeSlots < this.limits.maxConcurrentAgents) {
  212. this.activeSlots += 1
  213. return Promise.resolve()
  214. }
  215. return new Promise<void>((resolve, reject) => {
  216. this.slotWaiters.push({
  217. resolve: () => {
  218. this.activeSlots += 1
  219. resolve()
  220. },
  221. reject,
  222. })
  223. })
  224. }
  225. private releaseSlot(): void {
  226. this.activeSlots -= 1
  227. const next = this.slotWaiters.shift()
  228. if (next) next.resolve()
  229. }
  230. /** The `agent(prompt, opts)` hook. */
  231. private async agent(rawPrompt: unknown, rawOpts: unknown): Promise<unknown> {
  232. this.throwIfCancelled()
  233. if (typeof rawPrompt !== 'string' || rawPrompt.length === 0) {
  234. throw new WorkflowError('agent() requires a non-empty prompt string', 'INVALID_ARGUMENT')
  235. }
  236. const opts = this.readAgentOptions(rawOpts)
  237. if (this.started >= this.limits.maxTotalAgents) {
  238. throw new WorkflowError(
  239. `this run reached its total agent cap (${this.limits.maxTotalAgents}) — a runaway-loop backstop; raise the applicable maxTotalAgents limit if the scale is intentional`,
  240. 'AGENT_CAP',
  241. )
  242. }
  243. this.started += 1
  244. const seq = this.started
  245. const label = opts.label ?? defaultLabel(rawPrompt)
  246. const phase = opts.phase ?? this.currentPhase
  247. await this.acquireSlot()
  248. try {
  249. // Re-check after the acquire: the await yields at least one microtask
  250. // tick even when a slot is free, and a queued waiter resumes a tick
  251. // after its release — a cancel() landing in either window must not
  252. // reach the host (which would refuse anyway, but the refusal reads as
  253. // a start failure rather than the cancellation it is).
  254. this.throwIfCancelled()
  255. let run: ChildHandle
  256. try {
  257. run = await this.children.startAgent({
  258. prompt: rawPrompt,
  259. ...opts.schema !== undefined ? { schema: opts.schema } : {},
  260. ...opts.provider !== undefined ? { provider: opts.provider } : {},
  261. ...opts.model !== undefined ? { model: opts.model } : {},
  262. })
  263. } catch (error: unknown) {
  264. // The host refuses starts once the run is cancelled — a refusal that
  265. // races our own cancel state must read as the cancellation it is,
  266. // not as a broken seam.
  267. if (this.isCancelled()) throw this.cancelledError()
  268. throw new WorkflowError(`agent() could not start a child: ${renderThrown(error)}`, 'AGENT_START', { cause: error })
  269. }
  270. // The start round-trip yields to the event loop, so a cancel CAN land
  271. // between the host starting the child and this continuation running —
  272. // wind the fresh child down instead of leaving it live behind a dead
  273. // script.
  274. if (this.isCancelled()) {
  275. await run.dispose()
  276. throw this.cancelledError()
  277. }
  278. const info: WorkflowAgentInfo = { seq, label, ...phase !== undefined ? { phase } : {}, childId: SessionId(run.id) }
  279. this.observer.agentStart(info)
  280. try {
  281. let result
  282. try {
  283. result = await run.result
  284. } catch (error: unknown) {
  285. // A rejected child result is an INFRASTRUCTURE fault relayed by the
  286. // host — distinct from a child that failed and resolved. Pair the
  287. // lifecycle before propagating, and propagate FATAL: an ordinary
  288. // throw would dissolve to a per-item null inside the combinators,
  289. // and a broken provider must not read as a failed child.
  290. if (this.isCancelled()) {
  291. this.observer.agentEnd({ ...info, outcome: 'cancelled' })
  292. throw this.cancelledError()
  293. }
  294. this.observer.agentEnd({ ...info, outcome: 'failed' })
  295. throw new WorkflowError(`child agent run failed: ${renderThrown(error)}`, 'AGENT_RESULT', { cause: error })
  296. }
  297. if (result.stopReason === 'completed') {
  298. if (opts.schema !== undefined) {
  299. // The provider honored outputSchema (capability-gated at start), so
  300. // a completed run without a structured value is a child failure.
  301. if (result.structured === undefined) {
  302. this.observer.agentEnd({ ...info, outcome: 'failed' })
  303. return null
  304. }
  305. this.observer.agentEnd({ ...info, outcome: 'completed' })
  306. return result.structured
  307. }
  308. this.observer.agentEnd({ ...info, outcome: 'completed' })
  309. return outputText(result.output)
  310. }
  311. // A cancelled RUN kills the script; a child that failed for its own
  312. // reasons resolves null (scripts .filter(Boolean) per the CC contract).
  313. if (this.isCancelled()) {
  314. this.observer.agentEnd({ ...info, outcome: 'cancelled' })
  315. throw this.cancelledError()
  316. }
  317. this.observer.agentEnd({ ...info, outcome: 'failed' })
  318. return null
  319. } finally {
  320. await run.dispose()
  321. }
  322. } finally {
  323. this.releaseSlot()
  324. }
  325. }
  326. /** Materialize + validate the `agent()` options bag from the realm. */
  327. private readAgentOptions(rawOpts: unknown): {
  328. label?: string
  329. phase?: string
  330. provider?: string
  331. model?: string
  332. schema?: ObjectJsonSchema
  333. } {
  334. if (rawOpts === undefined) return {}
  335. let opts: unknown
  336. try {
  337. opts = materializeFromRealm(rawOpts, 'agent() options')
  338. } catch (error: unknown) {
  339. /* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */
  340. if (!(error instanceof MaterializeError)) throw error
  341. throw new WorkflowError(`agent() options must be plain JSON data — ${error.message}`, 'INVALID_ARGUMENT', { cause: error })
  342. }
  343. if (typeof opts !== 'object' || opts === null || Array.isArray(opts)) {
  344. throw new WorkflowError('agent() options must be an object', 'INVALID_ARGUMENT')
  345. }
  346. const record = opts as Record<string, unknown>
  347. for (const key of Object.keys(record)) {
  348. if (SUPPORTED_AGENT_OPTIONS.has(key)) continue
  349. if (DEFERRED_AGENT_OPTIONS.has(key)) {
  350. throw new WorkflowError(`agent() option "${key}" is deferred and not supported by this engine (supported: label, phase, schema, provider, model)`, 'UNSUPPORTED_OPTION')
  351. }
  352. throw new WorkflowError(`agent() option "${key}" is not recognized (supported: label, phase, schema, provider, model)`, 'UNSUPPORTED_OPTION')
  353. }
  354. for (const key of ['label', 'phase', 'provider', 'model'] as const) {
  355. if (record[key] !== undefined && typeof record[key] !== 'string') {
  356. throw new WorkflowError(`agent() option "${key}" must be a string`, 'INVALID_ARGUMENT')
  357. }
  358. }
  359. let schema: ObjectJsonSchema | undefined
  360. if (record.schema !== undefined) {
  361. try {
  362. assertObjectJsonSchema(record.schema)
  363. schema = record.schema
  364. } catch (error: unknown) {
  365. /* v8 ignore next -- defensive rethrow arm: assertObjectJsonSchema only throws JsonSchemaError */
  366. if (!(error instanceof JsonSchemaError)) throw error
  367. throw new WorkflowError(`agent() schema is outside the supported subset — ${error.message}`, 'UNSUPPORTED_SCHEMA', { cause: error })
  368. }
  369. }
  370. return {
  371. ...record.label !== undefined ? { label: record.label as string } : {},
  372. ...record.phase !== undefined ? { phase: record.phase as string } : {},
  373. ...record.provider !== undefined ? { provider: record.provider as string } : {},
  374. ...record.model !== undefined ? { model: record.model as string } : {},
  375. ...schema !== undefined ? { schema } : {},
  376. }
  377. }
  378. /** The `parallel(thunks)` hook: each thunk caught → `null`; fatal errors propagate. */
  379. private async parallel(rawThunks: unknown): Promise<unknown[]> {
  380. this.throwIfCancelled()
  381. if (!Array.isArray(rawThunks)) {
  382. throw new WorkflowError('parallel() requires an array of zero-argument functions', 'INVALID_ARGUMENT')
  383. }
  384. this.assertItemCap(rawThunks.length, 'parallel()')
  385. const thunks = rawThunks.map((thunk, index) => {
  386. if (typeof thunk !== 'function') {
  387. throw new WorkflowError(`parallel() item ${index} is not a function`, 'INVALID_ARGUMENT')
  388. }
  389. return thunk as () => unknown
  390. })
  391. return Promise.all(thunks.map(async (thunk) => {
  392. try {
  393. return await thunk()
  394. } catch (error: unknown) {
  395. // Hook failures are WorkflowErrors built OUTSIDE the script's realm;
  396. // fatality is recognized by `instanceof` against this realm's class —
  397. // a script-built object can never pass it, so fatality cannot be
  398. // forged (nor accidentally dissolved).
  399. if (isFatalWorkflowError(error)) throw error
  400. return null
  401. }
  402. }))
  403. }
  404. /** The `pipeline(items, ...stages)` hook: per-item stage chains, NO cross-stage barrier. */
  405. private async pipeline(rawItems: unknown, rawStages: unknown[]): Promise<unknown[]> {
  406. this.throwIfCancelled()
  407. if (!Array.isArray(rawItems)) {
  408. throw new WorkflowError('pipeline() requires an items array', 'INVALID_ARGUMENT')
  409. }
  410. this.assertItemCap(rawItems.length, 'pipeline()')
  411. if (rawStages.length === 0) {
  412. throw new WorkflowError('pipeline() requires at least one stage function', 'INVALID_ARGUMENT')
  413. }
  414. const stages = rawStages.map((stage, index) => {
  415. if (typeof stage !== 'function') {
  416. throw new WorkflowError(`pipeline() stage ${index} is not a function`, 'INVALID_ARGUMENT')
  417. }
  418. return stage as (previous: unknown, item: unknown, index: number) => unknown
  419. })
  420. return Promise.all(rawItems.map(async (item: unknown, index) => {
  421. let value: unknown = item
  422. try {
  423. for (const stage of stages) {
  424. value = await stage(value, item, index)
  425. }
  426. return value
  427. } catch (error: unknown) {
  428. // An ordinary stage throw drops the ITEM to null and skips its
  429. // remaining stages; a fatal WorkflowError (see parallel()) kills the
  430. // whole script.
  431. if (isFatalWorkflowError(error)) throw error
  432. return null
  433. }
  434. }))
  435. }
  436. private assertItemCap(length: number, hook: string): void {
  437. if (length > this.limits.maxItemsPerCall) {
  438. throw new WorkflowError(
  439. `${hook} received ${length} items — over the per-call cap (${this.limits.maxItemsPerCall}); split the work or raise maxItemsPerCall in the engine config`,
  440. 'ITEM_CAP',
  441. )
  442. }
  443. }
  444. /** The `phase(title)` hook: sets the current label for subsequent `agent()` calls and notifies observers. */
  445. private phase(title: unknown): void {
  446. this.throwIfCancelled()
  447. if (typeof title !== 'string' || title.length === 0) {
  448. throw new WorkflowError('phase() requires a non-empty title string', 'INVALID_ARGUMENT')
  449. }
  450. this.currentPhase = title
  451. this.observer.phase(title)
  452. }
  453. /** The `log(message)` hook: narration to observers. */
  454. private log(message: unknown): void {
  455. this.throwIfCancelled()
  456. if (typeof message !== 'string') {
  457. throw new WorkflowError('log() requires a message string', 'INVALID_ARGUMENT')
  458. }
  459. this.observer.log(message)
  460. }
  461. }