run.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  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 { readFileSync, writeFileSync } from 'node:fs'
  11. import { createRequire } from 'node:module'
  12. import { dirname, resolve } from 'node:path'
  13. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  14. import { SessionId } from '@deepseek-ai/dsh-session'
  15. import {
  16. settleRunResult,
  17. subprocessRunHandle,
  18. type SubagentResult,
  19. type SubagentRun,
  20. type SubagentStartRequest,
  21. type SubagentStopReason,
  22. } from '@deepseek-ai/dsh-subagent'
  23. import type {
  24. SubprocessHandle,
  25. SubprocessOutcome,
  26. SubprocessSpawnSpec,
  27. } from '@deepseek-ai/dsh-subprocess'
  28. import {
  29. CodexAppServerWire,
  30. type CodexWireFailureFacts,
  31. } from './wire.ts'
  32. /** Default POSIX grace between subprocess termination tiers. */
  33. export const DEFAULT_DISPOSE_GRACE_MS = 3_000
  34. interface CodexPackageManifest {
  35. readonly bin: {
  36. readonly codex: string
  37. }
  38. }
  39. const codexPackageJsonPath = createRequire(import.meta.url).resolve('@openai/codex/package.json')
  40. const codexPackageManifest = JSON.parse(
  41. readFileSync(codexPackageJsonPath, 'utf8'),
  42. ) as CodexPackageManifest
  43. /** Absolute package-local JavaScript wrapper selected by the package manifest. */
  44. const CODEX_PACKAGE_BIN = resolve(
  45. dirname(codexPackageJsonPath),
  46. codexPackageManifest.bin.codex,
  47. )
  48. /** Profile-selectable non-interactive Codex permission mode. */
  49. export type CodexPermissionMode =
  50. | 'never'
  51. | 'approve-for-me'
  52. | 'dangerously-bypass-approvals-and-sandbox'
  53. /** Native non-interactive Codex modes mapped to official `thread/start` fields. */
  54. export const CODEX_PERMISSION_MODES = [
  55. 'never',
  56. 'approve-for-me',
  57. 'dangerously-bypass-approvals-and-sandbox',
  58. ] as const satisfies readonly CodexPermissionMode[]
  59. /** Safe default for unattended Codex runs. */
  60. export const DEFAULT_CODEX_PERMISSION_MODE: CodexPermissionMode = 'never'
  61. type CodexFailureStage =
  62. | 'initialize'
  63. | 'thread-start'
  64. | CodexWireFailureFacts['stage']
  65. | 'process'
  66. | 'teardown'
  67. interface CodexFailureFacts {
  68. readonly stage: CodexFailureStage
  69. readonly category: string
  70. readonly httpStatus?: number | undefined
  71. readonly outcome?: SubprocessOutcome | undefined
  72. }
  73. function failureDiagnostic(facts: CodexFailureFacts): string {
  74. const fields = [
  75. 'product: Codex',
  76. `stage: ${facts.stage}`,
  77. `category: ${facts.category}`,
  78. ]
  79. if (facts.httpStatus !== undefined) {
  80. fields.push(`HTTP status: ${facts.httpStatus}`)
  81. }
  82. const processFields = [
  83. ['exit code', facts.outcome?.exitCode],
  84. ['signal', facts.outcome?.signal],
  85. ] as const
  86. for (const [label, value] of processFields) {
  87. if (value !== null && value !== undefined) fields.push(`${label}: ${value}`)
  88. }
  89. return `Product subagent failure (${fields.join('; ')})`
  90. }
  91. class CodexRunFailure extends Error {
  92. constructor(
  93. readonly facts: CodexFailureFacts,
  94. cause?: unknown,
  95. ) {
  96. super(
  97. `subagent-codex: ${failureDiagnostic(facts)}`,
  98. cause === undefined ? undefined : { cause },
  99. )
  100. this.name = 'CodexRunFailure'
  101. }
  102. }
  103. /**
  104. * Hide an unpublished Host failure behind fixed safe startup facts.
  105. * @param cause Original Host failure retained for internal diagnostics.
  106. * @returns A startup failure whose message contains only fixed safe facts.
  107. */
  108. export function codexStartupFailure(cause: unknown): Error {
  109. return new CodexRunFailure({
  110. stage: 'initialize',
  111. category: 'unknown',
  112. }, cause)
  113. }
  114. /**
  115. * Fixed package-local app-server command, independent of the host `PATH`.
  116. * @returns Node, the official wrapper, and the fixed app-server arguments.
  117. */
  118. export function codexAppServerArgv(): string[] {
  119. return [process.execPath, CODEX_PACKAGE_BIN, 'app-server', '--stdio']
  120. }
  121. /** Fully resolved inputs for one Codex app-server run. */
  122. export interface CodexRunSpec {
  123. /** Parent Session workspace, also supplied to `thread/start`. */
  124. readonly cwd: string
  125. /** Profile-selected native non-interactive permission mode. */
  126. readonly permissionMode: CodexPermissionMode
  127. /** Explicit deployment/test environment layered after the shared scrub. */
  128. readonly env: Record<string, string>
  129. /** Subprocess termination grace passed to the shared process-tree owner. */
  130. readonly disposeGraceMs: number
  131. /** Shared subprocess service spawn operation. */
  132. readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle
  133. /** Diagnostic sink for a post-publication error flattened into a result. */
  134. readonly onError?: (error: Error, stopReason: SubagentStopReason) => void
  135. }
  136. function thrown(value: unknown): Error {
  137. /* v8 ignore next -- typed subprocess/wire failures reject with Error. */
  138. return value instanceof Error ? value : new Error(String(value))
  139. }
  140. /**
  141. * Validate and preserve the one-shot task before crossing the process boundary.
  142. * @param prompt - task content accepted from the shared subagent service.
  143. * @returns the exact non-empty text block sequence.
  144. */
  145. export function textTask(prompt: readonly ContentBlock[]): string[] {
  146. if (prompt.length === 0) {
  147. throw new Error('subagent-codex: the one-shot task must contain only text blocks')
  148. }
  149. const texts: string[] = []
  150. for (const block of prompt) {
  151. if (block.type !== 'text') {
  152. throw new Error('subagent-codex: the one-shot task must contain only text blocks')
  153. }
  154. texts.push(block.text)
  155. }
  156. if (texts.every(text => text.trim().length === 0)) {
  157. throw new Error('subagent-codex: the one-shot task must not be empty')
  158. }
  159. return texts
  160. }
  161. /**
  162. * Close the private wire, terminate the managed process tree, and wait for the
  163. * subprocess owner to prove it is gone.
  164. * @param wire - private app-server protocol connection.
  165. * @param child - shared-service handle that owns the process tree.
  166. */
  167. export async function disposeCodexChild(
  168. wire: CodexAppServerWire,
  169. child: SubprocessHandle,
  170. ): Promise<void> {
  171. wire.close()
  172. if (child.pid > 0) {
  173. let outcome: SubprocessOutcome | undefined
  174. void child.done.then(
  175. (value) => { outcome = value },
  176. /* v8 ignore next -- a positive pid excludes spawn-level done rejection. */
  177. () => {},
  178. )
  179. try {
  180. child.stdin?.end()
  181. } catch {
  182. // A concurrently closed stdin does not change tree ownership below.
  183. }
  184. child.terminate()
  185. try {
  186. await child.waitForExit()
  187. } catch (error: unknown) {
  188. throw new CodexRunFailure({
  189. stage: 'teardown',
  190. category: 'unknown',
  191. outcome,
  192. }, thrown(error))
  193. }
  194. await child.done
  195. } else {
  196. await child.done.catch(() => {})
  197. }
  198. }
  199. /**
  200. * Start the real `codex app-server --stdio` child and publish its one-shot run.
  201. * @param request - resolved shared subagent request.
  202. * @param spec - Workspace, environment, process service, and diagnostic policy.
  203. * @returns the published run after initialization and ephemeral thread creation.
  204. */
  205. export async function startCodexRun(
  206. request: SubagentStartRequest,
  207. spec: CodexRunSpec,
  208. ): Promise<SubagentRun> {
  209. const texts = textTask(request.prompt)
  210. if (request.signal.aborted) {
  211. throw new Error('subagent-codex: request was aborted before app-server startup')
  212. }
  213. let child: SubprocessHandle
  214. try {
  215. child = spec.spawn({
  216. argv: codexAppServerArgv(),
  217. cwd: spec.cwd,
  218. stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
  219. graceMs: spec.disposeGraceMs,
  220. env: spec.env,
  221. })
  222. } catch (error: unknown) {
  223. throw new CodexRunFailure({
  224. stage: 'initialize',
  225. category: 'unknown',
  226. }, thrown(error))
  227. }
  228. const wire = new CodexAppServerWire(
  229. child.stdout as NonNullable<SubprocessHandle['stdout']>,
  230. child.stdin as NonNullable<SubprocessHandle['stdin']>,
  231. spec.permissionMode,
  232. )
  233. const onStderr = (chunk: Buffer | string): void => {
  234. const bytes = typeof chunk === 'string' ? Buffer.from(chunk) : chunk
  235. wire.observeStderr(bytes.toString())
  236. try {
  237. // Synchronous fd forwarding preserves byte order without owning a
  238. // backpressure queue. A slow host sink can block this event-loop turn.
  239. writeFileSync(process.stderr.fd, bytes)
  240. } catch {
  241. // Host stderr is an observation sink, not a child-run failure authority.
  242. }
  243. }
  244. const onStderrError = (): void => {
  245. // Stderr observation is auxiliary. JSON-RPC and child.done remain the
  246. // only terminal authorities if the diagnostic stream itself fails.
  247. }
  248. child.stderr?.on('data', onStderr)
  249. child.stderr?.on('error', onStderrError)
  250. const disposeProcess = async (): Promise<void> => {
  251. try {
  252. await disposeCodexChild(wire, child)
  253. // Let stderr already queued by the process close reach both bounded
  254. // diagnostic consumers before their listeners are detached.
  255. await new Promise<void>((resolve) => { setImmediate(resolve) })
  256. } finally {
  257. child.stderr?.off('data', onStderr)
  258. child.stderr?.off('error', onStderrError)
  259. }
  260. }
  261. let processFailureFacts: CodexFailureFacts | undefined
  262. const processFailure: Promise<never> = child.done.then<never>(
  263. (outcome) => {
  264. processFailureFacts = {
  265. stage: 'process',
  266. category: 'process-exit',
  267. outcome,
  268. }
  269. throw new CodexRunFailure(processFailureFacts)
  270. },
  271. (error: unknown) => {
  272. processFailureFacts = {
  273. stage: 'process',
  274. category: 'unknown',
  275. }
  276. throw new CodexRunFailure(processFailureFacts, thrown(error))
  277. },
  278. )
  279. // A normal post-result dispose also closes the process. Keep its expected
  280. // late rejection observed when the terminal result settles first.
  281. processFailure.catch(() => {})
  282. const runAbort = new AbortController()
  283. const requestCancel = (): void => {
  284. if (runAbort.signal.aborted) return
  285. runAbort.abort(new Error('subagent-codex: run cancelled locally'))
  286. wire.interrupt()
  287. }
  288. const onAbort = (): void => { requestCancel() }
  289. request.signal.addEventListener('abort', onAbort, { once: true })
  290. let startupStage: 'initialize' | 'thread-start' = 'initialize'
  291. try {
  292. wire.start()
  293. await Promise.race([wire.initialize(request.signal), processFailure])
  294. startupStage = 'thread-start'
  295. await Promise.race([wire.startThread(spec.cwd, request.signal), processFailure])
  296. } catch (error: unknown) {
  297. request.signal.removeEventListener('abort', onAbort)
  298. const cancelledBeforeCleanup = runAbort.signal.aborted
  299. if (!(error instanceof CodexRunFailure) && !cancelledBeforeCleanup) {
  300. // Node reports stdout EOF before the child close that owns its outcome.
  301. // Let an already-exiting process publish those facts before rollback.
  302. await new Promise<void>((resolve) => { setImmediate(resolve) })
  303. }
  304. const failure = new CodexRunFailure({
  305. stage: startupStage,
  306. category: 'unknown',
  307. outcome: error instanceof CodexRunFailure
  308. ? error.facts.outcome
  309. : processFailureFacts?.outcome,
  310. }, thrown(error))
  311. try {
  312. await disposeProcess()
  313. } catch (disposeError: unknown) {
  314. const cleanupFailure = thrown(disposeError)
  315. throw new AggregateError(
  316. [failure, cleanupFailure],
  317. `${failure.message}; ${cleanupFailure.message}`,
  318. )
  319. }
  320. if (cancelledBeforeCleanup) {
  321. throw new Error('subagent-codex: request was aborted before run publication')
  322. }
  323. try {
  324. request.signal.throwIfAborted()
  325. } catch {
  326. throw new Error('subagent-codex: request was aborted before run publication')
  327. }
  328. throw failure
  329. }
  330. const collectOutput = (): ContentBlock[] => wire.collectOutput()
  331. let diagnostic: string | undefined
  332. const recordFailureDiagnostic = (facts: CodexFailureFacts): string => {
  333. const failure = failureDiagnostic(facts)
  334. const permission = wire.collectDiagnostic()
  335. diagnostic = permission === undefined
  336. ? failure
  337. : `${failure}\n${permission}`
  338. return diagnostic
  339. }
  340. const withProcessOutcome = (facts: CodexFailureFacts): CodexFailureFacts => {
  341. const outcome = processFailureFacts?.outcome
  342. return outcome === undefined
  343. ? facts
  344. : { ...facts, outcome }
  345. }
  346. const publishedProcessFailure = processFailure.catch(
  347. async (error: unknown): Promise<never> => {
  348. // Frames already queued by the exiting app-server remain authoritative.
  349. // One I/O turn lets them settle before process exit ends the run.
  350. await new Promise<void>((resolve) => { setImmediate(resolve) })
  351. throw error
  352. },
  353. )
  354. const result: Promise<SubagentResult> = settleRunResult({
  355. attempt: async () => {
  356. try {
  357. const terminal = await Promise.race([
  358. wire.runTurn(texts, runAbort.signal),
  359. publishedProcessFailure,
  360. ])
  361. if (terminal.stopReason === 'completed') return terminal
  362. // Let stderr already queued with the terminal frame contribute its
  363. // fixed permission fact before the non-completed result is snapshotted.
  364. await new Promise<void>((resolve) => { setImmediate(resolve) })
  365. const facts = withProcessOutcome(wire.collectFailure())
  366. return { ...terminal, diagnostic: recordFailureDiagnostic(facts) }
  367. } catch (error: unknown) {
  368. // Give stderr data already queued in Node one turn to reach the wire
  369. // before settlement snapshots the diagnostic.
  370. await new Promise<void>((resolve) => { setImmediate(resolve) })
  371. const endedBeforeTerminal = wire.endedBeforeTerminal()
  372. if (
  373. endedBeforeTerminal
  374. && processFailureFacts === undefined
  375. && !runAbort.signal.aborted
  376. ) {
  377. try {
  378. const exited = await child.waitForExit(
  379. AbortSignal.timeout(Math.ceil(spec.disposeGraceMs)),
  380. )
  381. if (exited) await child.done
  382. } catch {
  383. // The wire failure remains authoritative when exit observation fails.
  384. }
  385. }
  386. const facts = error instanceof CodexRunFailure
  387. ? error.facts
  388. : endedBeforeTerminal && processFailureFacts !== undefined
  389. ? processFailureFacts
  390. : withProcessOutcome(wire.collectFailure())
  391. recordFailureDiagnostic(facts)
  392. throw error instanceof CodexRunFailure
  393. ? error
  394. : new CodexRunFailure(facts, thrown(error))
  395. }
  396. },
  397. collectOutput,
  398. collectDiagnostic: () => diagnostic,
  399. cancelled: () => runAbort.signal.aborted,
  400. onError: spec.onError,
  401. signal: request.signal,
  402. onAbort,
  403. })
  404. return subprocessRunHandle({
  405. id: SessionId(randomUUID()),
  406. result,
  407. signal: request.signal,
  408. onAbort,
  409. requestCancel,
  410. teardown: disposeProcess,
  411. })
  412. }