refactor(worker): move Codex turn logic into CodexAdapter behind AgentRuntime
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type { NormalizedAgentEvent } from '../agent-events';
|
||||
import { normalizeCodexJsonLine } from '../agent-events';
|
||||
import { prepareCodexWorkspaceFiles } from '../codex-runtime';
|
||||
import { env } from '../env';
|
||||
import type { AdapterFactory, TurnResult } from './agent-runtime';
|
||||
import {
|
||||
buildMarkedCommand,
|
||||
killBoxProcessesByMarker,
|
||||
streamExecInContainer,
|
||||
} from './docker';
|
||||
import type { AdapterWorkspace } from './provider';
|
||||
import { codexModelArgs, providerEnvironment } from './provider';
|
||||
|
||||
// Reused verbatim from the worker: normalize + pretty-print an auth JSON blob
|
||||
// before writing it with owner-only permissions.
|
||||
const writeJsonFile = async (filePath: string, content: string) => {
|
||||
let normalized = content.trim();
|
||||
try {
|
||||
normalized = `${JSON.stringify(JSON.parse(normalized), null, 2)}\n`;
|
||||
} catch {
|
||||
throw new Error('Codex auth JSON is not valid JSON.');
|
||||
}
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, normalized, { mode: 0o600 });
|
||||
};
|
||||
|
||||
const isNoise = (line: string) => {
|
||||
const trimmed = line.trim();
|
||||
return (
|
||||
!trimmed ||
|
||||
trimmed === 'Reading additional input from stdin...' ||
|
||||
trimmed.includes('`[features].codex_hooks` is deprecated')
|
||||
);
|
||||
};
|
||||
|
||||
export const createCodexAdapter: AdapterFactory = (deps) => {
|
||||
const execStream = deps?.execStream ?? streamExecInContainer;
|
||||
|
||||
const prepareAuth = async (workspace: AdapterWorkspace) => {
|
||||
const secret = workspace.claim.aiProviderProfile?.secret;
|
||||
if (!secret) {
|
||||
throw new Error('Codex auth profile is missing auth.json contents.');
|
||||
}
|
||||
await prepareCodexWorkspaceFiles({
|
||||
workdir: workspace.workdir,
|
||||
repoDir: workspace.repoDir,
|
||||
});
|
||||
const codexAuthPath = path.join(workspace.workdir, '.codex', 'auth.json');
|
||||
await writeJsonFile(codexAuthPath, secret);
|
||||
|
||||
// Also seed OpenCode's auth location with the saved JSON for forward
|
||||
// compatibility if this profile later runs through OpenCode directly.
|
||||
const openCodeAuthPath = path.join(
|
||||
workspace.workdir,
|
||||
'.local',
|
||||
'share',
|
||||
'opencode',
|
||||
'auth.json',
|
||||
);
|
||||
await writeJsonFile(openCodeAuthPath, secret);
|
||||
};
|
||||
|
||||
const runTurn = async (
|
||||
workspace: AdapterWorkspace,
|
||||
prompt: string,
|
||||
onEvent: (event: NormalizedAgentEvent) => Promise<void>,
|
||||
): Promise<TurnResult> => {
|
||||
workspace.runtime = 'codex';
|
||||
workspace.turnMarker = `spoon-turn-${randomUUID()}`;
|
||||
const marker = workspace.turnMarker;
|
||||
|
||||
const outputFileName = `last-message-${workspace.claim.job._id}.txt`;
|
||||
const outputFileHostPath = path.join(
|
||||
workspace.workdir,
|
||||
'.codex',
|
||||
outputFileName,
|
||||
);
|
||||
const outputFileContainerPath = path.posix.join(
|
||||
workspace.containerHome,
|
||||
'.codex',
|
||||
outputFileName,
|
||||
);
|
||||
const codexArgv = workspace.codexSessionId
|
||||
? [
|
||||
'codex',
|
||||
'exec',
|
||||
'resume',
|
||||
'--json',
|
||||
...codexModelArgs(workspace.claim),
|
||||
'--dangerously-bypass-approvals-and-sandbox',
|
||||
'--output-last-message',
|
||||
outputFileContainerPath,
|
||||
workspace.codexSessionId,
|
||||
prompt,
|
||||
]
|
||||
: [
|
||||
'codex',
|
||||
'exec',
|
||||
'--json',
|
||||
...codexModelArgs(workspace.claim),
|
||||
'--dangerously-bypass-approvals-and-sandbox',
|
||||
'--output-last-message',
|
||||
outputFileContainerPath,
|
||||
'--cd',
|
||||
workspace.containerRepo,
|
||||
prompt,
|
||||
];
|
||||
const command = buildMarkedCommand(marker, codexArgv);
|
||||
|
||||
const aiEnv = providerEnvironment(workspace.claim, workspace.containerHome);
|
||||
const secretEnv = Object.fromEntries(
|
||||
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
|
||||
);
|
||||
|
||||
let capturedSessionId: string | undefined;
|
||||
let capturedError: string | undefined;
|
||||
|
||||
// execa's own `timeout` only kills the local docker-exec client, not the
|
||||
// in-box process group. Schedule an explicit marker kill on timeout and
|
||||
// clear it once the exec resolves.
|
||||
const killTimer = setTimeout(() => {
|
||||
void killBoxProcessesByMarker({
|
||||
containerName: workspace.boxName,
|
||||
marker,
|
||||
});
|
||||
}, env.jobTimeoutMs);
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await execStream({
|
||||
containerName: workspace.boxName,
|
||||
containerCwd: workspace.containerRepo,
|
||||
command,
|
||||
environment: { ...aiEnv, ...secretEnv },
|
||||
redact: workspace.redact,
|
||||
timeoutMs: env.jobTimeoutMs,
|
||||
onStdoutLine: async (line) => {
|
||||
for (const event of normalizeCodexJsonLine(line)) {
|
||||
if (event.kind === 'session') capturedSessionId = event.sessionId;
|
||||
if (event.kind === 'error') capturedError = event.message;
|
||||
await onEvent(event);
|
||||
}
|
||||
},
|
||||
onStderrLine: async (line) => {
|
||||
if (isNoise(line)) return;
|
||||
await onEvent({ kind: 'status', status: line });
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(killTimer);
|
||||
}
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
capturedError = result.output;
|
||||
}
|
||||
|
||||
let finalMessage: string | undefined;
|
||||
try {
|
||||
const lastMessage = await readFile(outputFileHostPath, 'utf8');
|
||||
if (lastMessage.trim()) finalMessage = lastMessage.trim();
|
||||
} catch (error) {
|
||||
const code = error && typeof error === 'object' ? 'code' in error : false;
|
||||
if (!code || (error as { code?: string }).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return { finalMessage, sessionId: capturedSessionId, error: capturedError };
|
||||
};
|
||||
|
||||
const abort = async (workspace: AdapterWorkspace) => {
|
||||
if (!workspace.turnMarker) return;
|
||||
await killBoxProcessesByMarker({
|
||||
containerName: workspace.boxName,
|
||||
marker: workspace.turnMarker,
|
||||
});
|
||||
};
|
||||
|
||||
return { name: 'codex', prepareAuth, runTurn, abort };
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import { registerAdapter } from './agent-runtime';
|
||||
import { createCodexAdapter } from './codex-adapter';
|
||||
|
||||
registerAdapter('codex', createCodexAdapter);
|
||||
Reference in New Issue
Block a user