run.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. /**
  2. * One-shot Codex child lifecycle: spawn the real app-server through the
  3. * subprocess seam, publish only after initialization and ephemeral thread
  4. * creation, flatten post-publication failures, and dispose to whole-tree
  5. * quiescence.
  6. *
  7. * @module @deepseek-ai/dsh-subagent-codex/run
  8. */
  9. import { randomUUID } from 'node:crypto'
  10. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  11. import { SessionId } from '@deepseek-ai/dsh-session'
  12. import {
  13. settleRunResult,
  14. subprocessRunHandle,
  15. type SubagentResult,
  16. type SubagentRun,
  17. type SubagentStartRequest,
  18. type SubagentStopReason,
  19. } from '@deepseek-ai/dsh-subagent'
  20. import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
  21. import { CodexAppServerWire } from './wire.ts'
  22. /** Default POSIX grace between subprocess termination tiers. */
  23. export const DEFAULT_DISPOSE_GRACE_MS = 3_000
  24. /**
  25. * Resolve the fixed app-server command for a platform.
  26. *
  27. * Windows npm and pnpm installs expose `codex.cmd`, which requires `cmd.exe`;
  28. * the argv is constant so no task or configuration text enters the
  29. * shell boundary.
  30. * @param platform - host platform used to select the executable boundary.
  31. * @returns argv for the fixed Codex app-server command.
  32. */
  33. export function codexAppServerArgv(
  34. platform: NodeJS.Platform = process.platform,
  35. ): string[] {
  36. return platform === 'win32'
  37. ? ['cmd.exe', '/d', '/s', '/c', 'codex', 'app-server', '--stdio']
  38. : ['codex', 'app-server', '--stdio']
  39. }
  40. /** Fully resolved inputs for one Codex app-server run. */
  41. export interface CodexRunSpec {
  42. /** Parent Session workspace, also supplied to `thread/start`. */
  43. readonly cwd: string
  44. /** Explicit deployment/test environment layered after the shared scrub. */
  45. readonly env: Record<string, string>
  46. /** Subprocess termination grace passed to the shared process-tree owner. */
  47. readonly disposeGraceMs: number
  48. /** Shared subprocess service spawn operation. */
  49. readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle
  50. /** Diagnostic sink for a post-publication error flattened into a result. */
  51. readonly onError?: (error: Error, stopReason: SubagentStopReason) => void
  52. }
  53. function thrown(value: unknown): Error {
  54. /* v8 ignore next -- typed subprocess/wire failures reject with Error. */
  55. return value instanceof Error ? value : new Error(String(value))
  56. }
  57. /**
  58. * Validate and preserve the one-shot task before crossing the process boundary.
  59. * @param prompt - task content accepted from the shared subagent service.
  60. * @returns the exact non-empty text block sequence.
  61. */
  62. export function textTask(prompt: readonly ContentBlock[]): string[] {
  63. if (prompt.length === 0) {
  64. throw new Error('subagent-codex: the one-shot task must contain only text blocks')
  65. }
  66. const texts: string[] = []
  67. for (const block of prompt) {
  68. if (block.type !== 'text') {
  69. throw new Error('subagent-codex: the one-shot task must contain only text blocks')
  70. }
  71. texts.push(block.text)
  72. }
  73. if (texts.every(text => text.trim().length === 0)) {
  74. throw new Error('subagent-codex: the one-shot task must not be empty')
  75. }
  76. return texts
  77. }
  78. /**
  79. * Close the private wire, terminate the managed process tree, and wait for the
  80. * subprocess owner to prove it is gone.
  81. * @param wire - private app-server protocol connection.
  82. * @param child - shared-service handle that owns the process tree.
  83. */
  84. export async function disposeCodexChild(
  85. wire: CodexAppServerWire,
  86. child: SubprocessHandle,
  87. ): Promise<void> {
  88. wire.close()
  89. if (child.pid <= 0) {
  90. await child.done.catch(() => {})
  91. return
  92. }
  93. try {
  94. child.stdin?.end()
  95. } catch {
  96. // A concurrently closed stdin does not change tree ownership below.
  97. }
  98. child.terminate()
  99. await child.waitForExit()
  100. await child.done
  101. }
  102. /**
  103. * Start the real `codex app-server --stdio` child and publish its one-shot run.
  104. * @param request - resolved shared subagent request.
  105. * @param spec - Workspace, environment, process service, and diagnostic policy.
  106. * @returns the published run after initialization and ephemeral thread creation.
  107. */
  108. export async function startCodexRun(
  109. request: SubagentStartRequest,
  110. spec: CodexRunSpec,
  111. ): Promise<SubagentRun> {
  112. const texts = textTask(request.prompt)
  113. if (request.signal.aborted) {
  114. throw new Error('subagent-codex: request was aborted before app-server startup')
  115. }
  116. const child = spec.spawn({
  117. argv: codexAppServerArgv(),
  118. cwd: spec.cwd,
  119. stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
  120. graceMs: spec.disposeGraceMs,
  121. env: spec.env,
  122. })
  123. const wire = new CodexAppServerWire(
  124. child.stdout as NonNullable<SubprocessHandle['stdout']>,
  125. child.stdin as NonNullable<SubprocessHandle['stdin']>,
  126. )
  127. const disposeProcess = (): Promise<void> => disposeCodexChild(wire, child)
  128. const processFailure: Promise<never> = child.done.then(
  129. outcome => Promise.reject(new Error(
  130. 'subagent-codex: app-server exited before the run settled '
  131. + `(code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`,
  132. )),
  133. (error: unknown) => Promise.reject(thrown(error)),
  134. )
  135. // A normal post-result dispose also closes the process. Keep that expected
  136. // late rejection observed after the result race has already settled.
  137. processFailure.catch(() => {})
  138. const runAbort = new AbortController()
  139. const requestCancel = (): void => {
  140. if (runAbort.signal.aborted) return
  141. runAbort.abort(new Error('subagent-codex: run cancelled locally'))
  142. wire.interrupt()
  143. }
  144. const onAbort = (): void => { requestCancel() }
  145. request.signal.addEventListener('abort', onAbort, { once: true })
  146. try {
  147. wire.start()
  148. await Promise.race([wire.initialize(request.signal), processFailure])
  149. await Promise.race([wire.startThread(spec.cwd, request.signal), processFailure])
  150. } catch (error: unknown) {
  151. request.signal.removeEventListener('abort', onAbort)
  152. try {
  153. await disposeProcess()
  154. } catch (disposeError: unknown) {
  155. throw new AggregateError(
  156. [thrown(error), thrown(disposeError)],
  157. 'subagent-codex: startup failed and app-server cleanup also failed',
  158. )
  159. }
  160. if (runAbort.signal.aborted) {
  161. throw new Error('subagent-codex: request was aborted before run publication')
  162. }
  163. throw thrown(error)
  164. }
  165. const collectOutput = (): ContentBlock[] => wire.collectOutput()
  166. const result: Promise<SubagentResult> = settleRunResult({
  167. attempt: () => Promise.race([
  168. wire.runTurn(texts, runAbort.signal),
  169. processFailure,
  170. ]),
  171. collectOutput,
  172. cancelled: () => runAbort.signal.aborted,
  173. onError: spec.onError,
  174. signal: request.signal,
  175. onAbort,
  176. })
  177. return subprocessRunHandle({
  178. id: SessionId(randomUUID()),
  179. result,
  180. signal: request.signal,
  181. onAbort,
  182. requestCancel,
  183. teardown: disposeProcess,
  184. })
  185. }