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);
|
||||||
+81
-268
@@ -18,9 +18,8 @@ import type { NormalizedAgentEvent } from './agent-events';
|
|||||||
import type { OpenCodeSession } from './opencode-session';
|
import type { OpenCodeSession } from './opencode-session';
|
||||||
import type { AdapterWorkspace, Claim } from './runtime/provider';
|
import type { AdapterWorkspace, Claim } from './runtime/provider';
|
||||||
import type { BoxHandle } from './user-container';
|
import type { BoxHandle } from './user-container';
|
||||||
import { normalizeCodexJsonLine } from './agent-events';
|
import { getAdapter } from './runtime/agent-runtime';
|
||||||
import { abortManagedCodexProcess, managedCodexCommand } from './codex-process';
|
import './runtime/register';
|
||||||
import { prepareCodexWorkspaceFiles } from './codex-runtime';
|
|
||||||
import { env } from './env';
|
import { env } from './env';
|
||||||
import {
|
import {
|
||||||
cloneRepository,
|
cloneRepository,
|
||||||
@@ -44,10 +43,8 @@ import {
|
|||||||
runExecInContainer,
|
runExecInContainer,
|
||||||
startWorkspaceContainer,
|
startWorkspaceContainer,
|
||||||
stopWorkspaceContainer,
|
stopWorkspaceContainer,
|
||||||
streamExecInContainer,
|
|
||||||
} from './runtime/docker';
|
} from './runtime/docker';
|
||||||
import {
|
import {
|
||||||
codexModelArgs,
|
|
||||||
collectJsonStringValues,
|
collectJsonStringValues,
|
||||||
isCodexLoginProfile,
|
isCodexLoginProfile,
|
||||||
opencodeModel,
|
opencodeModel,
|
||||||
@@ -66,15 +63,10 @@ type ActiveWorkspace = AdapterWorkspace & {
|
|||||||
containerId?: string;
|
containerId?: string;
|
||||||
opencodePassword?: string;
|
opencodePassword?: string;
|
||||||
opencodeSession?: OpenCodeSession;
|
opencodeSession?: OpenCodeSession;
|
||||||
codexPidFile?: string;
|
|
||||||
agentTurnActive?: boolean;
|
agentTurnActive?: boolean;
|
||||||
cancelRequested?: boolean;
|
cancelRequested?: boolean;
|
||||||
resolveTurn?: () => void;
|
resolveTurn?: () => void;
|
||||||
lastRecordedDiffSignature?: string;
|
lastRecordedDiffSignature?: string;
|
||||||
// Captures the most recent Codex `error`/`turn.failed` event for the active
|
|
||||||
// turn so the failure surfaces the real reason instead of a generic
|
|
||||||
// "no assistant response" message.
|
|
||||||
codexTurnError?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type FileTreeNode = {
|
type FileTreeNode = {
|
||||||
@@ -280,6 +272,22 @@ const setCodexSessionId = async (
|
|||||||
codexSessionId,
|
codexSessionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Persist the runtime session id for continuity, keyed on the workspace runtime
|
||||||
|
// (replaces the per-runtime setter call sites so dispatch stays generic).
|
||||||
|
const persistRuntimeSession = async (
|
||||||
|
workspace: ActiveWorkspace,
|
||||||
|
sessionId: string,
|
||||||
|
) => {
|
||||||
|
if (workspace.runtime === 'codex') {
|
||||||
|
workspace.codexSessionId = sessionId;
|
||||||
|
await setCodexSessionId(workspace.claim.job._id, sessionId);
|
||||||
|
} else if (workspace.runtime === 'opencode') {
|
||||||
|
workspace.opencodeSessionId = sessionId;
|
||||||
|
} else {
|
||||||
|
workspace.claudeSessionId = sessionId;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const createInteractionRequest = async (args: {
|
const createInteractionRequest = async (args: {
|
||||||
jobId: Id<'agentJobs'>;
|
jobId: Id<'agentJobs'>;
|
||||||
runtime: 'opencode' | 'codex';
|
runtime: 'opencode' | 'codex';
|
||||||
@@ -326,48 +334,6 @@ const commandToShell = (command: string) => ['bash', '-lc', command];
|
|||||||
const workspaceContainerName = (jobId: string) =>
|
const workspaceContainerName = (jobId: string) =>
|
||||||
`spoon-agent-job-${jobId.replace(/[^a-zA-Z0-9_.-]/g, '-')}`;
|
`spoon-agent-job-${jobId.replace(/[^a-zA-Z0-9_.-]/g, '-')}`;
|
||||||
|
|
||||||
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 prepareCodexAuth = async (workspace: ActiveWorkspace) => {
|
|
||||||
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);
|
|
||||||
|
|
||||||
await appendEvent(
|
|
||||||
workspace.claim.job._id,
|
|
||||||
'info',
|
|
||||||
'clone',
|
|
||||||
'Prepared Codex auth JSON for the isolated workspace.',
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const agentFailurePrefix = (claim: Claim) =>
|
const agentFailurePrefix = (claim: Claim) =>
|
||||||
isCodexLoginProfile(claim) ? 'codex failed' : 'opencode failed';
|
isCodexLoginProfile(claim) ? 'codex failed' : 'opencode failed';
|
||||||
|
|
||||||
@@ -412,9 +378,11 @@ const handleAgentEvent = async (args: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.kind === 'session') {
|
if (event.kind === 'session') {
|
||||||
|
// Session persistence is centralized in persistRuntimeSession (called from
|
||||||
|
// the dispatch after the turn); here we only track it in memory so a later
|
||||||
|
// turn can resume.
|
||||||
if (workspace.runtimeMode === 'codex_exec') {
|
if (workspace.runtimeMode === 'codex_exec') {
|
||||||
workspace.codexSessionId = event.sessionId;
|
workspace.codexSessionId = event.sessionId;
|
||||||
await setCodexSessionId(jobId, event.sessionId);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -491,10 +459,9 @@ const handleAgentEvent = async (args: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// event.kind === 'error'
|
// event.kind === 'error'
|
||||||
// Record the real Codex failure reason on the workspace so the turn can
|
// The adapter captures the real Codex failure reason (Codex can emit
|
||||||
// surface it (Codex can emit `error`/`turn.failed` events and still exit 0
|
// `error`/`turn.failed` events and still exit 0) and surfaces it as
|
||||||
// in some versions, which otherwise looks like an empty response).
|
// TurnResult.error; here we just log it.
|
||||||
workspace.codexTurnError = event.message;
|
|
||||||
await appendEvent(jobId, 'error', 'plan', truncate(event.message, 20_000));
|
await appendEvent(jobId, 'error', 'plan', truncate(event.message, 20_000));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -584,177 +551,6 @@ const workspaceCurrentContent = new Map<
|
|||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
|
|
||||||
// Reading through a function boundary prevents TypeScript from narrowing the
|
|
||||||
// field to `undefined` after the synchronous reset in `runCodexTurn`; it is set
|
|
||||||
// asynchronously by the stream event handler.
|
|
||||||
const readCodexTurnError = (workspace: ActiveWorkspace) =>
|
|
||||||
workspace.codexTurnError;
|
|
||||||
|
|
||||||
const runCodexTurn = async (args: {
|
|
||||||
workspace: ActiveWorkspace;
|
|
||||||
prompt: string;
|
|
||||||
assistantMessageId: Id<'agentJobMessages'>;
|
|
||||||
assistantContent: { value: string };
|
|
||||||
}) => {
|
|
||||||
const { workspace, prompt, assistantMessageId, assistantContent } = args;
|
|
||||||
workspace.runtimeMode = 'codex_exec';
|
|
||||||
workspace.codexTurnError = undefined;
|
|
||||||
await setRuntimeSession({
|
|
||||||
jobId: workspace.claim.job._id,
|
|
||||||
agentRuntimeMode: 'codex_exec',
|
|
||||||
codexSessionId: workspace.codexSessionId,
|
|
||||||
});
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
if (workspace.cancelRequested) {
|
|
||||||
throw new Error('Workspace cancellation requested.');
|
|
||||||
}
|
|
||||||
const codexCommand = 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 pidFileName = `codex-turn-${workspace.claim.job._id}.pid`;
|
|
||||||
const pidFileHostPath = path.join(workspace.workdir, '.codex', pidFileName);
|
|
||||||
const pidFileContainerPath = path.posix.join(
|
|
||||||
workspace.containerHome,
|
|
||||||
'.codex',
|
|
||||||
pidFileName,
|
|
||||||
);
|
|
||||||
workspace.codexPidFile = pidFileContainerPath;
|
|
||||||
const command = managedCodexCommand({
|
|
||||||
jobId: workspace.claim.job._id,
|
|
||||||
pidFile: pidFileContainerPath,
|
|
||||||
command: codexCommand,
|
|
||||||
});
|
|
||||||
const aiEnv = providerEnvironment(workspace.claim, workspace.containerHome);
|
|
||||||
const secretEnv = Object.fromEntries(
|
|
||||||
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
|
|
||||||
);
|
|
||||||
let result;
|
|
||||||
try {
|
|
||||||
result = await streamExecInContainer({
|
|
||||||
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)) {
|
|
||||||
await handleAgentEvent({
|
|
||||||
workspace,
|
|
||||||
event,
|
|
||||||
assistantMessageId,
|
|
||||||
assistantContent,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onStderrLine: async (line) => {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (
|
|
||||||
trimmed &&
|
|
||||||
trimmed !== 'Reading additional input from stdin...' &&
|
|
||||||
!trimmed.includes('`[features].codex_hooks` is deprecated')
|
|
||||||
) {
|
|
||||||
await appendEvent(
|
|
||||||
workspace.claim.job._id,
|
|
||||||
'info',
|
|
||||||
'plan',
|
|
||||||
truncate(trimmed, 10_000),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
workspace.codexPidFile = undefined;
|
|
||||||
await rm(pidFileHostPath, { force: true });
|
|
||||||
}
|
|
||||||
await appendEvent(
|
|
||||||
workspace.claim.job._id,
|
|
||||||
'info',
|
|
||||||
'plan',
|
|
||||||
`Codex CLI exited with code ${result.exitCode}; captured output length ${result.output.length}; assistant length ${assistantContent.value.length}.`,
|
|
||||||
);
|
|
||||||
if (result.exitCode !== 0) {
|
|
||||||
throw new Error(`codex failed:\n${result.output}`);
|
|
||||||
}
|
|
||||||
if (!assistantContent.value.trim()) {
|
|
||||||
try {
|
|
||||||
const lastMessage = await readFile(outputFileHostPath, 'utf8');
|
|
||||||
if (lastMessage.trim()) {
|
|
||||||
assistantContent.value = truncate(
|
|
||||||
workspace.redact(lastMessage.trim()),
|
|
||||||
40_000,
|
|
||||||
);
|
|
||||||
await updateMessage({
|
|
||||||
messageId: assistantMessageId,
|
|
||||||
content: assistantContent.value,
|
|
||||||
status: 'streaming',
|
|
||||||
});
|
|
||||||
await appendEvent(
|
|
||||||
workspace.claim.job._id,
|
|
||||||
'info',
|
|
||||||
'plan',
|
|
||||||
`Recovered assistant response from Codex output file ${outputFileName}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const code = error && typeof error === 'object' ? 'code' in error : false;
|
|
||||||
if (!code || (error as { code?: string }).code !== 'ENOENT') {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
await appendEvent(
|
|
||||||
workspace.claim.job._id,
|
|
||||||
'warn',
|
|
||||||
'plan',
|
|
||||||
`Codex output file ${outputFileName} was not created.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Codex can report a failure via a JSON `error`/`turn.failed` event while
|
|
||||||
// still exiting 0. If the turn produced no assistant text but did report an
|
|
||||||
// error, surface that real reason rather than a generic empty response.
|
|
||||||
// Read through a helper so it is not narrowed away by the reset above (the
|
|
||||||
// field is mutated asynchronously inside the stream handler).
|
|
||||||
const codexTurnError = readCodexTurnError(workspace);
|
|
||||||
if (!assistantContent.value.trim() && codexTurnError) {
|
|
||||||
throw new Error(`codex failed:\n${codexTurnError}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const runOpenCodeTurn = async (args: {
|
const runOpenCodeTurn = async (args: {
|
||||||
workspace: ActiveWorkspace;
|
workspace: ActiveWorkspace;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
@@ -1279,8 +1075,9 @@ const runClaim = async (claim: Claim) => {
|
|||||||
boxHandle,
|
boxHandle,
|
||||||
githubToken,
|
githubToken,
|
||||||
redact,
|
redact,
|
||||||
// Placeholder until Task 3 resolves the runtime from the claim/profile.
|
// job.runtime becomes authoritative in Task 8; until then resolve from
|
||||||
runtime: 'codex',
|
// the profile so dispatch stays byte-identical to today.
|
||||||
|
runtime: isCodexLoginProfile(claim) ? 'codex' : 'opencode',
|
||||||
};
|
};
|
||||||
if (userEnv) {
|
if (userEnv) {
|
||||||
await appendEvent(
|
await appendEvent(
|
||||||
@@ -1298,7 +1095,13 @@ const runClaim = async (claim: Claim) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (isCodexLoginProfile(claim)) {
|
if (isCodexLoginProfile(claim)) {
|
||||||
await prepareCodexAuth(workspace);
|
await getAdapter(workspace.runtime).prepareAuth(workspace);
|
||||||
|
await appendEvent(
|
||||||
|
jobId,
|
||||||
|
'info',
|
||||||
|
'clone',
|
||||||
|
'Prepared Codex auth JSON for the isolated workspace.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await materializeEnvFile(workspace);
|
await materializeEnvFile(workspace);
|
||||||
const detected = await detectPackageCommands(repoDir);
|
const detected = await detectPackageCommands(repoDir);
|
||||||
@@ -1505,20 +1308,8 @@ export const getWorkspaceAgentStatus = (jobId: string) => {
|
|||||||
const abortWorkspaceAgentForWorkspace = async (workspace: ActiveWorkspace) => {
|
const abortWorkspaceAgentForWorkspace = async (workspace: ActiveWorkspace) => {
|
||||||
if (workspace.opencodeSession) {
|
if (workspace.opencodeSession) {
|
||||||
await abortOpenCodeSession(workspace.opencodeSession);
|
await abortOpenCodeSession(workspace.opencodeSession);
|
||||||
} else if (workspace.runtimeMode === 'codex_exec' && workspace.codexPidFile) {
|
} else if (workspace.runtime === 'codex' && workspace.turnMarker) {
|
||||||
await abortManagedCodexProcess({
|
await getAdapter(workspace.runtime).abort(workspace);
|
||||||
jobId: workspace.claim.job._id,
|
|
||||||
pidFile: workspace.codexPidFile,
|
|
||||||
execute: async (command) =>
|
|
||||||
await runExecInContainer({
|
|
||||||
containerName: workspace.boxName,
|
|
||||||
containerCwd: workspace.containerRepo,
|
|
||||||
command,
|
|
||||||
environment: {},
|
|
||||||
redact: workspace.redact,
|
|
||||||
timeoutMs: env.jobTimeoutMs,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
workspace.agentTurnActive = false;
|
workspace.agentTurnActive = false;
|
||||||
workspace.resolveTurn?.();
|
workspace.resolveTurn?.();
|
||||||
@@ -1607,18 +1398,58 @@ export const sendWorkspaceMessage = async (
|
|||||||
});
|
});
|
||||||
const assistantContent = { value: '' };
|
const assistantContent = { value: '' };
|
||||||
if (isCodexLoginProfile(claim)) {
|
if (isCodexLoginProfile(claim)) {
|
||||||
|
const messageId = assistantMessageId;
|
||||||
await appendEvent(
|
await appendEvent(
|
||||||
claim.job._id,
|
claim.job._id,
|
||||||
'info',
|
'info',
|
||||||
'plan',
|
'plan',
|
||||||
'Starting Codex CLI turn with the configured login profile.',
|
'Starting Codex CLI turn with the configured login profile.',
|
||||||
);
|
);
|
||||||
await runCodexTurn({
|
workspace.runtimeMode = 'codex_exec';
|
||||||
workspace,
|
await setRuntimeSession({
|
||||||
prompt,
|
jobId: claim.job._id,
|
||||||
assistantMessageId,
|
agentRuntimeMode: 'codex_exec',
|
||||||
assistantContent,
|
codexSessionId: workspace.codexSessionId,
|
||||||
});
|
});
|
||||||
|
if (workspace.cancelRequested) {
|
||||||
|
throw new Error('Workspace cancellation requested.');
|
||||||
|
}
|
||||||
|
const onEvent = (event: NormalizedAgentEvent) =>
|
||||||
|
handleAgentEvent({
|
||||||
|
workspace,
|
||||||
|
event,
|
||||||
|
assistantMessageId: messageId,
|
||||||
|
assistantContent,
|
||||||
|
});
|
||||||
|
const adapter = getAdapter(workspace.runtime);
|
||||||
|
const result = await adapter.runTurn(workspace, prompt, onEvent);
|
||||||
|
if (result.sessionId) {
|
||||||
|
await persistRuntimeSession(workspace, result.sessionId);
|
||||||
|
}
|
||||||
|
if (!assistantContent.value.trim() && result.finalMessage) {
|
||||||
|
assistantContent.value = truncate(redact(result.finalMessage), 40_000);
|
||||||
|
await updateMessage({
|
||||||
|
messageId,
|
||||||
|
content: assistantContent.value,
|
||||||
|
status: 'streaming',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!assistantContent.value.trim()) {
|
||||||
|
console.error(
|
||||||
|
`Codex completed without producing an assistant response for job ${claim.job._id}.`,
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
result.error
|
||||||
|
? `${workspace.runtime} failed:\n${result.error}`
|
||||||
|
: 'Codex completed without producing an assistant response.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await updateMessage({
|
||||||
|
messageId,
|
||||||
|
status: 'completed',
|
||||||
|
content: assistantContent.value,
|
||||||
|
});
|
||||||
|
workspace.agentTurnActive = false;
|
||||||
console.log(
|
console.log(
|
||||||
`Codex turn completed for job ${claim.job._id}; response length=${assistantContent.value.length}`,
|
`Codex turn completed for job ${claim.job._id}; response length=${assistantContent.value.length}`,
|
||||||
);
|
);
|
||||||
@@ -1665,24 +1496,6 @@ export const sendWorkspaceMessage = async (
|
|||||||
throw new Error(`${agentFailurePrefix(claim)}:\n${result.output}`);
|
throw new Error(`${agentFailurePrefix(claim)}:\n${result.output}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isCodexLoginProfile(claim)) {
|
|
||||||
if (!assistantContent.value.trim()) {
|
|
||||||
console.error(
|
|
||||||
`Codex completed without producing an assistant response for job ${claim.job._id}.`,
|
|
||||||
);
|
|
||||||
throw new Error(
|
|
||||||
workspace.codexTurnError
|
|
||||||
? `Codex failed: ${workspace.codexTurnError}`
|
|
||||||
: 'Codex completed without producing an assistant response.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await updateMessage({
|
|
||||||
messageId: assistantMessageId,
|
|
||||||
status: 'completed',
|
|
||||||
content: assistantContent.value,
|
|
||||||
});
|
|
||||||
workspace.agentTurnActive = false;
|
|
||||||
}
|
|
||||||
workspace.agentTurnActive = false;
|
workspace.agentTurnActive = false;
|
||||||
if (claim.job.jobType === 'maintenance_review') {
|
if (claim.job.jobType === 'maintenance_review') {
|
||||||
const decision = parseMaintenanceDecision(assistantContent.value);
|
const decision = parseMaintenanceDecision(assistantContent.value);
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { NormalizedAgentEvent } from '../../src/agent-events';
|
||||||
|
import type { ExecStreamFn } from '../../src/runtime/agent-runtime';
|
||||||
|
import type { AdapterWorkspace, Claim } from '../../src/runtime/provider';
|
||||||
|
|
||||||
|
const loadAdapter = async () => {
|
||||||
|
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
|
||||||
|
process.env.GITHUB_APP_ID = '123';
|
||||||
|
process.env.GITHUB_APP_PRIVATE_KEY =
|
||||||
|
'-----BEGIN PRIVATE KEY-----\\ntest\\n-----END PRIVATE KEY-----';
|
||||||
|
return await import('../../src/runtime/codex-adapter');
|
||||||
|
};
|
||||||
|
|
||||||
|
const claim = {
|
||||||
|
job: {
|
||||||
|
_id: 'job-1',
|
||||||
|
prompt: 'hi',
|
||||||
|
baseBranch: 'main',
|
||||||
|
workBranch: 'spoon/x',
|
||||||
|
forkOwner: 'o',
|
||||||
|
forkRepo: 'r',
|
||||||
|
upstreamOwner: 'u',
|
||||||
|
upstreamRepo: 'r',
|
||||||
|
},
|
||||||
|
spoon: { name: 'Spoon' },
|
||||||
|
openai: { model: 'gpt-5', reasoningEffort: 'medium' },
|
||||||
|
aiProviderProfile: {
|
||||||
|
id: 'p',
|
||||||
|
name: 'Codex',
|
||||||
|
provider: 'opencode_openai_login',
|
||||||
|
authType: 'opencode_auth_json',
|
||||||
|
model: 'gpt-5',
|
||||||
|
reasoningEffort: 'medium',
|
||||||
|
},
|
||||||
|
github: {},
|
||||||
|
secrets: [],
|
||||||
|
} as unknown as Claim;
|
||||||
|
|
||||||
|
const makeWorkspace = (): AdapterWorkspace => ({
|
||||||
|
claim,
|
||||||
|
// A non-existent workdir so the --output-last-message read is a clean ENOENT.
|
||||||
|
workdir: '/tmp/spoon-codex-adapter-test-missing',
|
||||||
|
homeDir: '/tmp/spoon-codex-adapter-test-missing',
|
||||||
|
username: 'tester',
|
||||||
|
containerHome: '/home/tester',
|
||||||
|
containerRepo: '/home/tester/Code/spoon',
|
||||||
|
repoDir: '/tmp/spoon-codex-adapter-test-missing/Code/spoon',
|
||||||
|
boxName: 'spoon-box-tester',
|
||||||
|
redact: (value: string) => value,
|
||||||
|
runtime: 'codex',
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CodexAdapter', () => {
|
||||||
|
test('streams normalized events and marks the turn via buildMarkedCommand', async () => {
|
||||||
|
const { createCodexAdapter } = await loadAdapter();
|
||||||
|
const captured: unknown[] = [];
|
||||||
|
const execStream = vi.fn(async (args) => {
|
||||||
|
captured.push(args.command);
|
||||||
|
await args.onStdoutLine?.(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'item.completed',
|
||||||
|
item: { id: 'item-1', type: 'agent_message', text: 'done' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await args.onStdoutLine?.(JSON.stringify({ type: 'turn.completed' }));
|
||||||
|
return { exitCode: 0, output: '' };
|
||||||
|
}) as unknown as ExecStreamFn;
|
||||||
|
|
||||||
|
const adapter = createCodexAdapter({ execStream });
|
||||||
|
const events: NormalizedAgentEvent[] = [];
|
||||||
|
const onEvent = (event: NormalizedAgentEvent): Promise<void> => {
|
||||||
|
events.push(event);
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await adapter.runTurn(makeWorkspace(), 'do it', onEvent);
|
||||||
|
|
||||||
|
expect(events).toContainEqual({
|
||||||
|
kind: 'assistant_delta',
|
||||||
|
content: 'done\n\n',
|
||||||
|
externalMessageId: 'item-1',
|
||||||
|
});
|
||||||
|
expect(events).toContainEqual({ kind: 'assistant_completed' });
|
||||||
|
expect(result).toBeTypeOf('object');
|
||||||
|
// The turn ran through buildMarkedCommand (setsid bash session).
|
||||||
|
const command = captured[0] as string[];
|
||||||
|
expect(command[0]).toBe('setsid');
|
||||||
|
expect(command[1]).toBe('bash');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('surfaces a turn.failed error while exiting zero', async () => {
|
||||||
|
const { createCodexAdapter } = await loadAdapter();
|
||||||
|
const execStream = vi.fn(async (args) => {
|
||||||
|
await args.onStdoutLine?.(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'turn.failed',
|
||||||
|
error: { message: 'boom' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return { exitCode: 0, output: '' };
|
||||||
|
}) as unknown as ExecStreamFn;
|
||||||
|
|
||||||
|
const adapter = createCodexAdapter({ execStream });
|
||||||
|
const result = await adapter.runTurn(makeWorkspace(), 'do it', () =>
|
||||||
|
Promise.resolve(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.error).toContain('boom');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -49,6 +49,8 @@ vi.mock('../../src/opencode-session', () => ({
|
|||||||
replyOpenCodePermission: vi.fn(),
|
replyOpenCodePermission: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/runtime/docker', () => ({
|
vi.mock('../../src/runtime/docker', () => ({
|
||||||
|
buildMarkedCommand: vi.fn(() => ['setsid', 'bash', '-lc', '# marker']),
|
||||||
|
killBoxProcessesByMarker: vi.fn(() => Promise.resolve()),
|
||||||
listWorkspaceContainerNames: vi.fn(() => Promise.resolve([])),
|
listWorkspaceContainerNames: vi.fn(() => Promise.resolve([])),
|
||||||
runExecInContainer: vi.fn(),
|
runExecInContainer: vi.fn(),
|
||||||
startWorkspaceContainer: vi.fn(),
|
startWorkspaceContainer: vi.fn(),
|
||||||
|
|||||||
Reference in New Issue
Block a user