feat(subagent): add Codex product provider
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise<boolean> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => { controller.abort() }, ms)
|
||||
try {
|
||||
return await child.waitForExit(controller.signal)
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 treeExitsWithin(child, 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,
|
||||
})
|
||||
if (child.stdin === undefined || child.stdout === undefined) {
|
||||
child.terminate()
|
||||
await child.waitForExit()
|
||||
throw new Error('subagent-codex: subprocess implementation dropped a piped protocol stream')
|
||||
}
|
||||
|
||||
const wire = new CodexAppServerWire(child.stdout, child.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 flags = { cancelled: false }
|
||||
const runAbort = new AbortController()
|
||||
let settleCancellation!: () => void
|
||||
const cancellation = new Promise<void>((resolve) => { settleCancellation = resolve })
|
||||
const requestCancel = (): void => {
|
||||
if (flags.cancelled) return
|
||||
flags.cancelled = true
|
||||
runAbort.abort(new Error('subagent-codex: run cancelled locally'))
|
||||
settleCancellation()
|
||||
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 (flags.cancelled) {
|
||||
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, () => flags.cancelled),
|
||||
processFailure,
|
||||
cancellation.then((): SubagentResult => ({
|
||||
output: collectOutput(),
|
||||
stopReason: 'aborted',
|
||||
})),
|
||||
]),
|
||||
collectOutput,
|
||||
cancelled: () => flags.cancelled,
|
||||
onError: spec.onError,
|
||||
signal: request.signal,
|
||||
onAbort,
|
||||
})
|
||||
|
||||
return subprocessRunHandle({
|
||||
id: SessionId(randomUUID()),
|
||||
result,
|
||||
signal: request.signal,
|
||||
onAbort,
|
||||
requestCancel,
|
||||
teardown: disposeProcess,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user