[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.
This commit is contained in:
Eyejoker
2026-06-03 19:10:53 +08:00
committed by GitHub
parent 807828e0ce
commit f1679ac686
15 changed files with 22 additions and 1073 deletions

View File

@@ -10,8 +10,7 @@
"start": "bun dist/index.js"
},
"dependencies": {
"@openai/codex": "0.136.0",
"@openai/codex-sdk": "0.136.0",
"@openai/codex": "0.133.0",
"ejclaw-runners-shared": "workspace:*"
},
"devDependencies": {

View File

@@ -1,301 +0,0 @@
import {
Codex,
type CodexOptions,
type Input,
type ModelReasoningEffort,
type ThreadEvent,
type ThreadOptions,
type Usage,
type UserInput,
} from '@openai/codex-sdk';
import type { AppServerInputItem } from './app-server-client.js';
export interface CodexSdkThreadPort {
id: string | null;
runStreamed(
input: Input | unknown,
options?: { signal?: AbortSignal },
): Promise<{ events: AsyncIterable<ThreadEvent | unknown> }>;
}
export interface CodexSdkPort {
startThread(options?: ThreadOptions): CodexSdkThreadPort;
resumeThread(id: string, options?: ThreadOptions): CodexSdkThreadPort;
}
export interface CodexSdkClientOptions {
codex?: CodexSdkPort;
env?: Record<string, string>;
log: (message: string) => void;
codexPathOverride?: string;
config?: CodexOptions['config'];
}
export interface CodexSdkThreadStartOptions {
cwd: string;
model?: string;
effort?: string;
}
export interface CodexSdkTurnOptions extends CodexSdkThreadStartOptions {
onProgress?: (message: string) => void;
}
export interface CodexSdkTurnState {
status: 'pending' | 'inProgress' | 'completed' | 'failed' | 'interrupted';
threadId: string | null;
finalAnswer: string | null;
latestAgentMessage: string | null;
errorMessage: string | null;
usage: Usage | null;
}
export interface CodexSdkTurnResult {
state: CodexSdkTurnState;
result: string | null;
threadId: string | null;
}
export function createInitialCodexSdkTurnState(): CodexSdkTurnState {
return {
status: 'pending',
threadId: null,
finalAnswer: null,
latestAgentMessage: null,
errorMessage: null,
usage: null,
};
}
function isKnownReasoningEffort(
effort: string | undefined,
): effort is ModelReasoningEffort {
return (
effort === 'minimal' ||
effort === 'low' ||
effort === 'medium' ||
effort === 'high' ||
effort === 'xhigh'
);
}
function normalizeReasoningEffort(
effort: string | undefined,
): ModelReasoningEffort | undefined {
if (effort === 'minimal') {
return 'low';
}
return isKnownReasoningEffort(effort) ? effort : undefined;
}
function buildThreadOptions(
options: CodexSdkThreadStartOptions,
): ThreadOptions {
return {
approvalPolicy: 'never',
model: options.model,
modelReasoningEffort: normalizeReasoningEffort(options.effort),
networkAccessEnabled: true,
sandboxMode: 'danger-full-access',
workingDirectory: options.cwd,
};
}
export function toCodexSdkInput(input: AppServerInputItem[]): UserInput[] {
return input.map((item) => {
if (item.type === 'text') {
return { type: 'text', text: item.text };
}
return { type: 'local_image', path: item.path };
});
}
export function reduceCodexSdkTurnState(
state: CodexSdkTurnState,
event: ThreadEvent | unknown,
): CodexSdkTurnState {
if (!event || typeof event !== 'object' || !('type' in event)) {
return state;
}
const typed = event as ThreadEvent;
if (typed.type === 'thread.started') {
return { ...state, threadId: typed.thread_id };
}
if (typed.type === 'turn.started') {
return { ...state, status: 'inProgress' };
}
if (typed.type === 'item.completed') {
if (typed.item.type === 'agent_message') {
const text = typed.item.text.trim();
if (!text) return state;
return {
...state,
finalAnswer: text,
latestAgentMessage: text,
};
}
if (typed.item.type === 'error') {
return {
...state,
errorMessage: typed.item.message,
};
}
return state;
}
if (typed.type === 'turn.completed') {
return {
...state,
status: state.status === 'failed' ? 'failed' : 'completed',
usage: typed.usage,
};
}
if (typed.type === 'turn.failed') {
return {
...state,
status: 'failed',
errorMessage: typed.error.message,
};
}
if (typed.type === 'error') {
return {
...state,
status: 'failed',
errorMessage: typed.message,
};
}
return state;
}
function isAbortError(error: unknown): boolean {
return (
error instanceof Error &&
(error.name === 'AbortError' ||
error.message.toLowerCase().includes('abort'))
);
}
export class CodexSdkClient {
private readonly codex: CodexSdkPort;
private readonly log: (message: string) => void;
private readonly threads = new Map<string, CodexSdkThreadPort>();
private nextPendingThreadId = 1;
constructor(options: CodexSdkClientOptions) {
this.log = options.log;
this.codex =
options.codex ??
new Codex({
codexPathOverride: options.codexPathOverride,
config: options.config,
env: options.env,
});
}
async startOrResumeThread(
sessionId: string | undefined,
options: CodexSdkThreadStartOptions,
): Promise<string> {
const threadOptions = buildThreadOptions(options);
const thread = sessionId
? this.codex.resumeThread(sessionId, threadOptions)
: this.codex.startThread(threadOptions);
const handle =
sessionId ?? `pending-sdk-thread-${this.nextPendingThreadId++}`;
this.threads.set(handle, thread);
return handle;
}
async startTurn(
threadHandle: string,
input: AppServerInputItem[],
options: CodexSdkTurnOptions,
): Promise<{
turnId: string;
steer: (nextInput: AppServerInputItem[]) => Promise<void>;
interrupt: () => Promise<void>;
wait: () => Promise<CodexSdkTurnResult>;
}> {
const thread = this.threads.get(threadHandle);
if (!thread) {
throw new Error(`Unknown Codex SDK thread handle: ${threadHandle}`);
}
const abortController = new AbortController();
const waitPromise = this.consumeTurn(
threadHandle,
thread,
input,
options,
abortController.signal,
);
return {
turnId: threadHandle,
steer: async () => {
throw new Error(
'Codex SDK runner does not support mid-turn steering; queue the message for the next turn.',
);
},
interrupt: async () => {
abortController.abort();
},
wait: async () => waitPromise,
};
}
private async consumeTurn(
threadHandle: string,
thread: CodexSdkThreadPort,
input: AppServerInputItem[],
options: CodexSdkTurnOptions,
signal: AbortSignal,
): Promise<CodexSdkTurnResult> {
let state = createInitialCodexSdkTurnState();
try {
const { events } = await thread.runStreamed(toCodexSdkInput(input), {
signal,
});
for await (const event of events) {
state = reduceCodexSdkTurnState(state, event);
if (state.threadId && state.threadId !== threadHandle) {
this.threads.set(state.threadId, thread);
this.threads.delete(threadHandle);
}
if (
state.latestAgentMessage &&
state.latestAgentMessage !== state.finalAnswer
) {
options.onProgress?.(state.latestAgentMessage);
}
}
} catch (error) {
if (isAbortError(error)) {
state = { ...state, status: 'interrupted' };
} else {
const message = error instanceof Error ? error.message : String(error);
this.log(`Codex SDK turn failed: ${message}`);
state = { ...state, status: 'failed', errorMessage: message };
}
}
const threadId = state.threadId ?? thread.id;
if (threadId && threadId !== threadHandle) {
this.threads.set(threadId, thread);
this.threads.delete(threadHandle);
}
return {
state: { ...state, threadId },
result: state.finalAnswer,
threadId,
};
}
}

View File

@@ -1,10 +1,7 @@
/**
* EJClaw Codex Runner
*
* App-server by default, with optional CODEX_RUNTIME=sdk lane.
* SDK mode uses @openai/codex-sdk / codex exec JSON events and leaves
* mid-turn IPC follow-ups queued for the next run because SDK steering is
* not currently available.
* App-server only runtime.
*
* Input protocol:
* Stdin: Full RunnerInput JSON (read until EOF)
@@ -30,8 +27,6 @@ import {
import { CodexAppServerClient } from './app-server-client.js';
import { parseAppServerInput } from './app-server-input.js';
import { CodexSdkClient } from './codex-sdk-client.js';
import { resolveCodexRuntimeMode } from './runtime-mode.js';
import {
prependRoomRoleHeader,
type RoomRoleContext,
@@ -109,15 +104,6 @@ function log(message: string): void {
console.error(`[codex-runner] ${message}`);
}
function toStringEnv(env: NodeJS.ProcessEnv): Record<string, string> {
return Object.fromEntries(
Object.entries(env).filter((entry): entry is [string, string] => {
const [, value] = entry;
return typeof value === 'string';
}),
);
}
function isCodexGoalsEnabled(runnerInput: RunnerInput): boolean {
return runnerInput.codexGoals === true || process.env.CODEX_GOALS === 'true';
}
@@ -297,117 +283,6 @@ async function executeAppServerTurn(
}
}
function countQueuedIpcInputFiles(): number {
try {
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
return fs
.readdirSync(IPC_INPUT_DIR)
.filter((file) => file.endsWith('.json')).length;
} catch (err) {
log(
`IPC queued input count error: ${err instanceof Error ? err.message : String(err)}`,
);
return 0;
}
}
async function executeSdkTurn(
client: CodexSdkClient,
threadHandle: string,
prompt: string,
retryCount = 0,
): Promise<{
result: string | null;
error?: string;
threadId: string;
}> {
let lastProgressMessage: string | null = null;
const activeTurn = await client.startTurn(
threadHandle,
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: threadHandle,
});
},
},
);
let elapsedMs = 0;
let polling = true;
let lastQueuedInputCount = 0;
const pollDuringTurn = async () => {
if (!polling) return;
if (consumeCloseSentinel()) {
log('Close sentinel detected during SDK turn, interrupting');
polling = false;
try {
await activeTurn.interrupt();
} catch (err) {
log(
`Failed to interrupt active SDK turn: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
return;
}
const queuedInputCount = countQueuedIpcInputFiles();
if (queuedInputCount > 0 && queuedInputCount !== lastQueuedInputCount) {
lastQueuedInputCount = queuedInputCount;
log(
`SDK runtime cannot steer active turns; leaving ${queuedInputCount} queued input file(s) for the next run`,
);
}
elapsedMs += IPC_POLL_MS;
if (elapsedMs > 0 && elapsedMs % 60000 === 0) {
log(`SDK turn in progress... (${formatProgressElapsed(elapsedMs)})`);
}
setTimeout(() => void pollDuringTurn(), IPC_POLL_MS);
};
setTimeout(() => void pollDuringTurn(), IPC_POLL_MS);
try {
const { state, result, threadId } = await activeTurn.wait();
const finalThreadId = threadId ?? threadHandle;
if (state.status === 'completed') {
return { result, threadId: finalThreadId };
}
if (state.status === 'interrupted' && consumeCloseSentinel()) {
return { result, threadId: finalThreadId };
}
if (state.status === 'interrupted' && retryCount < 1) {
log('Codex SDK turn interrupted, retrying once...');
return executeSdkTurn(client, finalThreadId, prompt, retryCount + 1);
}
return {
result,
error:
state.errorMessage ||
`Codex SDK turn finished with status ${state.status}`,
threadId: finalThreadId,
};
} finally {
polling = false;
}
}
async function runAppServerCompact(
client: CodexAppServerClient,
threadId: string | undefined,
@@ -530,64 +405,6 @@ async function runAppServerSession(
}
}
async function runSdkSession(
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 CodexSdkClient({
env: toStringEnv(clientEnv),
log,
});
const threadHandle = await client.startOrResumeThread(runnerInput.sessionId, {
cwd: EFFECTIVE_CWD,
model: CODEX_MODEL || undefined,
effort: CODEX_EFFORT || undefined,
});
log(
runnerInput.sessionId
? `SDK thread resumed (${threadHandle})`
: `SDK thread started (${threadHandle})`,
);
log('Starting SDK turn...');
const { result, error, threadId } = await executeSdkTurn(
client,
threadHandle,
prompt,
);
if (error) {
const normalized = normalizeStructuredOutput(result || null);
log(`SDK turn error: ${error}`);
writeOutput({
status: 'error',
...normalized,
newSessionId: threadId,
error,
});
} else {
const normalized = normalizeStructuredOutput(result || null);
writeOutput({
status: 'success',
...normalized,
...(result ? { phase: 'final' as const } : {}),
newSessionId: threadId,
});
}
if (consumeCloseSentinel()) {
log('Close sentinel detected, exiting SDK runtime');
}
}
// ── Main ──────────────────────────────────────────────────────────
async function main(): Promise<void> {
@@ -638,20 +455,8 @@ async function main(): Promise<void> {
}
try {
const runtimeMode = resolveCodexRuntimeMode(
process.env,
{
codexGoals: isCodexGoalsEnabled(runnerInput),
roomRole: runnerInput.roomRoleContext?.role,
},
rawPrompt,
);
log(`Runtime selected: ${runtimeMode.mode} (${runtimeMode.reason})`);
if (runtimeMode.mode === 'sdk') {
await runSdkSession(runnerInput, prompt);
} else {
await runAppServerSession(runnerInput, prompt);
}
log('Runtime selected: app-server');
await runAppServerSession(runnerInput, prompt);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
log(`Runner error: ${errorMessage}`);

View File

@@ -1,85 +0,0 @@
export type CodexRuntimeMode = 'app-server' | 'sdk';
export interface CodexRuntimeModeInput {
codexGoals?: boolean;
roomRole?: string;
}
export interface CodexRuntimeModeResolution {
mode: CodexRuntimeMode;
reason:
| 'default'
| 'CODEX_RUNTIME=app-server'
| 'CODEX_RUNTIME=sdk'
| 'sdk-unsupported-goals'
| 'sdk-unsupported-session-command'
| 'sdk-role-not-enabled'
| 'invalid-CODEX_RUNTIME';
}
function normalizeRuntimeMode(
raw: string | undefined,
): CodexRuntimeMode | null {
if (!raw) return null;
const normalized = raw.trim().toLowerCase();
if (normalized === 'app-server' || normalized === 'appserver') {
return 'app-server';
}
if (normalized === 'sdk') {
return 'sdk';
}
return null;
}
export interface CodexRuntimeEnv {
[key: string]: string | undefined;
CODEX_RUNTIME?: string;
CODEX_RUNTIME_SDK_ROLES?: string;
}
function sdkRoleEnabled(
env: CodexRuntimeEnv,
roomRole: string | undefined,
): boolean {
const raw = env.CODEX_RUNTIME_SDK_ROLES?.trim();
if (!raw) return true;
if (!roomRole) return false;
const allowed = raw
.split(',')
.map((role) => role.trim().toLowerCase())
.filter(Boolean);
return allowed.includes(roomRole.trim().toLowerCase());
}
export function resolveCodexRuntimeMode(
env: CodexRuntimeEnv,
input: CodexRuntimeModeInput,
rawPrompt: string,
): CodexRuntimeModeResolution {
const requested = normalizeRuntimeMode(env.CODEX_RUNTIME);
if (requested === 'sdk') {
if (rawPrompt.trim() === '/compact') {
return {
mode: 'app-server',
reason: 'sdk-unsupported-session-command',
};
}
if (input.codexGoals === true) {
return { mode: 'app-server', reason: 'sdk-unsupported-goals' };
}
if (!sdkRoleEnabled(env, input.roomRole)) {
return { mode: 'app-server', reason: 'sdk-role-not-enabled' };
}
return { mode: 'sdk', reason: 'CODEX_RUNTIME=sdk' };
}
if (requested === 'app-server') {
return { mode: 'app-server', reason: 'CODEX_RUNTIME=app-server' };
}
if (env.CODEX_RUNTIME && env.CODEX_RUNTIME.trim()) {
return { mode: 'app-server', reason: 'invalid-CODEX_RUNTIME' };
}
return { mode: 'app-server', reason: 'default' };
}

View File

@@ -1,64 +0,0 @@
import { describe, expect, it } from 'vitest';
import { resolveCodexRuntimeMode } from '../src/runtime-mode.js';
describe('codex runner runtime mode selection', () => {
it('keeps app-server as the default runtime', () => {
expect(
resolveCodexRuntimeMode({}, { codexGoals: false }, 'do work'),
).toEqual({
mode: 'app-server',
reason: 'default',
});
});
it('selects SDK mode when CODEX_RUNTIME=sdk', () => {
expect(
resolveCodexRuntimeMode(
{ CODEX_RUNTIME: 'sdk' },
{ codexGoals: false },
'do work',
),
).toEqual({ mode: 'sdk', reason: 'CODEX_RUNTIME=sdk' });
});
it('canary-limits SDK mode to configured paired roles', () => {
expect(
resolveCodexRuntimeMode(
{ CODEX_RUNTIME: 'sdk', CODEX_RUNTIME_SDK_ROLES: 'owner,arbiter' },
{ codexGoals: false, roomRole: 'owner' },
'do work',
),
).toEqual({ mode: 'sdk', reason: 'CODEX_RUNTIME=sdk' });
expect(
resolveCodexRuntimeMode(
{ CODEX_RUNTIME: 'sdk', CODEX_RUNTIME_SDK_ROLES: 'owner,arbiter' },
{ codexGoals: false, roomRole: 'reviewer' },
'do work',
),
).toEqual({ mode: 'app-server', reason: 'sdk-role-not-enabled' });
});
it('falls back to app-server for /compact because SDK lacks compaction', () => {
expect(
resolveCodexRuntimeMode(
{ CODEX_RUNTIME: 'sdk' },
{ codexGoals: false },
'/compact',
),
).toEqual({
mode: 'app-server',
reason: 'sdk-unsupported-session-command',
});
});
it('falls back to app-server when Codex goals are enabled', () => {
expect(
resolveCodexRuntimeMode(
{ CODEX_RUNTIME: 'sdk' },
{ codexGoals: true },
'do work',
),
).toEqual({ mode: 'app-server', reason: 'sdk-unsupported-goals' });
});
});

View File

@@ -1,194 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
CodexSdkClient,
createInitialCodexSdkTurnState,
reduceCodexSdkTurnState,
toCodexSdkInput,
type CodexSdkThreadPort,
} from '../src/codex-sdk-client.js';
const usage = {
input_tokens: 1,
cached_input_tokens: 0,
output_tokens: 2,
reasoning_output_tokens: 0,
};
async function* events(items: unknown[]): AsyncGenerator<unknown> {
for (const item of items) {
yield item;
}
}
class FakeThread implements CodexSdkThreadPort {
id: string | null = null;
lastInput: unknown = null;
lastSignal: AbortSignal | undefined;
constructor(private readonly scriptedEvents: unknown[]) {}
async runStreamed(input: unknown, options?: { signal?: AbortSignal }) {
this.lastInput = input;
this.lastSignal = options?.signal;
return { events: events(this.scriptedEvents) };
}
}
class FakeCodex {
readonly started: unknown[] = [];
readonly resumed: unknown[] = [];
readonly threads: FakeThread[] = [];
constructor(private readonly scriptedEvents: unknown[]) {}
startThread(options?: unknown): FakeThread {
this.started.push(options);
const thread = new FakeThread(this.scriptedEvents);
this.threads.push(thread);
return thread;
}
resumeThread(id: string, options?: unknown): FakeThread {
this.resumed.push({ id, options });
const thread = new FakeThread(this.scriptedEvents);
thread.id = id;
this.threads.push(thread);
return thread;
}
}
describe('Codex SDK runner spike', () => {
it('maps existing app-server text/image input into Codex SDK input shape', () => {
expect(
toCodexSdkInput([
{ type: 'text', text: 'inspect this' },
{ type: 'localImage', path: '/tmp/screenshot.png' },
]),
).toEqual([
{ type: 'text', text: 'inspect this' },
{ type: 'local_image', path: '/tmp/screenshot.png' },
]);
});
it('reduces SDK thread events into the runner final result and thread id', () => {
let state = createInitialCodexSdkTurnState();
state = reduceCodexSdkTurnState(state, {
type: 'thread.started',
thread_id: 'thread-1',
});
state = reduceCodexSdkTurnState(state, { type: 'turn.started' });
state = reduceCodexSdkTurnState(state, {
type: 'item.completed',
item: {
id: 'msg-1',
type: 'agent_message',
text: 'STEP_DONE\n검증 완료',
},
});
state = reduceCodexSdkTurnState(state, {
type: 'turn.completed',
usage,
});
expect(state).toMatchObject({
status: 'completed',
threadId: 'thread-1',
finalAnswer: 'STEP_DONE\n검증 완료',
usage,
});
});
it('runs a streamed SDK turn and returns the first real thread id', async () => {
const codex = new FakeCodex([
{ type: 'thread.started', thread_id: 'thread-42' },
{ type: 'turn.started' },
{
type: 'item.completed',
item: { id: 'msg-1', type: 'agent_message', text: 'TASK_DONE\nSDK_OK' },
},
{ type: 'turn.completed', usage },
]);
const client = new CodexSdkClient({
codex,
env: { PATH: '/usr/bin' },
log: () => undefined,
});
const handle = await client.startOrResumeThread(undefined, {
cwd: '/repo',
model: 'gpt-5.5',
effort: 'high',
});
const turn = await client.startTurn(
handle,
[{ type: 'text', text: 'respond' }],
{ cwd: '/repo', model: 'gpt-5.5', effort: 'high' },
);
await expect(turn.wait()).resolves.toMatchObject({
threadId: 'thread-42',
result: 'TASK_DONE\nSDK_OK',
state: { status: 'completed' },
});
expect(codex.started).toEqual([
{
approvalPolicy: 'never',
model: 'gpt-5.5',
modelReasoningEffort: 'high',
networkAccessEnabled: true,
sandboxMode: 'danger-full-access',
workingDirectory: '/repo',
},
]);
expect(codex.threads[0].lastInput).toEqual([
{ type: 'text', text: 'respond' },
]);
});
it('coerces minimal effort to low because Codex SDK exec keeps tools enabled', async () => {
const codex = new FakeCodex([
{ type: 'thread.started', thread_id: 'thread-minimal' },
{ type: 'turn.started' },
{
type: 'item.completed',
item: { id: 'msg-1', type: 'agent_message', text: 'SDK_OK' },
},
{ type: 'turn.completed', usage },
]);
const client = new CodexSdkClient({ codex, log: () => undefined });
await client.startOrResumeThread(undefined, {
cwd: '/repo',
effort: 'minimal',
});
expect(codex.started[0]).toMatchObject({ modelReasoningEffort: 'low' });
});
it('documents the SDK gap: mid-turn steering is not available', async () => {
const codex = new FakeCodex([
{ type: 'thread.started', thread_id: 'thread-1' },
{ type: 'turn.started' },
{ type: 'turn.completed', usage },
]);
const client = new CodexSdkClient({ codex, log: () => undefined });
const handle = await client.startOrResumeThread('thread-1', {
cwd: '/repo',
});
const turn = await client.startTurn(
handle,
[{ type: 'text', text: 'start' }],
{
cwd: '/repo',
},
);
await expect(
turn.steer([{ type: 'text', text: 'follow-up' }]),
).rejects.toThrow('Codex SDK runner does not support mid-turn steering');
await expect(turn.wait()).resolves.toMatchObject({
state: { status: 'completed' },
});
});
});