Files
deepseek-harness/packages/subagent/subagent-codex/src/run.ts

189 lines
6.4 KiB
TypeScript

/**
* One-shot Codex child lifecycle: spawn the real app-server through the
* subprocess seam, publish only after initialization and ephemeral thread
* creation, flatten post-publication failures, and dispose to whole-tree
* quiescence.
*
* @module @deepseek-ai/dsh-subagent-codex/run
*/
import { randomUUID } from 'node:crypto'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import {
settleRunResult,
subprocessRunHandle,
type SubagentResult,
type SubagentRun,
type SubagentStartRequest,
type SubagentStopReason,
} from '@deepseek-ai/dsh-subagent'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { CodexAppServerWire } from './wire.ts'
/** Default POSIX grace between subprocess termination tiers. */
export const DEFAULT_DISPOSE_GRACE_MS = 3_000
/** Fully resolved inputs for one Codex app-server run. */
export interface CodexRunSpec {
/** Parent Session workspace, also supplied to `thread/start`. */
readonly cwd: string
/** Explicit deployment/test environment layered after the shared scrub. */
readonly env: Record<string, string>
/** Subprocess termination grace and final tree-exit bound. */
readonly disposeGraceMs: number
/** Shared subprocess service spawn operation. */
readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle
/** Diagnostic sink for a post-publication error flattened into a result. */
readonly onError?: (error: Error, stopReason: SubagentStopReason) => void
}
function thrown(value: unknown): Error {
/* v8 ignore next -- typed subprocess/wire failures reject with Error. */
return value instanceof Error ? value : new Error(String(value))
}
/**
* Validate and preserve the one-shot task before crossing the process seam.
* @param prompt - task content accepted from the shared subagent service.
* @returns the exact non-empty text block sequence.
*/
export function textTask(prompt: readonly ContentBlock[]): string[] {
if (prompt.length === 0) {
throw new Error('subagent-codex: the one-shot task must contain only text blocks')
}
const texts: string[] = []
for (const block of prompt) {
if (block.type !== 'text') {
throw new Error('subagent-codex: the one-shot task must contain only text blocks')
}
texts.push(block.text)
}
if (texts.every(text => text.trim().length === 0)) {
throw new Error('subagent-codex: the one-shot task must not be empty')
}
return texts
}
/**
* Close the private wire, terminate the managed process tree, and wait for the
* subprocess owner to prove it is gone.
* @param wire - private app-server protocol connection.
* @param child - shared-service handle that owns the process tree.
* @param graceMs - termination grace used to bound final exit observation.
*/
export async function disposeCodexChild(
wire: CodexAppServerWire,
child: SubprocessHandle,
graceMs: number,
): Promise<void> {
wire.close()
if (child.pid <= 0) {
await child.done.catch(() => {})
return
}
try {
child.stdin?.end()
} catch {
// A concurrently closed stdin does not change tree ownership below.
}
child.terminate()
if (!(await child.waitForExit(AbortSignal.timeout(graceMs * 2)))) {
throw new Error('subagent-codex: app-server process tree did not exit within its dispose window')
}
await child.done
}
/**
* Start the real `codex app-server --stdio` child and publish its one-shot run.
* @param request - resolved shared subagent request.
* @param spec - workspace, environment, process seam, and diagnostic policy.
* @returns the published run after initialization and ephemeral thread creation.
*/
export async function startCodexRun(
request: SubagentStartRequest,
spec: CodexRunSpec,
): Promise<SubagentRun> {
const texts = textTask(request.prompt)
if (request.signal.aborted) {
throw new Error('subagent-codex: request was aborted before app-server startup')
}
const child = spec.spawn({
argv: ['codex', 'app-server', '--stdio'],
cwd: spec.cwd,
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
graceMs: spec.disposeGraceMs,
env: spec.env,
})
const wire = new CodexAppServerWire(
child.stdout as NonNullable<SubprocessHandle['stdout']>,
child.stdin as NonNullable<SubprocessHandle['stdin']>,
)
const disposeProcess = (): Promise<void> =>
disposeCodexChild(wire, child, spec.disposeGraceMs)
const processFailure: Promise<never> = child.done.then(
outcome => Promise.reject(new Error(
'subagent-codex: app-server exited before the run settled '
+ `(code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`,
)),
(error: unknown) => Promise.reject(thrown(error)),
)
// A normal post-result dispose also closes the process. Keep that expected
// late rejection observed after the result race has already settled.
processFailure.catch(() => {})
const runAbort = new AbortController()
const requestCancel = (): void => {
if (runAbort.signal.aborted) return
runAbort.abort(new Error('subagent-codex: run cancelled locally'))
wire.interrupt()
}
const onAbort = (): void => { requestCancel() }
request.signal.addEventListener('abort', onAbort, { once: true })
try {
wire.start()
await Promise.race([wire.initialize(request.signal), processFailure])
await Promise.race([wire.startThread(spec.cwd, request.signal), processFailure])
} catch (error: unknown) {
request.signal.removeEventListener('abort', onAbort)
try {
await disposeProcess()
} catch (disposeError: unknown) {
throw new AggregateError(
[thrown(error), thrown(disposeError)],
'subagent-codex: startup failed and app-server cleanup also failed',
)
}
if (runAbort.signal.aborted) {
throw new Error('subagent-codex: request was aborted before app-server startup')
}
throw thrown(error)
}
const collectOutput = (): ContentBlock[] => wire.collectOutput()
const result: Promise<SubagentResult> = settleRunResult({
attempt: () => Promise.race([
wire.runTurn(texts, runAbort.signal, () => runAbort.signal.aborted),
processFailure,
]),
collectOutput,
cancelled: () => runAbort.signal.aborted,
onError: spec.onError,
signal: request.signal,
onAbort,
})
return subprocessRunHandle({
id: SessionId(randomUUID()),
result,
signal: request.signal,
onAbort,
requestCancel,
teardown: disposeProcess,
})
}