feat: add Codex executor with session resume

This commit is contained in:
2026-07-22 22:15:15 +08:00
parent d38151ad78
commit 214b7c9a30
19 changed files with 1855 additions and 33 deletions
+9 -9
View File
@@ -3,12 +3,12 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
[![Node.js](https://img.shields.io/badge/node-%3E%3D20-green.svg)](https://nodejs.org)
**Codex or Claude hosted autonomous implementation loop with Reasonix, Claude Code, and Agy executors.**
**Codex or Claude hosted autonomous implementation loop with Reasonix, Claude Code, Agy, and Codex executors.**
`agent-workflow` orchestrates a stateful implement-review-fix cycle where:
- **Codex or Claude** acts as the host planner, reviewer, and final acceptance authority
- **Reasonix, Claude Code, or Agy** executes implementation and fixes in the same session
- **Reasonix, Claude Code, Agy, or Codex** executes implementation and fixes in the same session
- The workflow maintains atomic state, VCS baseline gates, scope enforcement, and convergence limits
- Supports both **Git** and **SVN** working copies with strict validation
@@ -16,7 +16,7 @@
- Node.js >= 20
- Git repository or SVN working copy
- At least one executor: [Reasonix](https://reasonix.ai), [Claude Code](https://claude.ai/code), or [Agy](https://agy.dev)
- At least one executor: [Reasonix](https://reasonix.ai), [Claude Code](https://claude.ai/code), [Agy](https://agy.dev), or Codex
## Installation
@@ -95,13 +95,13 @@ agent-workflow decide --run <run-id> --input decision.json --reviewer <codex|cla
Start a new workflow run. Requires a clean working tree.
```bash
agent-workflow start --plan <plan.json|-> --executor <reasonix|claude|agy> [--reviewer <codex|claude>] [--vcs <git|svn|none>] [--max-cycles <n>] [--new-task]
agent-workflow start --plan <plan.json|-> --executor <reasonix|claude|agy|codex> [--reviewer <codex|claude>] [--vcs <git|svn|none>] [--max-cycles <n>] [--new-task]
```
- `--vcs` - Explicitly specify VCS kind (`git`, `svn`, or `none`). If omitted, auto-detects from the working directory. If neither Git nor SVN is detected, it automatically falls back to `none` (snapshot mode).
- When `CODEX_THREAD_ID` is available, runs in the same Codex thread and repository share a current task. Each executor is created lazily on first use and its reliable session handle is reused by later plans in that task.
- `--new-task` creates a new task boundary and clears all three lazy executor bindings. Use it only when the conversation has moved to a materially different task.
- Claude, Reasonix, and Agy bindings remain separate. Selecting another executor does not switch an active run; it starts or resumes that executor only in a later run after the repository is clean.
- Claude, Reasonix, Agy, and Codex bindings remain separate. Selecting another executor does not switch an active run; it starts or resumes that executor only in a later run after the repository is clean.
- Agy sessions without a captured conversation ID are not shared because `--continue` cannot guarantee which conversation will be selected.
- Git and SVN working copies are automatically detected when `--vcs` is not provided.
- SVN working copies must be in a clean, consistent state (no mixed revisions, switched paths, externals, or conflicts).
@@ -146,10 +146,10 @@ agent-workflow retry-execute --run <run-id>
If a failed Claude launch recorded a session ID that was never created, explicitly bind a known persisted Claude session from the same repository and frozen plan while retrying:
```bash
agent-workflow retry-execute --run <run-id> --session <claude-session-id>
agent-workflow retry-execute --run <run-id> --session <claude-or-codex-session-id>
```
The override is accepted only for Claude runs. The session JSONL must exist under `~/.claude/projects`, belong to the run repository, and contain the frozen plan title. Baseline, scope, and review-artifact gates still apply, and the old/new session IDs are appended to the workflow audit log.
The override is accepted only for Claude and Codex runs. For Claude the session JSONL must exist under `~/.claude/projects`; for Codex it is searched under `~/.codex/sessions` and `~/.codex/archived_sessions`. The session must belong to the run repository and contain the frozen plan title. Baseline, scope, and review-artifact gates still apply, and the old/new session IDs are appended to the workflow audit log.
For Reasonix runs blocked after a session handle was not captured, `retry-execute` automatically recovers the persisted session only when its project directory, frozen plan title, and execution time window match the run. The recovery is recorded in the workflow audit log.
@@ -205,7 +205,7 @@ agent-workflow web [--port <n>]
- **`--port <n>`** — specify a custom port to listen on (default: `4317`).
- **Security & Scope** — The server binds exclusively to the loopback interface (`127.0.0.1`) for local-only access. It refuses any external or remote listener configurations. Since executor logs can contain sensitive code, configuration details, or tokens/credentials, binding to `127.0.0.1` ensures that the console remains local. Mutation endpoints require same-origin localhost requests, JSON content type, bounded bodies, and validated model values. Credentials are never returned to the browser.
- **Workflow monitoring remains read-only** — The console provides no endpoints to start, stop, decide, or otherwise control workflow runs. It parses and displays execution progress, cycles, timeline, and stdout/stderr output. It cannot recover or display private reasoning tokens/processes of underlying models that are not output to the console.
- **Writable global model settings** — The Chinese “全局执行器模型配置” view can independently set Claude, Agy, and Reasonix models. Discovery uses the current CLI environment: Claude via Anthropic `/v1/models` (with configured-default fallback), Agy via `agy models`, and Reasonix via `reasonix doctor --json` provider/model entries. Reasonix candidates use the CLI-compatible `provider/model` form, with provider-only fallback when no concrete model is listed. CLI discovery allows up to 15 seconds for executor startup; HTTP model discovery remains bounded separately. Discovery failures are isolated per executor, and manual entry always remains available. Saving atomically updates only `executors.<kind>.model` in `~/.agent-workflow/config.json`, preserves unrelated fields, and clears an override when the value is emptied. Active executors are not interrupted; saved models take effect on later cycles, scope remediation, extensions, and executor retries through the existing reload path. Project configuration continues to override global values.
- **Writable global model settings** — The Chinese “全局执行器模型配置” view can independently set Claude, Agy, Reasonix, and Codex models. Discovery uses the current CLI environment: Claude via Anthropic `/v1/models` (with configured-default fallback), Agy via `agy models`, and Reasonix via `reasonix doctor --json` provider/model entries. Reasonix candidates use the CLI-compatible `provider/model` form, with provider-only fallback when no concrete model is listed. CLI discovery allows up to 15 seconds for executor startup; HTTP model discovery remains bounded separately. Discovery failures are isolated per executor, and manual entry always remains available. Saving atomically updates only `executors.<kind>.model` in `~/.agent-workflow/config.json`, preserves unrelated fields, and clears an override when the value is emptied. Active executors are not interrupted; saved models take effect on later cycles, scope remediation, extensions, and executor retries through the existing reload path. Project configuration continues to override global values.
- **Interaction with `logs --follow`** — The web server directly reads the persisted run files without taking any logs follow locks or interfering with the main executor loop, allowing you to use both the CLI `logs --follow` and the web console concurrently.
### `agent-workflow abort`
@@ -261,7 +261,7 @@ Create `agent-workflow.json` in your repository root, and optionally create `~/.
`timeoutSeconds` is an inactivity timeout, not a total runtime limit. The host resets the timeout whenever the executor emits stdout/stderr progress, so an active task may run longer than the configured window. A continuously silent executor is terminated after the configured number of seconds. Agy receives a 24-hour internal print-mode ceiling while the host inactivity timeout remains the primary stuck-process guard.
- `defaultVcs` - Default VCS kind: `"auto"` (detect), `"git"`, `"svn"`, or `"none"`. Default is `"auto"`.
- `model` - Executor model identifier (accepted as any non-empty string). When set, it is passed to Claude, Agy, and Reasonix. The effective model is reloaded from the latest config before each later cycle, scope remediation, cycle extension, and executor retry, so mid-run model changes take effect without starting a new run. Binary path, agent, profile, budget, timeout, and other executor settings are locked at run start.
- `model` - Executor model identifier (accepted as any non-empty string). When set, it is passed to Claude, Agy, Reasonix, and Codex. The effective model is reloaded from the latest config before each later cycle, scope remediation, cycle extension, and executor retry, so mid-run model changes take effect without starting a new run. Binary path, agent, profile, budget, timeout, and other executor settings are locked at run start.
Configuration files are forbidden from storing secrets (`apiKey`, `token`, `secret`, `password`, `credential`, etc.). If the latest config is invalid or the reloaded `model` is empty, the run enters a `blocked` state before launching the executor. After correcting the config, the same run can continue with `agent-workflow retry-execute --run <run-id>`.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "agent-workflow",
"version": "0.1.0",
"description": "Codex-hosted autonomous implementation loop with Reasonix, Claude Code, and Agy executors",
"description": "Codex-hosted autonomous implementation loop with Reasonix, Claude Code, Agy, and Codex executors",
"type": "module",
"author": "liujing",
"license": "MIT",
+5 -6
View File
@@ -5,7 +5,7 @@ description: Drive an explicitly invoked agent-workflow in which the host (Codex
# Agent Workflow Host
Act as the host planner, reviewer, and final acceptance authority. Let the selected executor implement and fix code. You must pass your own identity (codex or claude) as the `--reviewer` on lifecycle commands. Use `agent-workflow`; do not replace its state machine with direct executor commands. You cannot use the claude executor if you are the claude reviewer. On resume, you must inspect the `reviewer` field in the status output and refuse to continue if it belongs to a different host. When executing lifecycle commands, pass your concrete current identity (e.g. `--reviewer codex`) instead of using a literal `<codex|claude>` placeholder.
Act as the host planner, reviewer, and final acceptance authority. Let the selected executor implement and fix code. You must pass your own identity (codex or claude) as the `--reviewer` on lifecycle commands. Use `agent-workflow`; do not replace its state machine with direct executor commands. You cannot use the `claude` executor if you are the `claude` reviewer. You cannot use the `codex` executor if you are the `codex` reviewer. On resume, you must inspect the `reviewer` field in the status output and refuse to continue if it belongs to a different host. When executing lifecycle commands, pass your concrete current identity (e.g. `--reviewer codex`) instead of using a literal `<codex|claude>` placeholder.
## Invocation Contract
@@ -31,9 +31,9 @@ When invoked, the host (Codex or Claude) drives the workflow using its respectiv
- Treat an explicit `执行` invocation as authorization to execute the latest clearly bounded plan in the conversation.
- Do not start when the conversation contains multiple unresolved plan variants, missing scope, missing acceptance criteria, or missing verification commands. Ask only for the unresolved decision.
- If invoked without an operation, show the supported operations and do nothing.
- Default to `reasonix`. Use `claude` or `agy` only when the user explicitly selects it or the approved plan already names it.
- Default to `reasonix`. Use `claude`, `codex`, or `agy` only when the user explicitly selects it or the approved plan already names it.
- Never switch or fall back to another executor during a run.
- Treat the current task as the durable binding identified by `CODEX_THREAD_ID` plus the target repository. Each task may lazily bind one Reasonix, one Claude, and one Agy session; do not eagerly start unused executors.
- Treat the current task as the durable binding identified by `CODEX_THREAD_ID` plus the target repository. Each task may lazily bind one Reasonix, one Claude, one Codex, and one Agy session; do not eagerly start unused executors.
- A normal `执行` starts a new run in the current task and automatically reuses the selected executor's bound session when one exists. The other executor bindings remain untouched.
- Use `执行新任务计划` and pass `--new-task` only when the user clearly starts a materially different task. If the boundary is ambiguous, ask whether the prior executor context should be retained.
@@ -74,7 +74,7 @@ When invoked, the host (Codex or Claude) drives the workflow using its respectiv
7. Start the run and record the emitted run ID, then immediately print a logs-follow command the user can run independently:
```bash
agent-workflow start --plan <temp-plan.json> --executor <reasonix|claude|agy> --reviewer YOUR_IDENTITY [--vcs <git|svn|none>] [--new-task]
agent-workflow start --plan <temp-plan.json> --executor <reasonix|claude|agy|codex> --reviewer YOUR_IDENTITY [--vcs <git|svn|none>] [--new-task]
```
Do not pass `--new-task` for a follow-up plan, a correction, or another implementation slice of the same user goal. The CLI validates persisted task bindings before reuse and blocks stale or mismatched handles. An executor selected for the first time in the task starts lazily and is then recorded for later runs. Agy degraded `--continue` handles are intentionally not shared.
@@ -171,8 +171,7 @@ After `fix`, repeat review and decision for the new cycle. Respect the configure
- For `查看 <run-id>`, run `agent-workflow status --run <run-id> --json`, summarize it, and do not advance the workflow.
- For `继续 <run-id>`, read status first, then continue from `awaiting_review`, `awaiting_scope_resolution`, or `awaiting_host`. Read the persisted state and referenced cycle artifacts when earlier command output is no longer in context; do not reconstruct findings from memory.
- For `清理后重试 <run-id>`, run `agent-workflow retry-review --run <run-id> --reviewer YOUR_IDENTITY` after the approved scope artifacts have been removed. It must refuse when baseline drift or out-of-scope paths remain.
- For `执行器重试 <run-id>`, run `agent-workflow retry-execute --run <run-id>` after the external cause of an executor failure is cleared (e.g., session conflict resolved, transient error gone, or corrected an invalid `model` configuration). It preserves failed attempt logs and reuses the same session handle. If a Reasonix handle was not captured, the CLI may recover a persisted session only when its project directory, frozen plan title, and execution time window match the run, and must audit the recovery. Initial-cycle retries continue from the original plan; later-cycle retries resume from the preceding decision. Refuses when baseline has drifted, scope violations exist, or the cycle already produced review artifacts.
- For `执行器重试 <run-id> 使用会话 <claude-session-id>`, run `agent-workflow retry-execute --run <run-id> --session <claude-session-id>` only when a Claude run's recorded session was never created or is unavailable and a known persisted replacement session contains the same frozen plan. The CLI must validate that the session belongs to the same repository and plan before recording the rebind in the audit log and retrying. Never edit `state.json` manually or guess a session ID.
- For `执行器重试 <run-id>`, run `agent-workflow retry-execute --run <run-id>` after the external cause of an executor failure is cleared (e.g., session conflict resolved, transient error gone, or corrected an invalid `model` configuration). It preserves failed attempt logs and reuses the same session handle. If a Reasonix handle was not captured, the CLI may recover a persisted session only when its project directory, frozen plan title, and execution time window match the run, and must audit the recovery. For `执行器重试 <run-id> 使用会话 <claude-or-codex-session-id>`, explicitly bind a known Claude or Codex session; the CLI validates the session JSONL against the repository, frozen plan title, and executor kind, and records an audit event with the previous and new session IDs. Initial-cycle retries continue from the original plan; later-cycle retries resume from the preceding decision. Refuses when baseline has drifted, scope violations exist, or the cycle already produced review artifacts.
- For `延长 <run-id> 添加 <n> 个周期`, run `agent-workflow extend --run <run-id> --additional-cycles <n>` to extend a `budget_exhausted` run. This requires: the run ended at `budget_exhausted`, has a valid session handle, the last decision was `fix`, baseline hasn't drifted, and scope is clean. Returns to `awaiting_host` state to continue the same executor session.
- Treat `completed`, `needs_human`, `blocked`, and `aborted` as terminal. Report them instead of attempting another cycle.
- `budget_exhausted` is terminal but can be extended with the extend command if all conditions are met.
+29
View File
@@ -227,6 +227,35 @@ test("logFilePath: onProgress reports pid and bytesLogged", async () => {
}
});
test("spawnStreamingChild with input handles EPIPE when child closes stdin early", async () => {
const { stream } = collectSink();
// A child that immediately closes stdin and exits
const script = "process.stdin.destroy(); process.stdout.write('done\\n'); process.exit(0);";
const r = await spawnStreamingChild(process.execPath, ["-e", script], {
stdoutSink: stream,
stderrSink: stream,
input: "some very long input that will trigger EPIPE when stdin is closed",
});
assert.equal(r.status, 0);
assert.match(r.stdout, /done/);
});
test("spawnStreamingChild with input does not crash on async EPIPE", async () => {
const { stream } = collectSink();
// A child that reads once then closes stdin
const script = [
"process.stdin.once('data', () => { process.stdin.destroy(); });",
"process.stdout.write('got-data\\n');",
].join("");
const r = await spawnStreamingChild(process.execPath, ["-e", script], {
stdoutSink: stream,
stderrSink: stream,
input: "trigger\nmore-data-that-exceeds-what-child-reads",
});
assert.equal(r.status, 0);
assert.match(r.stdout, /got-data/);
});
test("logFilePath: write stream error resolves spawnStreamingChild with result.error", async () => {
const tmpDir = fs.mkdtempSync("/tmp/agent-workflow-log-err-test-");
try {
+19 -1
View File
@@ -68,6 +68,8 @@ export async function spawnStreamingChild(
onProgress?: (info: StreamProgress) => void;
/** Live callback for each stdout chunk (for real-time parsing, e.g. Claude JSON lines). */
onStdoutData?: (chunk: string) => void;
/** Text to write to stdin (enables pipe mode). */
input?: string;
},
): Promise<ChildRunResult> {
const stdoutSink = options.stdoutSink ?? process.stdout;
@@ -79,10 +81,26 @@ export async function spawnStreamingChild(
const spawnOpts: SpawnOptions = {
cwd: options.cwd,
env: options.env,
stdio: ["ignore", "pipe", "pipe"],
stdio: options.input ? ["pipe", "pipe", "pipe"] : ["ignore", "pipe", "pipe"],
detached: options.processGroup === true && process.platform !== "win32",
};
const child = spawn(command, argv, spawnOpts);
// Write stdin input if provided
if (options.input && child.stdin) {
// Attach error listener before writing to avoid unhandled EPIPE
child.stdin.on("error", (err: Error) => {
// EPIPE is expected if the child closes stdin early
if ((err as NodeJS.ErrnoException).code === "EPIPE") return;
// Log other errors (will be captured via stderr or exit code)
});
try {
child.stdin.write(options.input);
child.stdin.end();
} catch {
// Ignore EPIPE if the child closed stdin early
}
}
let stdout = "";
let stderr = "";
let settled = false;
+3
View File
@@ -93,6 +93,8 @@ export {
probeExecutor,
validateClaudeSessionForRepository,
validateClaudeSessionForWorkflow,
validateCodexSessionForRepository,
validateCodexSessionForWorkflow,
validateReasonixSessionForRepository,
} from "./workflow-executor.js";
@@ -155,6 +157,7 @@ export type {
ExecutorSessionHandle,
ReasonixSessionHandle,
ClaudeSessionHandle,
CodexSessionHandle,
AgySessionHandle,
CycleRecord,
ScopeViolation,
+2 -2
View File
@@ -105,10 +105,10 @@ function parsePositiveInt(flag: string, value: string): number {
export const WORKFLOW_USAGE = `
Usage:
agent-workflow start --executor <reasonix|claude|agy> --plan <plan.json|-> [--reviewer <codex|claude>] [--vcs <git|svn|none>] [--max-cycles <n>] [--new-task]
agent-workflow start --executor <reasonix|claude|agy|codex> --plan <plan.json|-> [--reviewer <codex|claude>] [--vcs <git|svn|none>] [--max-cycles <n>] [--new-task]
agent-workflow review --run <run-id> --reviewer <codex|claude>
agent-workflow retry-review --run <run-id> --reviewer <codex|claude>
agent-workflow retry-execute --run <run-id> [--session <claude-session-id>]
agent-workflow retry-execute --run <run-id> [--session <claude-or-codex-session-id>]
agent-workflow decide --run <run-id> --input <decision.json|-> --reviewer <codex|claude>
agent-workflow extend --run <run-id> --additional-cycles <n>
agent-workflow status --run <run-id> [--json]
+18 -6
View File
@@ -29,6 +29,8 @@ import {
probeExecutor,
validateClaudeSessionForRepository,
validateClaudeSessionForWorkflow,
validateCodexSessionForRepository,
validateCodexSessionForWorkflow,
validateReasonixSessionForRepository,
} from "./workflow-executor.js";
import {
@@ -304,6 +306,9 @@ function validateTaskSessionHandle(
case "claude":
validateClaudeSessionForRepository(handle.sessionId, repoRoot);
return;
case "codex":
validateCodexSessionForRepository(handle.sessionId, repoRoot);
return;
case "agy":
if (handle.degraded || !handle.conversationId || !/^[0-9a-f-]{36}$/i.test(handle.conversationId)) {
throw new WorkflowTaskBindingError("Task binding contains an Agy session without a reliable conversation ID.");
@@ -497,6 +502,9 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
if (reviewer === "claude" && config.executor === "claude") {
throw new WorkflowEngineError("Reviewer 'claude' cannot be used with executor 'claude'. Claude cannot self-review.", 4);
}
if (reviewer === "codex" && config.executor === "codex") {
throw new WorkflowEngineError("Reviewer 'codex' cannot be used with executor 'codex'. Codex cannot self-review.", 4);
}
const vcsProvider = createVcsProvider(vcsKind);
const codexThreadId = (opts.codexThreadId ?? process.env.CODEX_THREAD_ID)?.trim();
@@ -1530,8 +1538,8 @@ export interface RetryExecuteWorkflowOpts {
export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Promise<void> {
const { state, dir } = requireState(opts.runId);
if (opts.sessionId && state.executor !== "claude") {
throw new WorkflowEngineError("--session can only be used with a Claude executor run.", 4);
if (opts.sessionId && state.executor !== "claude" && state.executor !== "codex") {
throw new WorkflowEngineError("--session can only be used with a Claude or Codex executor run.", 4);
}
const staleExecution = state.status === "executing"
&& state.enginePid !== undefined
@@ -1609,9 +1617,13 @@ export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Prom
if (opts.sessionId) {
try {
if (state.executor === "claude") {
validateClaudeSessionForWorkflow(opts.sessionId, state.repoRoot, state.plan.title);
} else if (state.executor === "codex") {
validateCodexSessionForWorkflow(opts.sessionId, state.repoRoot, state.plan.title);
}
} catch (err) {
throw new WorkflowEngineError(`Cannot rebind Claude session: ${(err as Error).message}`, 4);
throw new WorkflowEngineError(`Cannot rebind ${state.executor} session: ${(err as Error).message}`, 4);
}
}
@@ -1640,11 +1652,11 @@ export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Prom
throw err;
}
const previousSessionId = state.sessionHandle?.kind === "claude"
const previousSessionId = (state.sessionHandle?.kind === "claude" || state.sessionHandle?.kind === "codex")
? state.sessionHandle.sessionId
: undefined;
if (opts.sessionId) {
state.sessionHandle = { kind: "claude", sessionId: opts.sessionId };
state.sessionHandle = { kind: state.executor as "claude" | "codex", sessionId: opts.sessionId };
} else if (recoveredReasonixSession) {
state.sessionHandle = { kind: "reasonix", sessionFilePath: recoveredReasonixSession };
}
@@ -1664,7 +1676,7 @@ export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Prom
runId: state.runId,
timestamp: state.updatedAt,
cycleIndex: state.currentCycle,
data: { previousSessionId, sessionId: opts.sessionId },
data: { previousSessionId, sessionId: opts.sessionId, executor: state.executor },
});
} else if (recoveredReasonixSession) {
appendEvent(dir, {
+477
View File
@@ -63,6 +63,38 @@ exit ${exitCode}
return scriptPath;
}
/**
* Creates a fake CLI script that records argv (null-delimited), stdin content,
* and the environment (CODEX_THREAD_ID presence) for testing exact adapter invocation.
*/
function writeRecordingFake(dir: string, opts: {
exitCode?: number;
stdout?: string;
stderr?: string;
argvFile?: string;
stdinFile?: string;
envFile?: string;
}): string {
const scriptPath = path.join(dir, "recording-fake.sh");
const argvFile = opts.argvFile ?? path.join(dir, "recorded-argv.txt");
const stdinFile = opts.stdinFile ?? path.join(dir, "recorded-stdin.txt");
const envFile = opts.envFile ?? path.join(dir, "recorded-env.txt");
const exitCode = opts.exitCode ?? 0;
const stdout = (opts.stdout ?? "").replace(/'/g, "'\\''");
const stderr = (opts.stderr ?? "").replace(/'/g, "'\\''");
const script = `#!/bin/bash
printf '%s\\0' "$@" > "${argvFile}"
cat - > "${stdinFile}"
echo "CODEX_THREAD_ID=\${CODEX_THREAD_ID:-<unset>}" > "${envFile}"
echo '${stdout}'
echo '${stderr}' >&2
exit ${exitCode}
`;
fs.writeFileSync(scriptPath, script, { mode: 0o755 });
return scriptPath;
}
// ---------------------------------------------------------------------------
// Reasonix adapter tests
// ---------------------------------------------------------------------------
@@ -947,4 +979,449 @@ exit 1
await expect(probeExecutor("reasonix", { binary: "/nonexistent/path/reasonix" }))
.rejects.toThrow(/agent-workflow\.json/);
});
// ---------------------------------------------------------------------------
// Codex adapter tests
// ---------------------------------------------------------------------------
it("Codex adapter start passes exact args and stdin, strips CODEX_THREAD_ID", async () => {
const argvFile = path.join(tmpDir, "recorded-argv.txt");
const stdinFile = path.join(tmpDir, "recorded-stdin.txt");
const envFile = path.join(tmpDir, "recorded-env.txt");
const fakeBin = writeRecordingFake(tmpDir, {
stdout: '{"type":"thread.started","thread_id":"abc-def-123","thread":{"id":"nested-id"}}\n{"type":"item.started","item":{"tool_name":"Bash","input":"echo hello"}}\n{"type":"turn.completed"}\n{"type":"result","result":"done"}',
argvFile, stdinFile, envFile,
});
const adapter = createExecutorAdapter("codex");
const result = await adapter.start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin, model: "gemini-2.5-pro" },
});
const capturedArgs = fs.readFileSync(argvFile, "utf8").split("\0").filter(Boolean);
expect(capturedArgs).toEqual([
"exec", "--json",
"--sandbox", "workspace-write",
"--cd", tmpDir,
"--model", "gemini-2.5-pro",
"-",
]);
const capturedStdin = fs.readFileSync(stdinFile, "utf8");
expect(capturedStdin.length).toBeGreaterThan(10);
expect(capturedStdin).toContain("Do something.");
const capturedEnv = fs.readFileSync(envFile, "utf8");
expect(capturedEnv).toContain("CODEX_THREAD_ID=<unset>");
expect(result.failed).toBe(false);
expect(result.sessionHandle).toEqual({ kind: "codex", sessionId: "abc-def-123" });
});
it("Codex adapter start without model does not pass --model", async () => {
const argvFile = path.join(tmpDir, "recorded-argv-no-model.txt");
const stdinFile = path.join(tmpDir, "recorded-stdin-no-model.txt");
const envFile = path.join(tmpDir, "recorded-env-no-model.txt");
const fakeBin = writeRecordingFake(tmpDir, {
stdout: '{"type":"thread.started","thread_id":"no-model-id"}',
argvFile, stdinFile, envFile,
});
const adapter = createExecutorAdapter("codex");
await adapter.start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin },
});
const capturedArgs = fs.readFileSync(argvFile, "utf8").split("\0").filter(Boolean);
expect(capturedArgs).not.toContain("--model");
expect(capturedArgs).toContain("-");
});
it("Codex adapter resume passes exact args and stdin, strips CODEX_THREAD_ID", async () => {
const argvFile = path.join(tmpDir, "recorded-resume-argv.txt");
const stdinFile = path.join(tmpDir, "recorded-resume-stdin.txt");
const envFile = path.join(tmpDir, "recorded-resume-env.txt");
const sessionId = "resume-session-456";
const fakeBin = writeRecordingFake(tmpDir, {
stdout: '{"type":"thread.started","thread_id":"resume-session-456"}',
argvFile, stdinFile, envFile,
});
const adapter = createExecutorAdapter("codex");
const result = await adapter.resume({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 2,
config: { binary: fakeBin, model: "gemini-2.5-flash" },
handle: { kind: "codex", sessionId },
acceptedFindings: "# Fix issues\n\n## Finding fix-1\n- Problem: test\n",
});
const capturedArgs = fs.readFileSync(argvFile, "utf8").split("\0").filter(Boolean);
expect(capturedArgs).toEqual([
"exec", "resume",
"-c", 'sandbox_mode="workspace-write"',
"--json",
"--model", "gemini-2.5-flash",
sessionId,
"-",
]);
const capturedEnv = fs.readFileSync(envFile, "utf8");
expect(capturedEnv).toContain("CODEX_THREAD_ID=<unset>");
expect(result.failed).toBe(false);
expect(result.sessionHandle).toEqual({ kind: "codex", sessionId: "resume-session-456" });
});
it("Codex adapter resume without model does not pass --model", async () => {
const argvFile = path.join(tmpDir, "recorded-resume-no-model.txt");
const fakeBin = writeRecordingFake(tmpDir, {
stdout: '{"type":"thread.started","thread_id":"resume-id"}',
argvFile,
});
const adapter = createExecutorAdapter("codex");
await adapter.resume({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 2,
config: { binary: fakeBin },
handle: { kind: "codex", sessionId: "resume-id" },
acceptedFindings: "# fix\n",
});
const capturedArgs = fs.readFileSync(argvFile, "utf8").split("\0").filter(Boolean);
expect(capturedArgs).not.toContain("--model");
});
it("Codex adapter extracts thread_id from top-level field; retains nested thread.id as BWC", async () => {
const fakeBin = path.join(tmpDir, "codex-bwc");
const threadId = "top-level-thread-id";
fs.writeFileSync(fakeBin, `#!/bin/bash
echo '{"type":"thread.started","thread_id":"${threadId}","thread":{"id":"nested-id"}}'
echo '{"type":"result","result":"done"}'
exit 0
`, { mode: 0o755 });
const adapter = createExecutorAdapter("codex");
const result = await adapter.start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin },
});
expect(result.sessionHandle).toEqual({ kind: "codex", sessionId: threadId });
});
it("Codex adapter falls back to nested thread.id when thread_id absent", async () => {
const fakeBin = path.join(tmpDir, "codex-nested");
const nestedId = "nested-thread-id-only";
fs.writeFileSync(fakeBin, `#!/bin/bash
echo '{"type":"thread.started","thread":{"id":"${nestedId}"}}'
echo '{"type":"result","result":"done"}'
exit 0
`, { mode: 0o755 });
const adapter = createExecutorAdapter("codex");
const result = await adapter.start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin },
});
expect(result.sessionHandle).toEqual({ kind: "codex", sessionId: nestedId });
});
it("Codex adapter partial output with thread ID then nonzero exit marks failed, no handle", async () => {
const fakeBin = path.join(tmpDir, "codex-partial-fail");
const partialThreadId = "partially-emitted-thread-id";
fs.writeFileSync(fakeBin, `#!/bin/bash
echo '{"type":"thread.started","thread_id":"${partialThreadId}"}'
exit 1
`, { mode: 0o755 });
const adapter = createExecutorAdapter("codex");
const result = await adapter.start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin },
});
expect(result.failed).toBe(true);
expect(result.exitCode).toBe(1);
expect(result.sessionHandle).toBeUndefined();
});
it("Codex adapter parses item.started with tool_name", async () => {
const fakeBin = path.join(tmpDir, "codex-tool-name");
fs.writeFileSync(fakeBin, `#!/bin/bash
echo '{"type":"thread.started","thread_id":"tid-1"}'
echo '{"type":"item.started","item":{"tool_name":"Read","input":{"file_path":"src/main.ts"}}}'
echo '{"type":"item.completed"}'
echo '{"type":"turn.completed"}'
echo '{"type":"result","result":"done"}'
exit 0
`, { mode: 0o755 });
const adapter = createExecutorAdapter("codex");
const result = await adapter.start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin },
});
expect(result.failed).toBe(false);
expect(result.sessionHandle).toEqual({ kind: "codex", sessionId: "tid-1" });
});
it("Codex adapter parses message-based error events", async () => {
const fakeBin = path.join(tmpDir, "codex-msg-error");
fs.writeFileSync(fakeBin, `#!/bin/bash
echo '{"type":"error","message":{"content":"API rate limit hit"}}'
exit 1
`, { mode: 0o755 });
const adapter = createExecutorAdapter("codex");
const result = await adapter.start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin },
});
expect(result.failed).toBe(true);
expect(result.failureReason).toContain("API rate limit hit");
});
it("Codex adapter rejects empty session ID as failure", async () => {
const fakeBin = path.join(tmpDir, "codex-no-session");
fs.writeFileSync(fakeBin, `#!/bin/bash
echo '{"type":"result","result":"done"}'
exit 0
`, { mode: 0o755 });
const adapter = createExecutorAdapter("codex");
const result = await adapter.start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin },
});
expect(result.failed).toBe(true);
expect(result.failureReason).toContain("no resumable session ID");
expect(result.sessionHandle).toBeUndefined();
});
it("Codex adapter parses error events as failure", async () => {
const fakeBin = path.join(tmpDir, "codex-error");
fs.writeFileSync(fakeBin, `#!/bin/bash
echo '{"type":"error","error":"API rate limit exceeded"}'
exit 1
`, { mode: 0o755 });
const adapter = createExecutorAdapter("codex");
const result = await adapter.start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin },
});
expect(result.failed).toBe(true);
expect(result.exitCode).toBe(1);
});
it("validateCodexSessionForRepository finds rollout-named session in active dir", async () => {
const { validateCodexSessionForRepository } = await import("./workflow-executor.js");
const sessionId = "c5e7a123-4b6d-8f90-1a2b-3c4d5e6f7890";
const rolloutFile = `rollout-20260501-${sessionId}.jsonl`;
const originalHome = process.env.HOME;
process.env.HOME = tmpDir;
try {
const sessionsDir = path.join(tmpDir, ".codex", "sessions");
fs.mkdirSync(sessionsDir, { recursive: true });
fs.writeFileSync(
path.join(sessionsDir, rolloutFile),
JSON.stringify({ type: "session_meta", payload: { id: sessionId, cwd: tmpDir } }) + "\n",
);
const result = validateCodexSessionForRepository(sessionId, tmpDir);
expect(result).toBeDefined();
expect(result).toContain(rolloutFile);
} finally {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
}
});
it("validateCodexSessionForRepository finds rollout-named session in archived_sessions dir", async () => {
const { validateCodexSessionForRepository } = await import("./workflow-executor.js");
const sessionId = "d8f6b234-5c7e-9a01-2b3c-4d5e6f7a8901";
const rolloutFile = `rollout-20260502-${sessionId}.jsonl`;
const originalHome = process.env.HOME;
process.env.HOME = tmpDir;
try {
const archivedDir = path.join(tmpDir, ".codex", "archived_sessions");
fs.mkdirSync(archivedDir, { recursive: true });
fs.writeFileSync(
path.join(archivedDir, rolloutFile),
JSON.stringify({ type: "session_meta", payload: { id: sessionId, cwd: tmpDir } }) + "\n",
);
const result = validateCodexSessionForRepository(sessionId, tmpDir);
expect(result).toBeDefined();
expect(result).toContain(rolloutFile);
} finally {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
}
});
it("validateCodexSessionForWorkflow succeeds with matching repo, plan title, and rollout filename", async () => {
const { validateCodexSessionForWorkflow } = await import("./workflow-executor.js");
const sessionId = "a1b2c3d4-5e6f-7890-abcd-ef1234567890";
const rolloutFile = `rollout-20260503-${sessionId}.jsonl`;
const planTitle = "My Frozen Plan Title";
const originalHome = process.env.HOME;
process.env.HOME = tmpDir;
try {
const sessionsDir = path.join(tmpDir, ".codex", "sessions");
fs.mkdirSync(sessionsDir, { recursive: true });
// Include the plan title in a message content so validation passes
fs.writeFileSync(
path.join(sessionsDir, rolloutFile),
JSON.stringify({ type: "session_meta", payload: { id: sessionId, cwd: tmpDir, planTitle } }) + "\n" +
JSON.stringify({ type: "message", message: { content: planTitle } }) + "\n",
);
const result = validateCodexSessionForWorkflow(sessionId, tmpDir, planTitle);
expect(result).toContain(rolloutFile);
} finally {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
}
});
it("validateCodexSessionForWorkflow rejects session with mismatched ID", async () => {
const { validateCodexSessionForWorkflow } = await import("./workflow-executor.js");
const sessionId = "e9a7c345-6d8f-0b12-3c4d-5e6f7a8b9012";
const originalHome = process.env.HOME;
process.env.HOME = tmpDir;
try {
const sessionsDir = path.join(tmpDir, ".codex", "sessions");
fs.mkdirSync(sessionsDir, { recursive: true });
// Write file with mismatched ID
fs.writeFileSync(
path.join(sessionsDir, `rollout-20260504-${sessionId}.jsonl`),
JSON.stringify({ type: "session_meta", payload: { id: "different-id", cwd: tmpDir } }) + "\n",
);
expect(() =>
validateCodexSessionForWorkflow(sessionId, tmpDir, "Test Plan"),
).toThrow(/does not belong to repository/);
} finally {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
}
});
it("validateCodexSessionForWorkflow rejects session with wrong repo", async () => {
const { validateCodexSessionForWorkflow } = await import("./workflow-executor.js");
const sessionId = "f0b8d456-7e9a-1c23-4d5e-6f7a8b9c0123";
const originalHome = process.env.HOME;
process.env.HOME = tmpDir;
try {
const sessionsDir = path.join(tmpDir, ".codex", "sessions");
fs.mkdirSync(sessionsDir, { recursive: true });
fs.writeFileSync(
path.join(sessionsDir, `rollout-20260505-${sessionId}.jsonl`),
JSON.stringify({ type: "session_meta", payload: { id: sessionId, cwd: "/different/repo" } }) + "\n",
);
expect(() =>
validateCodexSessionForWorkflow(sessionId, tmpDir, "Test Plan"),
).toThrow(/does not belong to repository/);
} finally {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
}
});
it("validateCodexSessionForWorkflow rejects session with wrong plan title", async () => {
const { validateCodexSessionForWorkflow } = await import("./workflow-executor.js");
const sessionId = "a2b3c4d5-6e7f-8901-abcd-ef2345678901";
const originalHome = process.env.HOME;
process.env.HOME = tmpDir;
try {
const sessionsDir = path.join(tmpDir, ".codex", "sessions");
fs.mkdirSync(sessionsDir, { recursive: true });
fs.writeFileSync(
path.join(sessionsDir, `rollout-20260506-${sessionId}.jsonl`),
JSON.stringify({ type: "session_meta", payload: { id: sessionId, cwd: tmpDir } }) + "\n",
);
expect(() =>
validateCodexSessionForWorkflow(sessionId, tmpDir, "Non-existent Title"),
).toThrow(/does not contain frozen plan/);
} finally {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
}
});
it("validateCodexSessionForRepository rejects duplicate candidates across dirs", async () => {
const { validateCodexSessionForRepository } = await import("./workflow-executor.js");
const sessionId = "b3c4d5e6-7f8a-9012-bcde-f34567890123";
const originalHome = process.env.HOME;
process.env.HOME = tmpDir;
try {
// Create same session file in both sessions and archived_sessions
const sessionsDir = path.join(tmpDir, ".codex", "sessions");
fs.mkdirSync(sessionsDir, { recursive: true });
fs.writeFileSync(
path.join(sessionsDir, `rollout-20260507-${sessionId}.jsonl`),
JSON.stringify({ type: "session_meta", payload: { id: sessionId, cwd: tmpDir } }) + "\n",
);
const archivedDir = path.join(tmpDir, ".codex", "archived_sessions");
fs.mkdirSync(archivedDir, { recursive: true });
fs.writeFileSync(
path.join(archivedDir, `rollout-20260508-${sessionId}.jsonl`),
JSON.stringify({ type: "session_meta", payload: { id: sessionId, cwd: tmpDir } }) + "\n",
);
expect(() =>
validateCodexSessionForRepository(sessionId, tmpDir),
).toThrow(/Multiple/);
} finally {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
}
});
});
+358 -1
View File
@@ -16,6 +16,7 @@ import { spawnStreamingChild } from "./child-process.js";
import type {
AgySessionHandle,
ClaudeSessionHandle,
CodexSessionHandle,
ExecutorAdapter,
ExecutorConfig,
ExecutorKind,
@@ -72,7 +73,7 @@ function getSafetyConstraints(vcsKind?: VcsKind): string {
function startProgressSidecar(
dir: string,
executor: "reasonix" | "claude" | "agy",
executor: ExecutorKind,
cycleIndex: number,
attemptIndex: number,
logFilePath: string | undefined,
@@ -1172,6 +1173,360 @@ class AgyAdapter implements ExecutorAdapter {
}
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Codex adapter
// ---------------------------------------------------------------------------
function extractCodexSessionId(output: string): string | undefined {
try {
for (const line of output.split("\n")) {
const trimmed = line.trim();
if (!trimmed.startsWith("{")) continue;
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
// Real Codex thread.started carries thread_id at event top level
if (parsed.type === "thread.started") {
if (typeof parsed.thread_id === "string") return parsed.thread_id;
const thread = parsed.thread as Record<string, unknown> | undefined;
if (thread && typeof thread.id === "string") return thread.id;
}
// Legacy fallback
if (typeof parsed.session_id === "string") return parsed.session_id;
if (parsed.type === "session_meta" && typeof parsed.id === "string") return parsed.id;
}
} catch {
// fall through
}
const m = output.match(/"session_id"\s*:\s*"([^"]+)"/);
return m?.[1];
}
export function validateCodexSessionForWorkflow(
sessionId: string,
repoRoot: string,
planTitle: string,
): string {
const { sessionFile, content } = readCodexSessionForRepository(sessionId, repoRoot);
if (!content.includes(planTitle)) {
throw new Error(`Codex session ${sessionId} does not contain frozen plan title: ${planTitle}`);
}
return sessionFile;
}
export function validateCodexSessionForRepository(sessionId: string, repoRoot: string): string {
return readCodexSessionForRepository(sessionId, repoRoot).sessionFile;
}
function readCodexSessionForRepository(
sessionId: string,
repoRoot: string,
): { sessionFile: string; content: string } {
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId)) {
throw new Error(`Invalid Codex session ID: ${sessionId}`);
}
const homeDir = process.env.HOME || os.homedir();
const searchDirs = [
path.join(homeDir, ".codex", "sessions"),
path.join(homeDir, ".codex", "archived_sessions"),
];
const candidates: string[] = [];
const findSessionFile = (dir: string) => {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
findSessionFile(path.join(dir, entry.name));
} else if (entry.isFile()) {
// Real rollout filenames: rollout-<timestamp>-<uuid>.jsonl
const name = entry.name;
if (name.endsWith(".jsonl") && (name.includes(sessionId) || name === `${sessionId}.jsonl`)) {
candidates.push(path.join(dir, entry.name));
}
}
}
};
for (const searchDir of searchDirs) {
findSessionFile(searchDir);
}
if (candidates.length !== 1) {
throw new Error(
candidates.length === 0
? `Codex session file not found for ID: ${sessionId}`
: `Multiple Codex session files found for ID: ${sessionId}`,
);
}
const sessionFile = candidates[0];
const fd = fs.openSync(sessionFile, "r");
const maxBytes = 4 * 1024 * 1024;
const buffer = Buffer.alloc(maxBytes);
let content: string;
try {
const bytesRead = fs.readSync(fd, buffer, 0, maxBytes, 0);
content = buffer.toString("utf8", 0, bytesRead);
} finally {
fs.closeSync(fd);
}
let repoMatches = false;
let sessionMatches = false;
for (const line of content.split("\n")) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line) as Record<string, unknown>;
if (entry.type === "session_meta") {
const payload = entry.payload as Record<string, unknown> | undefined;
// Parse session ID from payload.id
const entryId = (payload?.id as string) ?? (entry.id as string) ?? (entry.session_id as string);
if (entryId === sessionId) sessionMatches = true;
// Parse working directory from payload.cwd
const cwd = (payload?.cwd as string) ?? (entry.cwd as string);
if (cwd && (path.resolve(cwd) === path.resolve(repoRoot) || path.normalize(cwd) === path.normalize(repoRoot))) {
repoMatches = true;
}
}
if (entry.session_id === sessionId) sessionMatches = true;
if (entry.cwd && path.resolve(entry.cwd as string) === path.resolve(repoRoot)) repoMatches = true;
} catch {
// Ignore a trailing partial line
}
}
if (!sessionMatches || !repoMatches) {
throw new Error(`Codex session ${sessionId} does not belong to repository ${repoRoot}`);
}
return { sessionFile, content };
}
function deriveCodexActivity(line: string): string | undefined {
const trimmed = line.trim();
if (!trimmed.startsWith("{")) return undefined;
try {
const ev = JSON.parse(trimmed) as Record<string, unknown>;
if (ev.type === "item.started" && typeof ev.item === "object" && ev.item) {
const item = ev.item as Record<string, unknown>;
// Real Codex item events use item.tool_name or item.name
if (typeof item.tool_name === "string") return `Tool: ${item.tool_name}`;
if (typeof item.name === "string") return `Tool: ${item.name}`;
if (typeof item.type === "string") return `Item: ${item.type}`;
}
if (ev.type === "item.completed") return "Item completed";
if (ev.type === "turn.completed") return "Turn completed";
if (ev.type === "thread.started") return "Thread started";
if (ev.type === "tool" && typeof ev.name === "string") return `Tool: ${ev.name}`;
if (ev.type === "message") return "Message...";
if (ev.type === "completion") return "Completion...";
if (ev.type === "initialization") return "Initializing...";
// Real Codex error may be in message or error field
if (ev.type === "error") {
const msg = typeof ev.message === "string" ? ev.message : typeof ev.error === "string" ? ev.error : undefined;
if (msg) return `Error: ${msg}`;
}
} catch {
// fall through
}
return undefined;
}
function extractCodexFailureReason(output: string): string | undefined {
for (const line of output.split("\n")) {
const trimmed = line.trim();
if (!trimmed.startsWith("{")) continue;
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
// Real Codex error includes message field
if (parsed.type === "error") {
const msg = typeof parsed.message === "string" && parsed.message.trim() ? parsed.message :
typeof parsed.error === "string" && parsed.error.trim() ? parsed.error :
typeof parsed.message === "object" && parsed.message !== null && typeof (parsed.message as Record<string, unknown>).content === "string" ? (parsed.message as Record<string, unknown>).content as string :
typeof parsed.error === "object" && parsed.error !== null && typeof (parsed.error as Record<string, unknown>).content === "string" ? (parsed.error as Record<string, unknown>).content as string : undefined;
if (msg) return msg.trim();
}
} catch {
// skip
}
}
return undefined;
}
function codexExecutorEnv(): NodeJS.ProcessEnv {
const env = executorEnv();
delete env.CODEX_THREAD_ID;
return env;
}
class CodexAdapter implements ExecutorAdapter {
readonly kind: ExecutorKind = "codex";
async probe(config: ExecutorConfig = {}): Promise<boolean> {
try {
const res = spawnSync(resolveExecutable(config, "codex"), ["--version"], {
stdio: "ignore",
env: codexExecutorEnv(),
});
return res.status === 0 && !res.error;
} catch {
return false;
}
}
async start(opts: ExecutorStartOpts): Promise<ExecutorResult> {
const bin = resolveExecutable(opts.config, "codex");
const prompt = buildStartPrompt(opts);
// Codex CLI accepts prompt via stdin using '-' argument
const args: string[] = [
"exec",
"--json",
"--sandbox", "workspace-write",
"--cd", opts.repoRoot,
...(opts.config.model ? ["--model", opts.config.model] : []),
"-",
];
const startMs = Date.now();
const logFile = opts.logFilePath;
const attemptIndex = deriveAttemptIndex(logFile);
const sidecarTimer = startProgressSidecar(opts.runDir, "codex", opts.cycleIndex, attemptIndex, logFile);
let lineBuffer = "";
const child = await spawnStreamingChild(bin, args, {
cwd: opts.repoRoot,
env: codexExecutorEnv(),
input: prompt,
signal: opts.signal,
processGroup: true,
logFilePath: logFile,
onProgress: ({ pid, bytesLogged }) => {
opts.onActivity?.();
updateSidecar(opts.runDir, { pid, logBytes: bytesLogged, lastActivityAt: nowIso() });
},
onStdoutData: (chunk: string) => {
lineBuffer += chunk;
while (true) {
const nl = lineBuffer.indexOf("\n");
if (nl < 0) break;
const line = lineBuffer.slice(0, nl);
lineBuffer = lineBuffer.slice(nl + 1);
const activity = deriveCodexActivity(line);
if (activity) {
const isToolActivity = !activity.startsWith("Error:");
if (isToolActivity) {
updateSidecar(opts.runDir, { activitySummary: activity, lastActivityAt: nowIso() });
} else {
updateSidecar(opts.runDir, { lastActivityAt: nowIso() });
}
}
}
},
});
let failed = child.status !== 0 || !!child.error;
const capturedId = extractCodexSessionId(child.stdout) ?? extractCodexSessionId(child.stderr);
let failureReason = child.error?.message
?? extractCodexFailureReason(child.stdout)
?? extractCodexFailureReason(child.stderr);
if (!failed && !capturedId) {
failed = true;
failureReason = "Codex execution completed successfully but yielded no resumable session ID.";
}
stopProgressSidecar(opts.runDir, sidecarTimer, failed);
const durationMs = Date.now() - startMs;
const handle: CodexSessionHandle | undefined = failed
? undefined
: { kind: "codex", sessionId: capturedId! };
const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : "");
return {
exitCode: child.status,
report,
durationMs,
...(handle ? { sessionHandle: handle } : {}),
failed,
...(failureReason ? { failureReason } : {}),
};
}
async resume(opts: ExecutorResumeOpts): Promise<ExecutorResult> {
const handle = opts.handle as CodexSessionHandle;
const bin = resolveExecutable(opts.config, "codex");
const prompt = buildFixPrompt(opts);
// resume uses -c sandbox_mode= override (--sandbox is rejected by codex exec resume)
const args: string[] = [
"exec", "resume",
"-c", 'sandbox_mode="workspace-write"',
"--json",
...(opts.config.model ? ["--model", opts.config.model] : []),
handle.sessionId,
"-",
];
const startMs = Date.now();
const logFile = opts.logFilePath;
const attemptIndex = deriveAttemptIndex(logFile);
const sidecarTimer = startProgressSidecar(opts.runDir, "codex", opts.cycleIndex, attemptIndex, logFile);
let lineBuffer = "";
const child = await spawnStreamingChild(bin, args, {
cwd: opts.repoRoot,
env: codexExecutorEnv(),
input: prompt,
signal: opts.signal,
processGroup: true,
logFilePath: logFile,
onProgress: ({ pid, bytesLogged }) => {
opts.onActivity?.();
updateSidecar(opts.runDir, { pid, logBytes: bytesLogged, lastActivityAt: nowIso() });
},
onStdoutData: (chunk: string) => {
lineBuffer += chunk;
while (true) {
const nl = lineBuffer.indexOf("\n");
if (nl < 0) break;
const line = lineBuffer.slice(0, nl);
lineBuffer = lineBuffer.slice(nl + 1);
const activity = deriveCodexActivity(line);
if (activity) {
const isToolActivity = !activity.startsWith("Error:");
if (isToolActivity) {
updateSidecar(opts.runDir, { activitySummary: activity, lastActivityAt: nowIso() });
} else {
updateSidecar(opts.runDir, { lastActivityAt: nowIso() });
}
}
}
},
});
const failed = child.status !== 0 || !!child.error;
stopProgressSidecar(opts.runDir, sidecarTimer, failed);
const durationMs = Date.now() - startMs;
const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : "");
const failureReason = child.error?.message
?? extractCodexFailureReason(child.stdout)
?? extractCodexFailureReason(child.stderr);
return {
exitCode: child.status,
report,
durationMs,
sessionHandle: handle,
failed,
...(failureReason ? { failureReason } : {}),
};
}
async cancel(_handle: ExecutorSessionHandle): Promise<void> {
}
}
// Factory
// ---------------------------------------------------------------------------
@@ -1183,6 +1538,8 @@ export function createExecutorAdapter(kind: ExecutorKind): ExecutorAdapter {
return new ClaudeAdapter();
case "agy":
return new AgyAdapter();
case "codex":
return new CodexAdapter();
default: {
const _exhaustive: never = kind;
throw new Error(`Unknown executor kind: ${_exhaustive}`);
+177
View File
@@ -145,9 +145,14 @@ describe("discoverExecutorModels isolation", () => {
fs.mkdirSync(binDir, { recursive: true });
const agyBin = path.join(binDir, "agy");
const reasonixBin = path.join(binDir, "reasonix");
const codexBin = path.join(binDir, "codex");
fs.writeFileSync(agyBin, "#!/bin/sh\nprintf '%s\\n' 'Test Agy Model'\n", { mode: 0o755 });
fs.writeFileSync(reasonixBin, `#!/bin/sh
printf '%s\\n' '{"providers":[{"name":"test-provider","models":["test-model"]}]}'
`, { mode: 0o755 });
fs.writeFileSync(codexBin, `#!/bin/sh
printf '%s\\n' 'codex-model-1'
printf '%s\\n' 'codex-model-2'
`, { mode: 0o755 });
const configDir = path.join(tmpHome, ".agent-workflow");
@@ -157,6 +162,7 @@ printf '%s\\n' '{"providers":[{"name":"test-provider","models":["test-model"]}]}
executors: {
agy: { binary: agyBin },
reasonix: { binary: reasonixBin },
codex: { binary: codexBin },
},
}));
});
@@ -252,3 +258,174 @@ printf '%s\\n' '{"providers":[{"name":"test-provider","models":["test-model"]}]}
expect(JSON.stringify(result)).not.toContain("token-from-settings");
});
});
describe("Codex model discovery JSON format", () => {
let tmpHome: string;
let originalHome: string | undefined;
let binDir: string;
let originalFetch: typeof globalThis.fetch | undefined;
let originalPath: string;
const ANTHROPIC_VARS = [
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_BASE_URL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
] as const;
const savedVars: Record<string, string | undefined> = {};
beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-codex-json-"));
originalHome = process.env.HOME;
process.env.HOME = tmpHome;
originalPath = process.env.PATH ?? "";
for (const key of ANTHROPIC_VARS) {
savedVars[key] = process.env[key];
delete process.env[key];
}
originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(async () => {
throw new Error("fetch not mocked for Claude in Codex test");
}) as unknown as typeof fetch;
binDir = path.join(tmpHome, "bin");
fs.mkdirSync(binDir, { recursive: true });
const configDir = path.join(tmpHome, ".agent-workflow");
fs.mkdirSync(configDir, { recursive: true });
// Ensure other executor bins (agy, reasonix) are NOT found in PATH
process.env.PATH = `/dev/null:/usr/bin:/bin`;
});
afterEach(() => {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
for (const key of ANTHROPIC_VARS) {
if (savedVars[key] === undefined) delete process.env[key];
else process.env[key] = savedVars[key];
}
if (originalFetch) globalThis.fetch = originalFetch;
else delete (globalThis as { fetch?: typeof fetch }).fetch;
process.env.PATH = originalPath;
fs.rmSync(tmpHome, { recursive: true, force: true });
});
it("parses models[].slug JSON format from codex debug models", async () => {
const codexBin = path.join(binDir, "codex");
fs.writeFileSync(codexBin, `#!/bin/sh
printf '%s\\n' '{"models":[{"slug":"gemini-2.5-pro"},{"slug":"gemini-2.5-flash"},{"slug":"gemini-2.0-pro-exp-02-05"}]}'
exit 0
`, { mode: 0o755 });
fs.writeFileSync(path.join(tmpHome, ".agent-workflow", "config.json"), JSON.stringify({
version: "1",
executors: { codex: { binary: codexBin } },
}));
const result = await discoverExecutorModels();
expect(result.codex.status).toBe("ok");
expect(result.codex.models).toContain("gemini-2.5-pro");
expect(result.codex.models).toContain("gemini-2.5-flash");
expect(result.codex.models).toContain("gemini-2.0-pro-exp-02-05");
// The raw JSON string must NOT be exposed as a single model entry
expect(result.codex.models).not.toContain(expect.stringContaining("models"));
});
it("parses data[].id as alternate format from codex debug models", async () => {
const codexBin = path.join(binDir, "codex");
fs.writeFileSync(codexBin, `#!/bin/sh
printf '%s\\n' '{"data":[{"id":"model-a"},{"id":"model-b"}]}'
exit 0
`, { mode: 0o755 });
fs.writeFileSync(path.join(tmpHome, ".agent-workflow", "config.json"), JSON.stringify({
version: "1",
executors: { codex: { binary: codexBin } },
}));
const result = await discoverExecutorModels();
expect(result.codex.status).toBe("ok");
expect(result.codex.models).toContain("model-a");
expect(result.codex.models).toContain("model-b");
});
it("applies a manual model override via config file", async () => {
const codexBin = path.join(binDir, "codex");
fs.writeFileSync(codexBin, `#!/bin/sh
printf '%s\\n' '{"models":[{"slug":"gemini-2.5-pro"},{"slug":"gemini-2.5-flash"}]}'
exit 0
`, { mode: 0o755 });
fs.writeFileSync(path.join(tmpHome, ".agent-workflow", "config.json"), JSON.stringify({
version: "1",
executors: { codex: { binary: codexBin, model: "custom-gemini-2.5" } },
}));
const result = await discoverExecutorModels();
expect(result.codex.status).toBe("ok");
expect(result.codex.models).toContain("gemini-2.5-pro");
// Manual override is in result.current, NOT in result.codex.models
expect(result.current.codex).toBe("custom-gemini-2.5");
});
it("falls through to line-delimited parsing when JSON has no valid slug/id", async () => {
const codexBin = path.join(binDir, "codex");
fs.writeFileSync(codexBin, `#!/bin/sh
printf '%s\\n' 'codex-model-a'
printf '%s\\n' 'codex-model-b'
exit 0
`, { mode: 0o755 });
fs.writeFileSync(path.join(tmpHome, ".agent-workflow", "config.json"), JSON.stringify({
version: "1",
executors: { codex: { binary: codexBin } },
}));
const result = await discoverExecutorModels();
expect(result.codex.status).toBe("ok");
expect(result.codex.models).toContain("codex-model-a");
expect(result.codex.models).toContain("codex-model-b");
});
it("falls back to --bundled when primary fails, then succeeds", async () => {
const codexBin = path.join(binDir, "codex");
fs.writeFileSync(codexBin, `#!/bin/sh
# First invocation (debug models) fails, second (debug models --bundled) succeeds
if [ "$*" = "debug models" ]; then
exit 1
fi
if [ "$*" = "debug models --bundled" ]; then
printf '%s\\n' '{"models":[{"slug":"bundled-gemini-2.5-pro"}]}'
exit 0
fi
exit 1
`, { mode: 0o755 });
fs.writeFileSync(path.join(tmpHome, ".agent-workflow", "config.json"), JSON.stringify({
version: "1",
executors: { codex: { binary: codexBin } },
}));
const result = await discoverExecutorModels();
expect(result.codex.status).toBe("ok");
expect(result.codex.models).toContain("bundled-gemini-2.5-pro");
});
it("reports error when both primary and --bundled fail", async () => {
const codexBin = path.join(binDir, "codex");
fs.writeFileSync(codexBin, `#!/bin/sh
exit 1
`, { mode: 0o755 });
fs.writeFileSync(path.join(tmpHome, ".agent-workflow", "config.json"), JSON.stringify({
version: "1",
executors: { codex: { binary: codexBin } },
}));
const result = await discoverExecutorModels();
expect(result.codex.status).toBe("error");
});
});
+108 -2
View File
@@ -10,6 +10,12 @@ import { spawn } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
/** Internal marker to skip line-delimited fallback after successful JSON parse with no models. */
class SkippableError extends Error {
constructor(msg: string) { super(msg); this.name = "SkippableError"; }
}
import type { ExecutorConfig, ExecutorKind } from "./workflow-types.js";
import { EXECUTOR_KINDS } from "./workflow-types.js";
import { loadGlobalWorkflowConfig } from "./workflow-config.js";
@@ -31,6 +37,7 @@ export interface ExecutorModelDiscovery {
export interface ModelDiscoveryResult {
claude: ExecutorModelDiscovery;
codex: ExecutorModelDiscovery;
agy: ExecutorModelDiscovery;
reasonix: ExecutorModelDiscovery;
/** Current global model overrides, if any. */
@@ -365,6 +372,103 @@ async function discoverAgyModels(config: ExecutorConfig): Promise<ExecutorModelD
}
}
async function discoverCodexModels(config: ExecutorConfig): Promise<ExecutorModelDiscovery> {
// Truthful Codex fallback — no default model assumption
const defaults: string[] = [];
const bin = getBinary(config, "codex");
// Helper: parse JSON models[].slug or data[].id from Codex debug models output
function parseCodexModelsJson(json: unknown): string[] {
if (Array.isArray(json)) {
// Array of { slug: string, ... } or array of strings
return json.map((item) => {
if (typeof item === "string") return item;
if (item && typeof item === "object") {
const obj = item as Record<string, unknown>;
if (typeof obj.slug === "string") return obj.slug;
if (typeof obj.id === "string") return obj.id;
}
return undefined;
}).filter((m): m is string => !!m);
}
// Object with { models: [...] } or { data: [...] } shape
const obj = json as Record<string, unknown>;
// Prefer data[] over models[] for Backward compatibility
const models = Array.isArray(obj.data) ? obj.data :
Array.isArray(obj.models) ? obj.models : undefined;
if (models) {
return models.map((item) => {
if (typeof item === "string") return item;
if (item && typeof item === "object") {
const objItem = item as Record<string, unknown>;
if (typeof objItem.slug === "string") return objItem.slug;
if (typeof objItem.id === "string") return objItem.id;
}
return undefined;
}).filter((m): m is string => !!m);
}
return [];
}
// Try primary discovery first
try {
const result = await runCommand(bin, ["debug", "models"]);
if (result.status !== 0) {
throw new Error(result.stderr?.trim() || `codex debug models exited with code ${result.status ?? "unknown"}`);
}
const stdout = result.stdout?.trim() ?? "";
// Try JSON parsing for models[].slug format
if (stdout.startsWith("{") || stdout.startsWith("[")) {
try {
const parsed = JSON.parse(stdout) as unknown;
const models = parseCodexModelsJson(parsed);
if (models.length > 0) return { status: "ok", models, defaults };
// JSON parsed successfully but has no models — skip line-delimited fallback
throw new SkippableError("JSON returned no models");
} catch (err) {
if (err instanceof SkippableError) throw err;
// Fall through to line-delimited parsing (malformed JSON)
}
}
const models = parseLineDelimitedModels(stdout);
if (models.length > 0) return { status: "ok", models, defaults };
} catch {
// Fall through to --bundled retry
}
// Retry with --bundled flag
try {
const result = await runCommand(bin, ["debug", "models", "--bundled"]);
if (result.status !== 0) {
throw new Error(result.stderr?.trim() || `codex debug models --bundled exited with code ${result.status ?? "unknown"}`);
}
const stdout = result.stdout?.trim() ?? "";
if (stdout.startsWith("{") || stdout.startsWith("[")) {
try {
const parsed = JSON.parse(stdout) as unknown;
const models = parseCodexModelsJson(parsed);
if (models.length > 0) return { status: "ok", models, defaults };
// JSON parsed successfully but has no models — skip line-delimited fallback
} catch {
// Fall through to line-delimited parsing (malformed JSON)
}
}
if (!stdout.startsWith("{") && !stdout.startsWith("[")) {
const models = parseLineDelimitedModels(stdout);
if (models.length > 0) return { status: "ok", models, defaults };
}
} catch (err) {
return { status: "error", error: cleanError(err), defaults };
}
return { status: "ok", models: defaults, defaults };
}
async function discoverReasonixModels(config: ExecutorConfig): Promise<ExecutorModelDiscovery> {
const defaults: string[] = [];
const bin = getBinary(config, "reasonix");
@@ -400,15 +504,17 @@ export async function discoverExecutorModels(): Promise<ModelDiscoveryResult> {
const configs = {
claude: getExecutorConfig("claude"),
codex: getExecutorConfig("codex"),
agy: getExecutorConfig("agy"),
reasonix: getExecutorConfig("reasonix"),
};
const [claude, agy, reasonix] = await Promise.all([
const [claude, codex, agy, reasonix] = await Promise.all([
discoverClaudeModels(configs.claude),
discoverCodexModels(configs.codex),
discoverAgyModels(configs.agy),
discoverReasonixModels(configs.reasonix),
]);
return { claude, agy, reasonix, current };
return { claude, codex, agy, reasonix, current };
}
+288
View File
@@ -4,6 +4,7 @@ import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { loadProjectWorkflowConfig, resolveWorkflowConfig } from "./workflow-config.js";
import type { HostDecisionV1 } from "./workflow-types.js";
import {
WorkflowLockError,
acquireLock,
@@ -185,6 +186,35 @@ describe("Resolved workflow config", () => {
expect(config.executorConfig.model).toBe("claude-sonnet");
});
it("resolves defaultExecutor: codex with binary override", () => {
writeGlobalConfig({
version: "1",
defaultExecutor: "codex",
executors: {
codex: { binary: "/custom/codex" },
},
});
const config = resolveWorkflowConfig(repoRoot, {});
expect(config.executor).toBe("codex");
expect(config.executorConfig.binary).toBe("/custom/codex");
});
it("CLI executor override takes precedence over global defaultExecutor: codex", () => {
writeGlobalConfig({
version: "1",
defaultExecutor: "codex",
executors: {
codex: { binary: "/codex/bin" },
claude: { binary: "/claude/bin" },
},
});
const config = resolveWorkflowConfig(repoRoot, { executor: "claude" });
expect(config.executor).toBe("claude");
expect(config.executorConfig.binary).toBe("/claude/bin");
});
it("rejects invalid model values", () => {
writeProjectConfig({
version: "1",
@@ -591,6 +621,7 @@ printf '{"type":"result","result":"done","session_id":"${sessionId}"}\\n'
expect(events.find((event) => event.kind === "executor_session_rebound")?.data).toEqual({
previousSessionId: "97a606e8-cfa3-4996-83a8-ddb7ece4234b",
sessionId,
executor: "claude",
});
});
@@ -627,6 +658,263 @@ printf '{"type":"result","result":"done","session_id":"${sessionId}"}\\n'
expect(readState(dir)?.sessionHandle).toEqual(state.sessionHandle);
expect(fs.existsSync(path.join(dir, "events.jsonl"))).toBe(false);
});
it("rejects codex+codex self-review combination", async () => {
const { startWorkflow } = await import("./workflow-engine.js");
const planPath = path.join(tmpDir, "plan-codex-self-review.json");
fs.writeFileSync(planPath, JSON.stringify(plan()));
await expect(startWorkflow({
cwd: repoRoot,
planInput: planPath,
runId: "codex-self-review-1",
executor: "codex",
reviewer: "codex",
})).rejects.toThrow(/cannot self-review/);
});
it("accepts claude reviewer with codex executor (controlled fake binary)", async () => {
const { startWorkflow } = await import("./workflow-engine.js");
const { readState } = await import("./workflow-state.js");
// Create a fake Codex binary that produces valid output
const fakeCodexPath = path.join(tmpDir, "fake-codex-start-resume");
const sessionUuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
fs.writeFileSync(fakeCodexPath, `#!/bin/bash
if [[ "$1" == "--version" ]]; then
echo "fake-codex 0.1.0"; exit 0
fi
echo '{"type":"thread.started","thread":{"id":"${sessionUuid}"}}'
echo '{"type":"item.started","item":{"name":"Bash"}}'
echo '{"type":"item.completed","item":{"name":"Bash"}}'
echo '{"type":"turn.completed"}'
echo '{"type":"result","result":"Done"}'
exit 0
`, { mode: 0o755 });
// Write config so codex binary is found, then commit to satisfy clean-tree check
fs.writeFileSync(path.join(repoRoot, "agent-workflow.json"), JSON.stringify({
version: "1",
executors: { codex: { binary: fakeCodexPath } },
}));
const { execSync } = await import("child_process");
execSync("git add agent-workflow.json && git commit -m 'add codex config for test'", { cwd: repoRoot });
// Set HOME so codex probe looks at our tmpDir (no real codex needed)
const originalHome = process.env.HOME;
process.env.HOME = tmpDir;
try {
const planPath = path.join(tmpDir, "plan-claude-review-codex-exec.json");
fs.writeFileSync(planPath, JSON.stringify(plan()));
// Start the workflow - should NOT throw self-review error, and should succeed
const { runId } = await startWorkflow({
cwd: repoRoot,
planInput: planPath,
runId: "claude-review-codex-exec",
executor: "codex",
reviewer: "claude",
maxCycles: 3,
});
const state = readState(runDir(repoRoot, runId))!;
expect(state.executor).toBe("codex");
expect(state.reviewer).toBe("claude");
expect(state.sessionHandle).toEqual({ kind: "codex", sessionId: sessionUuid });
expect(state.status).toBe("awaiting_review");
} finally {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
}
});
it("full lifecycle with claude reviewer and codex executor through review/fix/resume/accept", async () => {
const { startWorkflow, reviewWorkflow, decideWorkflow } = await import("./workflow-engine.js");
const { readState } = await import("./workflow-state.js");
const fakeCodexPath = path.join(tmpDir, "fake-codex-lifecycle");
const sessionUuid = "lifecycle-uuid-1111-2222-3333-444444444444";
const resumeMarker = path.join(tmpDir, "codex-resume-called");
const resumedSessionFile = path.join(tmpDir, "codex-resumed-session.txt");
fs.writeFileSync(fakeCodexPath, `#!/bin/bash
set -e
if [[ "$1" == "--version" ]]; then
echo "fake-codex 0.1.0"; exit 0
fi
if [[ "$1" == "exec" && "$2" == "resume" ]]; then
echo "resume_called" > '${resumeMarker}'
resumedId="\${@: -2:1}"
echo "$resumedId" > '${resumedSessionFile}'
echo "// Resume change" >> '${repoRoot}/test.txt'
echo '{"type":"thread.started","thread_id":"'"$resumedId"'","thread":{"id":"'"$resumedId"'"}}'
echo '{"type":"item.started","item":{"name":"Bash"}}'
echo '{"type":"item.completed","item":{"name":"Bash"}}'
echo '{"type":"turn.completed"}'
exit 0
fi
echo "// Initial content" > '${repoRoot}/test.txt'
echo '{"type":"thread.started","thread_id":"${sessionUuid}","thread":{"id":"${sessionUuid}"}}'
echo '{"type":"item.started","item":{"name":"Bash"}}'
echo '{"type":"item.completed","item":{"name":"Bash"}}'
echo '{"type":"turn.completed"}'
exit 0
`, { mode: 0o755 });
fs.writeFileSync(path.join(repoRoot, "agent-workflow.json"), JSON.stringify({
version: "1",
executors: { codex: { binary: fakeCodexPath } },
}));
const { execSync } = await import("child_process");
execSync("git add agent-workflow.json && git commit -m 'add codex config for lifecycle test'", { cwd: repoRoot });
const originalHome = process.env.HOME;
process.env.HOME = tmpDir;
try {
const planPath = path.join(tmpDir, "plan-lifecycle.json");
fs.writeFileSync(planPath, JSON.stringify(plan()));
// STEP 1: START
const { runId } = await startWorkflow({
cwd: repoRoot,
planInput: planPath,
runId: "codex-lifecycle",
executor: "codex",
reviewer: "claude",
maxCycles: 3,
});
let state = readState(runDir(repoRoot, runId))!;
expect(state.executor).toBe("codex");
expect(state.reviewer).toBe("claude");
expect(state.sessionHandle).toEqual({ kind: "codex", sessionId: sessionUuid });
expect(state.status).toBe("awaiting_review");
expect(state.currentCycle).toBe(1);
// STEP 2: REVIEW CYCLE 1
await reviewWorkflow({ runId, cwd: repoRoot, reviewer: "claude" });
state = readState(runDir(repoRoot, runId))!;
expect(state.status).toBe("awaiting_host");
expect(state.currentCycle).toBe(1);
// STEP 3: FIX DECISION → triggers resume
const fixDecision: HostDecisionV1 = {
version: "1",
reviewer: "claude",
outcome: "fix",
findingDecisions: [
{ findingId: "finding-1", disposition: "accept", summary: "test.txt should have more content" },
],
decidedAt: new Date().toISOString(),
};
const fixDecisionPath = path.join(tmpDir, "decision-fix.json");
fs.writeFileSync(fixDecisionPath, JSON.stringify(fixDecision));
await decideWorkflow({ runId, cwd: repoRoot, reviewer: "claude", decisionInput: fixDecisionPath });
// Assert resume was called with correct session ID
expect(fs.existsSync(resumeMarker)).toBe(true);
expect(fs.readFileSync(resumedSessionFile, "utf8").trim()).toBe(sessionUuid);
// State should now be awaiting_review (cycle 2)
state = readState(runDir(repoRoot, runId))!;
expect(state.currentCycle).toBe(2);
expect(state.status).toBe("awaiting_review");
// STEP 4: REVIEW CYCLE 2
await reviewWorkflow({ runId, cwd: repoRoot, reviewer: "claude" });
state = readState(runDir(repoRoot, runId))!;
expect(state.status).toBe("awaiting_host");
// STEP 5: ACCEPT DECISION (process.exit(0) is mocked)
const origExit = process.exit;
(process as any).exit = (code?: number) => {
if (code === 0) throw new Error("MockExit0");
origExit(code);
};
try {
const acceptDecision: HostDecisionV1 = {
version: "1",
reviewer: "claude",
outcome: "accept",
findingDecisions: [],
verificationEvidence: ["Test evidence"],
decidedAt: new Date().toISOString(),
};
const acceptPath = path.join(tmpDir, "decision-accept.json");
fs.writeFileSync(acceptPath, JSON.stringify(acceptDecision));
await expect(decideWorkflow({ runId, cwd: repoRoot, reviewer: "claude", decisionInput: acceptPath }))
.rejects.toThrow("MockExit0");
} finally {
process.exit = origExit;
}
// Verify completed state was persisted before process.exit
state = readState(runDir(repoRoot, runId))!;
expect(state.status).toBe("completed");
} finally {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
}
});
it("retryExecuteWorkflow validates codex session rebind", async () => {
const { retryExecuteWorkflow } = await import("./workflow-engine.js");
const { gitHead, initWorkflowState, readState, writeState } = await import("./workflow-state.js");
const runId = "codex-rebind-valid";
const sessionId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
const dir = runDir(repoRoot, runId);
// Create a valid codex session file
const codexDir = path.join(tmpDir, ".codex", "sessions");
fs.mkdirSync(codexDir, { recursive: true });
fs.writeFileSync(
path.join(codexDir, `${sessionId}.jsonl`),
JSON.stringify({ type: "session_meta", payload: { id: sessionId, cwd: repoRoot } }) + "\n" +
JSON.stringify({ type: "initialization", message: { content: "# Implementation Task: Test Plan" } }) + "\n",
);
// Set up a fake codex binary that returns a valid thread.started event
const fakeCodexPath = path.join(tmpDir, "fake-codex-for-rebind");
fs.writeFileSync(fakeCodexPath, `#!/bin/bash
# Simulate codex exec resume - echo a thread.started event
echo '{"type":"thread.started","thread":{"id":"${sessionId}"}}'
exit 0
`, { mode: 0o755 });
const state = initWorkflowState({
runId,
repoRoot,
baselineHead: gitHead(repoRoot),
plan: plan(),
executor: "codex",
maxCycles: 3,
});
state.executorConfig = { binary: fakeCodexPath };
state.status = "blocked";
state.stopDescription = "Executor exited with failure: codex execution failed";
state.currentCycle = 1;
state.cycles = [{ cycleIndex: 1, startedAt: new Date().toISOString() }];
state.sessionHandle = { kind: "codex", sessionId: "old-id-0000-0000-0000-000000000000" };
fs.mkdirSync(dir, { recursive: true });
writeState(dir, state);
await retryExecuteWorkflow({ runId, sessionId });
const recovered = readState(dir)!;
expect(recovered.sessionHandle).toEqual({ kind: "codex", sessionId });
const events = fs.readFileSync(path.join(dir, "events.jsonl"), "utf8")
.trim().split("\n").map((line) => JSON.parse(line));
expect(events.find((event) => event.kind === "executor_session_rebound")?.data).toEqual({
previousSessionId: "old-id-0000-0000-0000-000000000000",
sessionId,
executor: "codex",
});
});
});
// ---------------------------------------------------------------------------
+181
View File
@@ -169,3 +169,184 @@ echo 'started'
abortWorkflow({ runId: fresh.runId, reason: "test cleanup" });
});
});
describe("Codex task-level executor session reuse", () => {
let tmpDir: string;
let repoRoot: string;
let stateRoot: string;
let homeDir: string;
let previousHome: string | undefined;
let previousWorkflowDir: string | undefined;
const threadId = "019f6e2d-333c-7900-bbf4-8ad8a9ee162f";
const sessionUuid = "b7a8c901-2345-4def-9012-3456789abcde";
let codexBin: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-codex-reuse-"));
repoRoot = path.join(tmpDir, "repo");
stateRoot = path.join(tmpDir, "state");
homeDir = path.join(tmpDir, "home");
codexBin = path.join(tmpDir, "fake-codex.sh");
fs.mkdirSync(repoRoot);
fs.mkdirSync(homeDir);
previousHome = process.env.HOME;
previousWorkflowDir = process.env.AGENT_WORKFLOW_DIR;
process.env.HOME = homeDir;
process.env.AGENT_WORKFLOW_DIR = stateRoot;
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoRoot });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot });
// Create a valid codex session file in ~/.codex/sessions/
const sessionsDir = path.join(homeDir, ".codex", "sessions");
fs.mkdirSync(sessionsDir, { recursive: true });
fs.writeFileSync(
path.join(sessionsDir, `${sessionUuid}.jsonl`),
JSON.stringify({ type: "session_meta", payload: { id: sessionUuid, cwd: repoRoot } }) + "\n" +
JSON.stringify({ type: "initialization", message: { content: "# Implementation Task: Test Codex Plan" } }) + "\n",
);
// Create a fake Codex CLI that records args and emits thread.started on start/resume
const uuid = sessionUuid; // captured for bash var expansion
fs.writeFileSync(codexBin, `#!/bin/bash
# Fake Codex CLI: record args to NUL-delimited file, emit thread.started
printf '%s\\0' "$@" >> '${path.join(tmpDir, "codex-args.bin")}'
if [[ "$1" == "--version" ]]; then
echo "fake-codex 0.1.0"
exit 0
fi
if [[ "$1" == "exec" && "$2" == "resume" ]]; then
# Session ID is immediately before the final "-" prompt marker
resumedId="\${@: -2:1}"
echo '{"type":"thread.started","thread_id":"'"$resumedId"'","thread":{"id":"'"$resumedId"'"}}'
echo '{"type":"turn.completed"}'
echo '{"type":"result","result":"Resume complete"}'
exit 0
elif [[ "$1" == "exec" ]]; then
echo "{\\"type\\":\\"thread.started\\",\\"thread\\":{\\"id\\":\\"${uuid}\\"}}"
echo '{"type":"item.started","item":{"name":"Bash"}}'
echo '{"type":"item.completed","item":{"name":"Bash"}}'
echo '{"type":"turn.completed"}'
exit 0
else
exit 1
fi
`, { mode: 0o755 });
fs.writeFileSync(path.join(repoRoot, "agent-workflow.json"), JSON.stringify({
version: "1",
executors: { codex: { binary: codexBin } },
}));
fs.writeFileSync(path.join(repoRoot, "tracked.txt"), "unchanged\n");
execFileSync("git", ["add", "."], { cwd: repoRoot });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoRoot, stdio: "ignore" });
});
afterEach(() => {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
if (previousWorkflowDir === undefined) delete process.env.AGENT_WORKFLOW_DIR;
else process.env.AGENT_WORKFLOW_DIR = previousWorkflowDir;
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function writePlan(name: string): string {
const planPath = path.join(tmpDir, `${name}.json`);
fs.writeFileSync(planPath, JSON.stringify({
version: "1",
title: name,
planMarkdown: `Implement ${name}.`,
scope: ["tracked.txt"],
acceptanceCriteria: [`${name} is complete`],
verificationCommands: [["git", "status", "--short"]],
}));
return planPath;
}
it("resumes the same Codex session when a later plan belongs to the current task", async () => {
const first = await startWorkflow({
cwd: repoRoot,
executor: "codex",
reviewer: "claude",
planInput: writePlan("Codex First Plan"),
codexThreadId: threadId,
});
const firstState = readState(runDir(repoRoot, first.runId))!;
const firstTaskId = firstState.taskId;
expect(firstState.taskSessionReused).toBe(false);
expect(firstState.sessionHandle).toEqual({ kind: "codex", sessionId: sessionUuid });
expect(readTaskBinding(repoRoot, threadId)?.executors.codex?.handle).toEqual(firstState.sessionHandle);
abortWorkflow({ runId: first.runId, reason: "test boundary" });
const second = await startWorkflow({
cwd: repoRoot,
executor: "codex",
reviewer: "claude",
planInput: writePlan("Codex Second Plan"),
codexThreadId: threadId,
});
const secondState = readState(runDir(repoRoot, second.runId))!;
expect(secondState.taskId).toBe(firstTaskId);
expect(secondState.taskSessionReused).toBe(true);
// The resumed session must use the EXACT same ID
expect(secondState.sessionHandle).toEqual({ kind: "codex", sessionId: sessionUuid });
const args = fs.readFileSync(path.join(tmpDir, "codex-args.bin"), "utf8").split("\0").filter(Boolean);
expect(args).toContain("resume");
expect(args).toContain(sessionUuid);
const events = fs.readFileSync(path.join(runDir(repoRoot, second.runId), "events.jsonl"), "utf8");
expect(events).toContain('"kind":"executor_session_reused"');
abortWorkflow({ runId: second.runId, reason: "test cleanup" });
});
it("blocks reuse when Codex session file is missing from ~/.codex/sessions", async () => {
const first = await startWorkflow({
cwd: repoRoot,
executor: "codex",
reviewer: "claude",
planInput: writePlan("Codex Recoverable Task"),
codexThreadId: threadId,
});
abortWorkflow({ runId: first.runId, reason: "test boundary" });
// Remove the session file so validation fails
const sessionsDir = path.join(homeDir, ".codex", "sessions");
fs.rmSync(path.join(sessionsDir, `${sessionUuid}.jsonl`), { force: true });
await expect(startWorkflow({
cwd: repoRoot,
executor: "codex",
reviewer: "claude",
planInput: writePlan("Codex Follow-up Plan"),
codexThreadId: threadId,
})).rejects.toThrow(/Codex session file not found/);
});
it("blocks reuse when Codex session belongs to a different repository", async () => {
const first = await startWorkflow({
cwd: repoRoot,
executor: "codex",
reviewer: "claude",
planInput: writePlan("Codex Recoverable Task 2"),
codexThreadId: threadId,
});
abortWorkflow({ runId: first.runId, reason: "test boundary" });
// Rewrite the session file with a different cwd
const sessionsDir = path.join(homeDir, ".codex", "sessions");
fs.writeFileSync(
path.join(sessionsDir, `${sessionUuid}.jsonl`),
JSON.stringify({ type: "session_meta", payload: { id: sessionUuid, cwd: "/different/repo" } }) + "\n",
);
await expect(startWorkflow({
cwd: repoRoot,
executor: "codex",
reviewer: "claude",
planInput: writePlan("Codex Wrong Repo Plan"),
codexThreadId: threadId,
})).rejects.toThrow(/does not belong to repository/);
});
});
+9 -2
View File
@@ -19,7 +19,7 @@ export type ReviewerKind = (typeof REVIEWER_KINDS)[number];
// Executors
// ---------------------------------------------------------------------------
export const EXECUTOR_KINDS = ["reasonix", "claude", "agy"] as const;
export const EXECUTOR_KINDS = ["reasonix", "claude", "agy", "codex"] as const;
export type ExecutorKind = (typeof EXECUTOR_KINDS)[number];
// ---------------------------------------------------------------------------
@@ -143,6 +143,11 @@ export interface ClaudeSessionHandle {
sessionId: string;
}
export interface CodexSessionHandle {
kind: "codex";
sessionId: string;
}
export interface AgySessionHandle {
kind: "agy";
/** Conversation ID from agy output, if available. */
@@ -154,7 +159,8 @@ export interface AgySessionHandle {
export type ExecutorSessionHandle =
| ReasonixSessionHandle
| ClaudeSessionHandle
| AgySessionHandle;
| AgySessionHandle
| CodexSessionHandle;
export interface ScopeViolation {
id: string;
@@ -346,6 +352,7 @@ export interface WorkflowProjectConfig {
reasonix?: ExecutorConfig;
claude?: ExecutorConfig;
agy?: ExecutorConfig;
codex?: ExecutorConfig;
};
}
+24 -2
View File
@@ -763,6 +763,7 @@ export function getAssetsHtml(): string {
<input type="text" id="search-box" class="search-input" placeholder="搜索项目或运行记录...">
<select id="executor-filter" class="select-input">
<option value="">全部执行器</option>
<option value="codex">Codex</option>
<option value="claude">Claude Code</option>
<option value="reasonix">Reasonix</option>
<option value="agy">Agy</option>
@@ -938,11 +939,31 @@ export function getAssetsHtml(): string {
<div class="settings-header">
<div>
<h2 id="settings-title">全局执行器模型配置</h2>
<div class="settings-subtitle">分别配置 Claude、Agy、Reasonix 的全局模型。仅写入 ~/.agent-workflow/config.json 中的 executors.&lt;kind&gt;.model;项目配置与 CLI 仍可覆盖全局值。清空后恢复 CLI 默认选择。</div>
<div class="settings-subtitle">分别配置 Claude、Codex、Agy、Reasonix 的全局模型。仅写入 ~/.agent-workflow/config.json 中的 executors.&lt;kind&gt;.model;项目配置与 CLI 仍可覆盖全局值。清空后恢复 CLI 默认选择。</div>
</div>
<button id="close-settings-btn" class="icon-btn" title="关闭" aria-label="关闭全局配置">✕</button>
</div>
<div class="settings-body">
<div class="executor-settings-card" data-executor="codex">
<div class="executor-settings-title">
<h3>Codex</h3>
<span class="status-pill loading" id="status-codex">加载中</span>
</div>
<div class="settings-field">
<label for="model-select-codex">已发现模型</label>
<div class="settings-field-row">
<select id="model-select-codex" class="select-input"></select>
<button class="control-btn" type="button" onclick="applyDiscoveredModel('codex')">填入</button>
</div>
</div>
<div class="settings-field">
<label for="model-input-codex">手动输入模型</label>
<input id="model-input-codex" class="search-input" type="text" placeholder="例如 gemini-2.5-pro 或留空使用默认">
<div class="settings-help">通过 codex debug models 发现候选;发现失败时仍可手动输入。</div>
<div class="settings-error" id="error-codex"></div>
</div>
</div>
<div class="executor-settings-card" data-executor="claude">
<div class="executor-settings-title">
<h3>Claude Code</h3>
@@ -2458,8 +2479,9 @@ export function getAssetsHtml(): string {
document.getElementById('executor-filter').addEventListener('change', renderRuns);
document.getElementById('status-filter').addEventListener('change', renderRuns);
const EXECUTOR_KINDS_UI = ['claude', 'agy', 'reasonix'];
const EXECUTOR_KINDS_UI = ['codex', 'claude', 'agy', 'reasonix'];
const EXECUTOR_LABELS = {
codex: 'Codex',
claude: 'Claude Code',
agy: 'Agy',
reasonix: 'Reasonix',
+78
View File
@@ -174,6 +174,84 @@ describe("workflow-web-parser - Agy raw fallback", () => {
});
});
describe("workflow-web-parser - Codex stream-json", () => {
it("parses thread.started event", () => {
const line = '{"type":"thread.started","thread":{"id":"abc-123"}}';
const res = parseLogLine(line, "codex");
expect(res[0]).toEqual({
type: "system",
content: "Thread started: abc-123",
meta: { type: "thread.started", thread: { id: "abc-123" } },
raw: line,
});
});
it("parses item.started event with tool name", () => {
const line = '{"type":"item.started","item":{"name":"Bash","input":"ls -la"}}';
const res = parseLogLine(line, "codex");
expect(res[0]).toEqual({
type: "tool_call",
content: "Tool: Bash",
meta: { type: "item.started", item: { name: "Bash", input: "ls -la" } },
raw: line,
});
});
it("parses item.completed event", () => {
const line = '{"type":"item.completed","item":{"name":"Bash"}}';
const res = parseLogLine(line, "codex");
expect(res[0]).toEqual({
type: "result",
content: "Item completed",
meta: { type: "item.completed", item: { name: "Bash" } },
raw: line,
});
});
it("parses turn.completed event", () => {
const line = '{"type":"turn.completed"}';
const res = parseLogLine(line, "codex");
expect(res[0]).toEqual({
type: "result",
content: "Turn completed",
meta: { type: "turn.completed" },
raw: line,
});
});
it("parses error event", () => {
const line = '{"type":"error","error":"API rate limit exceeded"}';
const res = parseLogLine(line, "codex");
expect(res[0]).toEqual({
type: "error",
content: "API rate limit exceeded",
meta: { type: "error", error: "API rate limit exceeded" },
raw: line,
});
});
it("parses tool event as fallback", () => {
const line = '{"type":"tool","name":"Read","input":{"file_path":"src/index.ts"}}';
const res = parseLogLine(line, "codex");
expect(res[0]).toEqual({
type: "tool_call",
content: "Tool: Read",
meta: { type: "tool", name: "Read", input: { file_path: "src/index.ts" } },
raw: line,
});
});
it("falls through to raw for non-JSON lines", () => {
const line = "plain text output";
const res = parseLogLine(line, "codex");
expect(res[0]).toEqual({
type: "raw",
content: line,
raw: line,
});
});
});
describe("parseLogContent", () => {
it("splits text by lines and parses all", () => {
const content = "line 1\nline 2\r\nline 3\n";
+56
View File
@@ -181,6 +181,62 @@ export function parseLogLine(line: string, executor: string): ParsedLogEntry[] {
}
}
// 1b. Codex stream-json parser (similar to Claude JSONL format)
if (executor === "codex" && trimmed.startsWith("{") && trimmed.endsWith("}")) {
try {
const parsed = JSON.parse(trimmed) as Record<string, any>;
const type = String(parsed.type ?? "");
if (type === "thread.started") {
const threadId = parsed.thread_id ?? parsed.thread?.id ?? "";
return [{ type: "system", content: threadId ? `Thread started: ${threadId}` : "Thread started", meta: parsed, raw: line }];
}
if (type === "item.started") {
const item = parsed.item;
if (item && typeof item.tool_name === "string") {
return [{ type: "tool_call", content: `Tool: ${item.tool_name}`, meta: parsed, raw: line }];
}
if (item && typeof item.name === "string") {
return [{ type: "tool_call", content: `Tool: ${item.name}`, meta: parsed, raw: line }];
}
if (item && typeof item.type === "string") {
return [{ type: "tool_call", content: `Item: ${item.type}`, meta: parsed, raw: line }];
}
return [{ type: "raw", content: "Item started", meta: parsed, raw: line }];
}
if (type === "item.completed") {
return [{ type: "result", content: "Item completed", meta: parsed, raw: line }];
}
if (type === "turn.completed") {
return [{ type: "result", content: "Turn completed", meta: parsed, raw: line }];
}
if (type === "error") {
const errMsg = String(parsed.message ?? parsed.error ?? "Codex error");
return [{ type: "error", content: errMsg, meta: parsed, raw: line }];
}
if (type === "result" || parsed.result !== undefined) {
if (parsed.is_error === true) {
return [{ type: "error", content: String(parsed.result ?? parsed.error ?? "Error"), meta: parsed, raw: line }];
}
return [{ type: "result", content: String(parsed.result ?? ""), meta: parsed, raw: line }];
}
// Activity/status events
if (type === "tool" && typeof parsed.name === "string") {
return [{ type: "tool_call", content: `Tool: ${parsed.name}`, meta: parsed, raw: line }];
}
return [{ type: "raw", content: trimmed, meta: parsed, raw: line }];
} catch {
// Fall through
}
}
// 2. Reasonix parser
if (executor === "reasonix") {
const clean = line.replace(ANSI_REGEX, "");
+12
View File
@@ -405,6 +405,18 @@ printf '%s\\n' '{"providers":[{"name":"test-provider","models":["test-model"]}]}
expect(res.body).toContain("`/api/runs/${runId}/stream`");
});
it("includes Codex executor card in settings HTML", async () => {
const res = await makeRequest(`${baseUrl}/`);
// Codex settings card with right data attributes and IDs
expect(res.body).toMatch(/<div class="executor-settings-card" data-executor="codex">/);
expect(res.body).toMatch(/id="model-select-codex"/);
expect(res.body).toMatch(/id="model-input-codex"/);
expect(res.body).toMatch(/id="status-codex"/);
expect(res.body).toMatch(/id="error-codex"/);
// Codex option in executor filter
expect(res.body).toMatch(/<option value="codex">Codex<\/option>/);
});
it("includes responsive mobile layout CSS for sidebar", async () => {
const res = await makeRequest(`${baseUrl}/`);
expect(res.body).toContain("@media (max-width: 768px)");