Files
EJClaw/runners/codex-runner/src/index.ts
Eyejoker f1679ac686 [codex] Remove Codex SDK canary runtime (#220)
* Revert "fix: drain sdk follow-up turns (#219)"

This reverts commit 807828e0ce.

* Revert "feat: feature-flag Codex SDK runner (#218)"

This reverts commit 879d16235f.
2026-06-03 19:10:53 +08:00

472 lines
13 KiB
TypeScript

/**
* EJClaw Codex Runner
*
* App-server only runtime.
*
* Input protocol:
* Stdin: Full RunnerInput JSON (read until EOF)
* IPC: Follow-up messages as JSON files in $EJCLAW_IPC_DIR/input/
* Sentinel: _close — signals session end
*
* Stdout protocol:
* Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs.
*/
import fs from 'fs';
import path from 'path';
import {
EJCLAW_ENV,
IPC_CLOSE_SENTINEL,
IPC_INPUT_SUBDIR,
IPC_POLL_MS,
normalizeAgentOutput,
writeProtocolOutput,
type RunnerStructuredOutput,
} from 'ejclaw-runners-shared';
import { CodexAppServerClient } from './app-server-client.js';
import { parseAppServerInput } from './app-server-input.js';
import {
prependRoomRoleHeader,
type RoomRoleContext,
} from './room-role-context.js';
import {
assertReadonlyWorkspaceRepoConnectivity,
buildReviewerGitGuardEnv,
isArbiterRuntimeEnvEnabled,
isReviewerRuntime,
isReviewerRuntimeEnvEnabled,
} from './reviewer-runtime.js';
// ── Types ──────────────────────────────────────────────────────────
interface RunnerInput {
prompt: string;
sessionId?: string;
groupFolder: string;
chatJid: string;
isMain: boolean;
isScheduledTask?: boolean;
assistantName?: string;
agentType?: string;
codexGoals?: boolean;
roomRoleContext?: RoomRoleContext;
}
interface RunnerOutput {
status: 'success' | 'error';
result: string | null;
output?: RunnerStructuredOutput;
phase?: 'progress' | 'final';
newSessionId?: string;
error?: string;
compaction?: {
completed: boolean;
trigger?: string | null;
};
}
// ── Constants ──────────────────────────────────────────────────────
const GROUP_DIR = process.env[EJCLAW_ENV.groupDir] || '/workspace/group';
const IPC_DIR = process.env[EJCLAW_ENV.ipcDir] || '/workspace/ipc';
const WORK_DIR = process.env[EJCLAW_ENV.workDir] || '';
const IPC_INPUT_DIR = path.join(IPC_DIR, IPC_INPUT_SUBDIR);
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, IPC_CLOSE_SENTINEL);
const EFFECTIVE_CWD = WORK_DIR || GROUP_DIR;
const CODEX_MODEL = process.env.CODEX_MODEL || '';
const CODEX_EFFORT = process.env.CODEX_EFFORT || '';
let closeRequested = false;
// ── Helpers ────────────────────────────────────────────────────────
function writeOutput(output: RunnerOutput): void {
writeProtocolOutput(output);
}
function compactionOutput(
completed: boolean | undefined,
): Pick<RunnerOutput, 'compaction'> {
return completed ? { compaction: { completed: true, trigger: null } } : {};
}
function normalizeStructuredOutput(result: string | null): {
result: string | null;
output?: RunnerOutput['output'];
} {
return normalizeAgentOutput(result);
}
function log(message: string): void {
console.error(`[codex-runner] ${message}`);
}
function isCodexGoalsEnabled(runnerInput: RunnerInput): boolean {
return runnerInput.codexGoals === true || process.env.CODEX_GOALS === 'true';
}
async function readStdin(): Promise<string> {
return new Promise((resolve, reject) => {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk: string) => {
data += chunk;
});
process.stdin.on('end', () => resolve(data));
process.stdin.on('error', reject);
});
}
function consumeCloseSentinel(): boolean {
if (closeRequested) return true;
if (!fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) return false;
try {
fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL);
} catch {
/* ignore */
}
closeRequested = true;
return true;
}
function drainIpcInput(): string[] {
try {
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
const files = fs
.readdirSync(IPC_INPUT_DIR)
.filter((file) => file.endsWith('.json'))
.sort();
const messages: string[] = [];
for (const file of files) {
const filePath = path.join(IPC_INPUT_DIR, file);
try {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
fs.unlinkSync(filePath);
if (data.type === 'message' && data.text) {
messages.push(data.text);
}
} catch (err) {
log(
`Failed to process input file ${file}: ${
err instanceof Error ? err.message : String(err)
}`,
);
try {
fs.unlinkSync(filePath);
} catch {
/* ignore */
}
}
}
return messages;
} catch (err) {
log(`IPC drain error: ${err instanceof Error ? err.message : String(err)}`);
return [];
}
}
function formatProgressElapsed(ms: number): string {
const elapsedSeconds = Math.floor(ms / 10_000) * 10;
const hours = Math.floor(elapsedSeconds / 3600);
const minutes = Math.floor((elapsedSeconds % 3600) / 60);
const seconds = elapsedSeconds % 60;
const parts: string[] = [];
if (hours > 0) parts.push(`${hours}시간`);
if (minutes > 0) parts.push(`${minutes}`);
parts.push(`${seconds}`);
return parts.join(' ');
}
async function executeAppServerTurn(
client: CodexAppServerClient,
threadId: string,
prompt: string,
retryCount = 0,
): Promise<{
result: string | null;
error?: string;
compactionCompleted?: boolean;
}> {
let lastProgressMessage: string | null = null;
const activeTurn = await client.startTurn(
threadId,
parseAppServerInput(prompt, log),
{
cwd: EFFECTIVE_CWD,
model: CODEX_MODEL || undefined,
effort: CODEX_EFFORT || undefined,
onProgress: (message) => {
const trimmed = message.trim();
if (!trimmed || trimmed === lastProgressMessage) {
return;
}
lastProgressMessage = trimmed;
writeOutput({
status: 'success',
phase: 'progress',
...normalizeStructuredOutput(trimmed),
newSessionId: threadId,
});
},
},
);
let elapsedMs = 0;
let polling = true;
const pollDuringTurn = async () => {
if (!polling) return;
if (consumeCloseSentinel()) {
log('Close sentinel detected during app-server turn, interrupting');
polling = false;
try {
await activeTurn.interrupt();
} catch (err) {
log(
`Failed to interrupt active turn: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
return;
}
const messages = drainIpcInput();
if (messages.length > 0) {
const merged = messages.join('\n');
log(`Steering active turn with ${messages.length} queued message(s)`);
try {
await activeTurn.steer(parseAppServerInput(merged, log));
} catch (err) {
log(
`turn/steer failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
elapsedMs += IPC_POLL_MS;
if (elapsedMs > 0 && elapsedMs % 60000 === 0) {
log(`Turn in progress... (${formatProgressElapsed(elapsedMs)})`);
}
setTimeout(() => void pollDuringTurn(), IPC_POLL_MS);
};
setTimeout(() => void pollDuringTurn(), IPC_POLL_MS);
try {
const { state, result } = await activeTurn.wait();
if (state.status === 'completed') {
return { result, compactionCompleted: state.compactionCompleted };
}
if (state.status === 'interrupted' && consumeCloseSentinel()) {
return { result, compactionCompleted: state.compactionCompleted };
}
if (state.status === 'interrupted' && retryCount < 1) {
log('Codex turn interrupted, retrying once...');
return executeAppServerTurn(client, threadId, prompt, retryCount + 1);
}
return {
result,
error:
state.errorMessage || `Codex turn finished with status ${state.status}`,
compactionCompleted: state.compactionCompleted,
};
} finally {
polling = false;
}
}
async function runAppServerCompact(
client: CodexAppServerClient,
threadId: string | undefined,
): Promise<void> {
if (!threadId) {
writeOutput({
status: 'success',
result: '현재 활성 Codex 세션이 없어 compact를 건너뜁니다.',
});
return;
}
const { state } = await client.startCompaction(threadId);
if (state.status === 'failed') {
writeOutput({
status: 'error',
result: null,
newSessionId: threadId,
error: state.errorMessage || 'Conversation compaction failed.',
...compactionOutput(state.compactionCompleted),
});
return;
}
writeOutput({
status: 'success',
result: state.compactionCompleted
? 'Conversation compacted.'
: 'Compaction requested but contextCompaction was not observed.',
newSessionId: threadId,
...compactionOutput(state.compactionCompleted),
});
}
async function runAppServerSession(
runnerInput: RunnerInput,
prompt: string,
): Promise<void> {
const reviewerRuntime =
isReviewerRuntimeEnvEnabled(process.env) ||
isReviewerRuntime(runnerInput.roomRoleContext);
const readonlyRuntime =
reviewerRuntime || isArbiterRuntimeEnvEnabled(process.env);
const clientEnv = buildReviewerGitGuardEnv(process.env, reviewerRuntime);
assertReadonlyWorkspaceRepoConnectivity(clientEnv, readonlyRuntime);
const client = new CodexAppServerClient({
cwd: EFFECTIVE_CWD,
env: clientEnv,
log,
enableGoals: isCodexGoalsEnabled(runnerInput),
});
await client.start();
let threadId: string | undefined;
try {
try {
threadId = await client.startOrResumeThread(runnerInput.sessionId, {
cwd: EFFECTIVE_CWD,
model: CODEX_MODEL || undefined,
});
log(
runnerInput.sessionId
? `App-server thread resumed (${threadId})`
: `App-server thread started (${threadId})`,
);
} catch (err) {
if (!runnerInput.sessionId) throw err;
log(
`App-server resume failed, retrying with new thread: ${
err instanceof Error ? err.message : String(err)
}`,
);
threadId = await client.startOrResumeThread(undefined, {
cwd: EFFECTIVE_CWD,
model: CODEX_MODEL || undefined,
});
log(`App-server thread restarted (${threadId})`);
}
const trimmedPrompt = prompt.trim();
if (trimmedPrompt === '/compact') {
await runAppServerCompact(client, threadId);
return;
}
log('Starting app-server turn...');
const { result, error, compactionCompleted } = await executeAppServerTurn(
client,
threadId,
prompt,
);
if (error) {
const normalized = normalizeStructuredOutput(result || null);
log(`App-server turn error: ${error}`);
writeOutput({
status: 'error',
...normalized,
newSessionId: threadId,
error,
...compactionOutput(compactionCompleted),
});
} else {
const normalized = normalizeStructuredOutput(result || null);
writeOutput({
status: 'success',
...normalized,
...(result ? { phase: 'final' as const } : {}),
newSessionId: threadId,
...compactionOutput(compactionCompleted),
});
}
if (consumeCloseSentinel()) {
log('Close sentinel detected, exiting app-server runtime');
}
} finally {
await client.close();
}
}
// ── Main ──────────────────────────────────────────────────────────
async function main(): Promise<void> {
let runnerInput: RunnerInput;
try {
const stdinData = await readStdin();
runnerInput = JSON.parse(stdinData);
try {
fs.unlinkSync('/tmp/input.json');
} catch {
/* may not exist */
}
log(`Received input for group: ${runnerInput.groupFolder}`);
} catch (err) {
writeOutput({
status: 'error',
result: null,
error: `Failed to parse input: ${
err instanceof Error ? err.message : String(err)
}`,
});
process.exit(1);
}
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
closeRequested = false;
try {
fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL);
} catch {
/* ignore */
}
const rawPrompt = runnerInput.prompt;
let prompt = rawPrompt;
if (runnerInput.isScheduledTask) {
prompt = `[SCHEDULED TASK]\n\n${prompt}`;
}
const pending = drainIpcInput();
if (pending.length > 0) {
prompt += '\n' + pending.join('\n');
}
// Prepend room role header AFTER checking for session commands,
// so /compact is not masked by the header prefix.
const isSessionCommand = rawPrompt.trim() === '/compact';
if (!isSessionCommand) {
prompt = prependRoomRoleHeader(prompt, runnerInput.roomRoleContext);
}
try {
log('Runtime selected: app-server');
await runAppServerSession(runnerInput, prompt);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
log(`Runner error: ${errorMessage}`);
writeOutput({
status: 'error',
result: null,
error: errorMessage,
});
}
}
main();