launcher.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. /**
  2. * Shared launcher for ACP tests that drive an agent subprocess over JSON-RPC
  3. * stdio. It owns source-or-built launch resolution, workspace environment,
  4. * stdout tee, SDK client, update collection, permission fallback, and process
  5. * shutdown so e2e and snapshot suites do not each reconstruct that boundary.
  6. *
  7. * @module @deepseek-ai/dsh-acp-snapshot/launcher
  8. */
  9. import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
  10. import { join } from 'node:path'
  11. import { Readable, Writable } from 'node:stream'
  12. import {
  13. ClientSideConnection,
  14. ndJsonStream,
  15. type Agent as AcpAgent,
  16. type Client,
  17. type RequestPermissionRequest,
  18. type RequestPermissionResponse,
  19. type SessionNotification,
  20. } from '@agentclientprotocol/sdk'
  21. import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
  22. const EXIT_MARKER_GRACE_MS = 250
  23. /** The source/built agent entry, leaf config, and workspace tsconfig an ACP test boots. */
  24. export interface AgentUnderTest {
  25. /** The agent source bin entry (for example `packages/examples/acp-demo/src/bin.ts`). */
  26. binScript: string
  27. /** Explicit built-mode entry for fixtures whose source path is not under `src/`. */
  28. libBinScript?: string | undefined
  29. /** The leaf `cordis.yml` loaded by the bin. */
  30. configPath: string
  31. /** The repo tsconfig whose paths resolve unbuilt workspace imports. */
  32. tsconfigPath: string
  33. }
  34. /** Options for one ACP test subprocess. */
  35. export interface AcpTestLaunchOptions {
  36. /** The agent composition to boot. */
  37. agent: AgentUnderTest
  38. /** Process cwd and default session-home root. */
  39. cwd: string
  40. /** Alternate leaf config for this launch. */
  41. configPath?: string
  42. /** Extra environment values layered over the parent environment. */
  43. env?: NodeJS.ProcessEnv
  44. /** Permission handler; omitted requests fail closed as `cancelled`. */
  45. requestPermission?: (params: RequestPermissionRequest) => Promise<RequestPermissionResponse>
  46. }
  47. /** A running ACP test process and its captured client-side surfaces. */
  48. export interface LaunchedAcpTestAgent {
  49. /** The child process, exposed for process-level assertions. */
  50. child: ChildProcessWithoutNullStreams
  51. /** Resolve when the OS spawns the child; reject with its asynchronous spawn failure. */
  52. spawned: Promise<void>
  53. /** The SDK connection backed by the child's stdio. */
  54. client: ClientSideConnection
  55. /** Session updates in receive order. */
  56. updates: SessionNotification['update'][]
  57. /** Decode all stdout bytes captured so far. */
  58. rawStdout(): string
  59. /** Decode all stderr chunks captured so far. */
  60. stderr(): string
  61. /** Resolve when a future session update matches the predicate. */
  62. waitForUpdate(match: (update: SessionNotification['update']) => boolean): Promise<SessionNotification['update']>
  63. /** Close the process and drain its streams and callbacks; rejects promptly if fallback termination is refused. */
  64. close(signal?: NodeJS.Signals): Promise<void>
  65. }
  66. /**
  67. * Boot an ACP agent subprocess and connect an SDK client to its stdio.
  68. *
  69. * @param options Agent paths, cwd, environment, and optional permission handler.
  70. * @returns The running process, connected client, captures, and shutdown handle.
  71. */
  72. export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTestAgent {
  73. const { agent, cwd } = options
  74. const launch = resolveExampleLaunch({
  75. srcBin: agent.binScript,
  76. libBin: agent.libBinScript,
  77. configArgs: ['--config', options.configPath ?? agent.configPath],
  78. tsconfigPath: agent.tsconfigPath,
  79. env: {
  80. ...options.env,
  81. DSH_HOME: join(cwd, '.dsh'),
  82. DSH_AGENTS_HOME: join(cwd, '.agents'),
  83. },
  84. })
  85. const child = spawn(
  86. launch.command,
  87. launch.args,
  88. {
  89. cwd,
  90. env: { ...process.env, ...launch.env },
  91. stdio: ['pipe', 'pipe', 'pipe'],
  92. },
  93. )
  94. // A spawn-level failure is an asynchronous `error` event. Observe it in the
  95. // same tick as spawn so a missing cwd or OS rejection cannot crash the test
  96. // runner, then make startup and shutdown surface the original error.
  97. // Keep observing after the first error: a fallback kill attempted during
  98. // shutdown may itself report another process error, which must not become an
  99. // unhandled EventEmitter error after the promise has already settled.
  100. const childFailure = new Promise<Error>(resolve => child.on('error', resolve))
  101. const spawned = Promise.race([
  102. new Promise<void>(resolve => child.once('spawn', resolve)),
  103. childFailure.then((error): never => { throw error }),
  104. ])
  105. // `spawned` is public and close() also awaits it, but a caller may ignore both.
  106. // Keep that misuse from turning the already-observed child error into an
  107. // unhandled promise rejection.
  108. void spawned.catch(() => undefined)
  109. const stderrChunks: string[] = []
  110. child.stderr.setEncoding('utf8')
  111. child.stderr.on('data', (chunk: string) => stderrChunks.push(chunk))
  112. const rawBuffers: Buffer[] = []
  113. const passthrough = new Readable({ read() {} })
  114. const updates: SessionNotification['update'][] = []
  115. const updateWaiters: {
  116. match: (update: SessionNotification['update']) => boolean
  117. resolve: (update: SessionNotification['update']) => void
  118. reject: (reason: unknown) => void
  119. }[] = []
  120. let updateStreamFailure: Error | undefined
  121. const closeUpdateStream = (): void => {
  122. if (updateStreamFailure !== undefined) return
  123. updateStreamFailure = new Error('ACP test agent update stream closed before a matching session update arrived')
  124. for (const waiter of updateWaiters.splice(0)) waiter.reject(updateStreamFailure)
  125. }
  126. child.stdout.on('data', (buffer: Buffer) => {
  127. rawBuffers.push(buffer)
  128. passthrough.push(buffer)
  129. })
  130. child.stdout.on('end', () => {
  131. passthrough.push(null)
  132. })
  133. const stream = ndJsonStream(
  134. Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
  135. Readable.toWeb(passthrough) as ReadableStream<Uint8Array>,
  136. )
  137. const inFlightClientCallbacks = new Set<Promise<unknown>>()
  138. const trackClientCallback = <T>(callback: () => T | PromiseLike<T>): Promise<T> => {
  139. const pending = Promise.resolve().then(callback)
  140. inFlightClientCallbacks.add(pending)
  141. const untrack = (): void => { inFlightClientCallbacks.delete(pending) }
  142. void pending.then(untrack, untrack)
  143. return pending
  144. }
  145. const requestPermission = options.requestPermission
  146. ?? (() => Promise.resolve({ outcome: { outcome: 'cancelled' as const } }))
  147. const makeClient = (_agent: AcpAgent): Client => ({
  148. sessionUpdate(params: SessionNotification): Promise<void> {
  149. return trackClientCallback(() => {
  150. updates.push(params.update)
  151. for (let index = updateWaiters.length - 1; index >= 0; index--) {
  152. const waiter = updateWaiters[index]
  153. /* v8 ignore next 1 -- index is bounded by the array length */
  154. if (waiter === undefined) continue
  155. let matches: boolean
  156. try {
  157. matches = waiter.match(params.update)
  158. } catch (error: unknown) {
  159. updateWaiters.splice(index, 1)
  160. waiter.reject(error)
  161. continue
  162. }
  163. if (!matches) continue
  164. updateWaiters.splice(index, 1)
  165. waiter.resolve(params.update)
  166. }
  167. })
  168. },
  169. requestPermission: params => trackClientCallback(() => requestPermission(params)),
  170. })
  171. const client = new ClientSideConnection(makeClient, stream)
  172. // `exit` only reports the parent process's status. Descendants may retain
  173. // inherited stdout/stderr handles and buffered ACP frames may still be
  174. // crossing the SDK parser. Node's `close` follows stdio closure; the SDK's
  175. // `closed` follows parser exhaustion. Capture both eagerly so a caller that
  176. // invokes close after process exit still joins the complete drain boundary.
  177. const stdioClosed = new Promise<void>(resolve => child.once('close', () => { resolve() }))
  178. const drained = Promise.all([stdioClosed, client.closed]).then(async () => {
  179. // The ACP SDK's readable loop dispatches client callbacks without awaiting
  180. // them. Once `closed` settles no new callbacks can start, but callbacks
  181. // already in flight still belong to this launch's teardown boundary.
  182. while (inFlightClientCallbacks.size > 0) {
  183. await Promise.allSettled([...inFlightClientCallbacks])
  184. }
  185. })
  186. // A caller may await a pending update without calling close(). Make natural
  187. // stream exhaustion terminal for those waiters too, but only after the
  188. // parser has dispatched every buffered frame.
  189. void client.closed.then(closeUpdateStream)
  190. return {
  191. child,
  192. spawned,
  193. client,
  194. updates,
  195. rawStdout: () => Buffer.concat(rawBuffers).toString('utf8'),
  196. stderr: () => stderrChunks.join(''),
  197. waitForUpdate(match): Promise<SessionNotification['update']> {
  198. if (updateStreamFailure !== undefined) return Promise.reject(updateStreamFailure)
  199. return new Promise((resolve, reject) => updateWaiters.push({ match, resolve, reject }))
  200. },
  201. async close(signal?: NodeJS.Signals): Promise<void> {
  202. try {
  203. await spawned
  204. } catch (error: unknown) {
  205. await drained
  206. closeUpdateStream()
  207. throw error
  208. }
  209. if (!isRunning(child)) {
  210. await drained
  211. closeUpdateStream()
  212. return
  213. }
  214. const exited = waitForExit(child)
  215. if (signal === undefined) child.stdin.end()
  216. else child.kill(signal)
  217. const failure = await Promise.race([
  218. exited.then((): undefined => undefined),
  219. childFailure,
  220. ])
  221. if (failure === undefined) {
  222. await drained
  223. closeUpdateStream()
  224. return
  225. }
  226. const propagateFailureAfterDrain = async (): Promise<never> => {
  227. await drained
  228. closeUpdateStream()
  229. throw failure
  230. }
  231. // Windows implements the supported signal names as forced termination. The exit markers
  232. // may therefore arrive after the error wins the race above but before fallback begins.
  233. if (!isRunning(child) || await exitMarkerWithinGrace(exited)) return propagateFailureAfterDrain()
  234. // An `error` after spawn is not an exit edge: in particular, a failed
  235. // signal can leave the subprocess live. Force termination, await the
  236. // already-observed exit edge, and only then propagate the child error so
  237. // callers may safely remove cwd/session resources after close rejects.
  238. const fallbackError = Promise.withResolvers<Error>()
  239. const observeFallbackError = (error: Error): void => { fallbackError.resolve(error) }
  240. child.once('error', observeFallbackError)
  241. if (!child.kill('SIGKILL')) {
  242. child.off('error', observeFallbackError)
  243. // A successful earlier signal may win between the live check and this fallback call.
  244. // In that case `kill()` correctly reports no process to signal; the original child error
  245. // remains the shutdown result once inherited stdio and callbacks have drained.
  246. if (!isRunning(child) || await exitMarkerWithinGrace(exited)) return propagateFailureAfterDrain()
  247. closeUpdateStream()
  248. throw new AggregateError(
  249. [failure, new Error('Fallback SIGKILL was not accepted by the child process')],
  250. 'ACP test agent failed and fallback termination was refused',
  251. )
  252. }
  253. const fallbackFailure = await Promise.race([
  254. exited.then((): undefined => undefined),
  255. fallbackError.promise,
  256. ])
  257. child.off('error', observeFallbackError)
  258. if (fallbackFailure !== undefined) {
  259. closeUpdateStream()
  260. throw new AggregateError(
  261. [failure, fallbackFailure],
  262. 'ACP test agent failed and fallback termination was refused',
  263. )
  264. }
  265. return propagateFailureAfterDrain()
  266. },
  267. }
  268. }
  269. /** Resolve once a running child exits. */
  270. function waitForExit(child: ChildProcessWithoutNullStreams): Promise<void> {
  271. return new Promise<void>(resolve => child.once('exit', () => { resolve() }))
  272. }
  273. /** Give an accepted Windows termination request a bounded window to publish its exit marker. */
  274. function exitMarkerWithinGrace(exited: Promise<void>): Promise<boolean> {
  275. return Promise.race([
  276. exited.then(() => true),
  277. new Promise<false>((resolve) => {
  278. const timer = setTimeout(() => { resolve(false) }, EXIT_MARKER_GRACE_MS)
  279. timer.unref()
  280. }),
  281. ])
  282. }
  283. /** Whether the child still lacks either OS termination marker. */
  284. function isRunning(child: ChildProcessWithoutNullStreams): boolean {
  285. return child.exitCode === null && child.signalCode === null
  286. }