session.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. /**
  2. * The worker-side half of the engine: {@link runWorkerSession} wires one
  3. * MessagePort to one {@link WorkflowExecution} — hook progress and child
  4. * starts go out as messages, run control and child lifecycle come back in —
  5. * and posts the run's terminal result exactly once. Deliberately separated
  6. * from the thread bootstrap (./worker.ts): the whole session is drivable
  7. * in-process over a `MessageChannel`, which is where its unit coverage lives
  8. * (code inside a real Worker is invisible to the main process's coverage).
  9. *
  10. * Startup handshake: the session posts `ready` and runs the script only
  11. * after the host's `go` — without it, a cancellation racing the worker's
  12. * boot could arrive AFTER the script's initial synchronous slice already
  13. * ran, and a run cancelled before start must not execute the body at all.
  14. * A `cancel` arriving instead of `go` still releases the gate: `drive()`
  15. * sees the cancelled state and settles without running the body.
  16. *
  17. * @module @deepseek-ai/dsh-workflow-workerthread/session
  18. */
  19. import type { MessagePort } from 'node:worker_threads'
  20. import { assertNever } from '@deepseek-ai/dsh-llm'
  21. import { HostToWorkerType, WorkerToHostType } from './protocol.ts'
  22. import type { HostToWorkerMessage, WorkerToHostPayloads } from './protocol.ts'
  23. import { renderThrown } from './realm.ts'
  24. import { WorkflowExecution } from './runtime.ts'
  25. import type { ExecutionObserver } from './runtime.ts'
  26. import type {
  27. ChildHandle,
  28. ChildPort,
  29. ChildResult,
  30. ChildStartRequest,
  31. WorkerInit,
  32. } from './types.ts'
  33. /** The book-keeping for one in-flight child RPC (keyed by callId). */
  34. interface PendingChild {
  35. started: PromiseWithResolvers<string>
  36. settled: PromiseWithResolvers<ChildResult>
  37. disposed: PromiseWithResolvers<void>
  38. }
  39. /** 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). */
  40. type Post = <T extends WorkerToHostType>(type: T, payload: WorkerToHostPayloads[T]) => void
  41. /**
  42. * The worker-side handle for one started child agent ({@link ChildHandle}):
  43. * every member is an RPC to the host keyed by this call's `callId`, resolved
  44. * by the session's message handler through the bridge's pending entry.
  45. */
  46. class RpcChildHandle implements ChildHandle {
  47. readonly result: Promise<ChildResult>
  48. constructor(
  49. private readonly post: Post,
  50. private readonly callId: number,
  51. private readonly entry: PendingChild,
  52. readonly id: string,
  53. ) {
  54. this.result = entry.settled.promise
  55. }
  56. cancel(reason?: string): void {
  57. this.post(WorkerToHostType.ChildCancel, { callId: this.callId, reason })
  58. }
  59. dispose(): Promise<void> {
  60. this.post(WorkerToHostType.ChildDispose, { callId: this.callId })
  61. return this.entry.disposed.promise
  62. }
  63. }
  64. /**
  65. * The worker-side child-RPC bridge ({@link ChildPort}): allocates callIds,
  66. * posts the start/cancel/dispose RPCs, and owns the per-call pending
  67. * book-keeping the session's message handler settles via the `onChild*`
  68. * entry points.
  69. */
  70. class ChildRpcBridge implements ChildPort {
  71. private nextCallId = 0
  72. private readonly pending = new Map<number, PendingChild>()
  73. constructor(private readonly post: Post) {}
  74. async startAgent(request: ChildStartRequest): Promise<ChildHandle> {
  75. this.nextCallId += 1
  76. const callId = this.nextCallId
  77. const entry: PendingChild = {
  78. started: Promise.withResolvers<string>(),
  79. settled: Promise.withResolvers<ChildResult>(),
  80. disposed: Promise.withResolvers<void>(),
  81. }
  82. // Containment: when the start is refused (or the run torn down) the
  83. // settled promise may never gain a consumer — it must not surface as an
  84. // unhandled rejection and kill the worker.
  85. entry.settled.promise.catch(() => { /* consumed: unconsumed child settlement after a refused start */ })
  86. this.pending.set(callId, entry)
  87. this.post(WorkerToHostType.ChildStart, { callId, request })
  88. const childId = await entry.started.promise
  89. return new RpcChildHandle(this.post, callId, entry, childId)
  90. }
  91. /** The host started the child; releases the `startAgent` await. */
  92. onChildStarted(callId: number, childId: string): void {
  93. this.pending.get(callId)?.started.resolve(childId)
  94. }
  95. /** The host refused the start; `startAgent` rejects with the rendered cause. */
  96. onChildStartError(callId: number, rendered: string): void {
  97. this.pending.get(callId)?.started.reject(new Error(rendered))
  98. }
  99. /** The child's terminal result arrived. */
  100. onChildSettled(callId: number, result: ChildResult): void {
  101. this.pending.get(callId)?.settled.resolve(result)
  102. }
  103. /** The child's `result` rejected host-side (an infrastructure fault, relayed as fatal). */
  104. onChildFailed(callId: number, rendered: string): void {
  105. this.pending.get(callId)?.settled.reject(new Error(rendered))
  106. }
  107. /** The host acked the dispose; the call's book-keeping is complete. */
  108. onChildDisposed(callId: number): void {
  109. const entry = this.pending.get(callId)
  110. this.pending.delete(callId)
  111. entry?.disposed.resolve()
  112. }
  113. }
  114. /**
  115. * Narrow the nullable `parentPort` the bootstrap reads from
  116. * `node:worker_threads`.
  117. * @param port - `parentPort` as imported (null on the main thread).
  118. * @returns the port, non-null.
  119. */
  120. export function requireParentPort(port: MessagePort | null): MessagePort {
  121. if (port === null) throw new Error('the workflow worker entry must be loaded inside a worker thread (no parentPort)')
  122. return port
  123. }
  124. /**
  125. * Run one workflow script to settlement against `port`, posting the terminal
  126. * result message exactly once; resolves after that post (stray children may
  127. * still be winding down through the port — the host owns their teardown and
  128. * ultimately terminates the thread). Never rejects: a constructor failure
  129. * (unparseable body — host pre-parse makes this a Node-version-skew signal)
  130. * is reported as an `error` result rather than dying without a result.
  131. * @param port - the channel to the host (the real `parentPort`, or one side
  132. * of an in-process `MessageChannel` in tests).
  133. * @param init - the run payload the host provided as `workerData`.
  134. */
  135. export async function runWorkerSession(port: MessagePort, init: WorkerInit): Promise<void> {
  136. const post: Post = (type, payload) => {
  137. port.postMessage({ type, ...payload })
  138. }
  139. const children = new ChildRpcBridge(post)
  140. const observer: ExecutionObserver = {
  141. phase: (title) => { post(WorkerToHostType.Phase, { title }) },
  142. log: (message) => { post(WorkerToHostType.Log, { message }) },
  143. agentStart: (info) => { post(WorkerToHostType.AgentStart, { info }) },
  144. agentEnd: (info) => { post(WorkerToHostType.AgentEnd, { info }) },
  145. }
  146. let execution: WorkflowExecution
  147. try {
  148. execution = new WorkflowExecution(init.meta, init.body, init.args, init.limits, observer, children)
  149. } catch (error: unknown) {
  150. post(WorkerToHostType.Result, { result: { value: null, stopReason: 'error', error: renderThrown(error), agentsStarted: 0 } })
  151. return
  152. }
  153. const gate = Promise.withResolvers<void>()
  154. port.on('message', (message: HostToWorkerMessage) => {
  155. switch (message.type) {
  156. case HostToWorkerType.Go:
  157. gate.resolve()
  158. break
  159. case HostToWorkerType.Cancel:
  160. execution.cancel(message.reason)
  161. // A cancel doubles as the gate release: drive() checks the cancelled
  162. // state before running the body, so the script never executes.
  163. gate.resolve()
  164. break
  165. case HostToWorkerType.ChildStarted:
  166. children.onChildStarted(message.callId, message.childId)
  167. break
  168. case HostToWorkerType.ChildStartError:
  169. children.onChildStartError(message.callId, message.rendered)
  170. break
  171. case HostToWorkerType.ChildSettled:
  172. children.onChildSettled(message.callId, message.result)
  173. break
  174. case HostToWorkerType.ChildFailed:
  175. children.onChildFailed(message.callId, message.rendered)
  176. break
  177. case HostToWorkerType.ChildDisposed:
  178. children.onChildDisposed(message.callId)
  179. break
  180. /* v8 ignore next 2 -- closed engine-owned union; the arm only makes adding a message type a compile error */
  181. default:
  182. assertNever(message, 'host-to-worker message')
  183. }
  184. })
  185. post(WorkerToHostType.Ready, {})
  186. await gate.promise
  187. const result = await execution.drive()
  188. post(WorkerToHostType.Result, { result })
  189. }