session.ts 7.9 KB

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