run.ts 15 KB

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