Claude Code Codex plugin: fresh tasks fail with "no rollout found for thread id" — thread/name/set is called before the first turn
Summary
The Claude Code Codex plugin (codex@openai-codex v1.0.5) fails every fresh delegated task with:
no rollout found for thread id <uuid>
Root cause is an ordering bug against current app-server semantics: the plugin calls thread/name/set immediately after thread/start, before the first turn runs. In codex-cli 0.142.5 a non-ephemeral thread's rollout file is created lazily at the first turn/start, not at thread/start. thread/name/set is a disk/thread-store path that requires the rollout to already exist, so it returns -32600 no rollout found for thread id …. The plugin's startThread() catch only swallows unknown variant / unknown method, so it rethrows this error and the whole task fails.
Because persistThread: true (which sets a thread name) is used for every task run, this is deterministic — the plugin's task path is effectively unusable on this CLI version, forcing users to fall back to codex exec.
Environment
- codex-cli 0.142.5 (Homebrew cask), macOS (Darwin arm64)
- Claude Code Codex plugin 1.0.5 (
codex@openai-codex, gitCommitSha80c31f9) - Transport:
codex app-serverover newline-delimited JSON-RPC 2.0
Where it happens (plugin source)
scripts/lib/codex.mjs — startThread():
async function startThread(client, cwd, options = {}) {
const response = await client.request("thread/start", buildThreadParams(cwd, options));
const threadId = response.thread.id;
if (options.threadName) {
try {
await client.request("thread/name/set", { threadId, name: options.threadName });
} catch (err) {
// Only suppresses "unknown variant/method" from older CLIs.
const msg = String(err?.message ?? err ?? "");
if (!msg.includes("unknown variant") && !msg.includes("unknown method")) {
throw err; // <-- "no rollout found" is rethrown here → task fails
}
}
}
return response;
}
runAppServerTurn()starts a non-ephemeral thread for tasks (ephemeral: options.persistThread ? false : true) and always passes athreadName, then runsturn/startafterstartThread()returns.executeTaskRun()(scripts/codex-companion.mjs) always calls withpersistThread: trueand athreadName, sothread/name/setis always attempted before any turn.
app-server side (matches source at tag rust-v0.142.5): a plain thread/start inserts the thread into the in-memory map but writes no rollout to $CODEX_HOME/sessions/; the rollout + state-db row are created when the first turn begins. thread/name/set resolves the rollout from disk (resolve_rollout_path → no rollout found when absent), unlike turn/start, which uses the in-memory handle (and would report thread not found on a miss, not no rollout found).
Minimal reproduction (no plugin needed)
// repro.mjs — run: node repro.mjs (from any dir, with codex 0.142.5 on PATH)
import { spawn } from "node:child_process";
import readline from "node:readline";
const p = spawn("codex", ["app-server"], { stdio: ["pipe", "pipe", "pipe"] });
p.stdout.setEncoding("utf8");
const pending = new Map(); let id = 0;
readline.createInterface({ input: p.stdout }).on("line", (l) => {
if (!l.trim()) return; let m; try { m = JSON.parse(l); } catch { return; }
if (m.id !== undefined && (m.result !== undefined || m.error !== undefined)) {
const r = pending.get(m.id); if (r) { pending.delete(m.id); m.error ? r.reject(m.error) : r.resolve(m.result); }
}
});
const req = (method, params) => new Promise((res, rej) => { const i = ++id; pending.set(i, { resolve: res, reject: rej }); p.stdin.write(JSON.stringify({ id: i, method, params }) + "\n"); });
const notify = (method, params) => p.stdin.write(JSON.stringify({ method, params }) + "\n");
await req("initialize", { clientInfo: { name: "repro", version: "0", title: "repro" }, capabilities: {} });
notify("initialized", {});
const st = await req("thread/start", { cwd: process.cwd(), model: null, approvalPolicy: "never", sandbox: "read-only", serviceName: "claude_code_codex_plugin", ephemeral: false });
const tid = st.thread.id;
try {
await req("thread/name/set", { threadId: tid, name: "My Task" }); // BEFORE any turn — same as the plugin
console.log("name/set OK");
} catch (e) {
console.log("name/set FAILED:", JSON.stringify(e)); // -> {"code":-32600,"message":"no rollout found for thread id <tid>"}
}
p.kill("SIGTERM");
Observed: name/set FAILED: {"code":-32600,"message":"no rollout found for thread id …"} — 10/10 runs, and still fails even with a 2000 ms delay before name/set (the rollout is genuinely not written until the first turn, not merely a flush race). If you instead run turn/start first and only then thread/name/set, the no rollout found error disappears.
Expected vs actual
- Expected: delegating a fresh task through the Claude Code plugin runs the turn; naming the thread is best-effort and never fails the task.
- Actual: the task fails immediately with
no rollout found for thread id …before the turn ever runs.
Suggested fix (plugin side)
Make thread naming non-fatal / correctly ordered. Any of:
- Treat
thread/name/setfailures as best-effort — extend thestartThread()catch to also swallowno rollout found(and the post-turnthread metadata unavailable before name updatevariant). Naming is cosmetic here:--resume-lastresolves the thread from the plugin's own trackedjob.threadId(resolveLatestTrackedTaskThread→findLatestResumableTaskJob), not the codex-side thread name. - Reorder — apply
thread/name/setafter the first turn has produced a rollout (aligns with the desired behavior in #24289).
Option 1 is the smallest and most robust across CLI versions.
Related
- #28496 —
-32600overloaded acrossno rollout foundand config-load errors (same error surface; recommends fall-back-to-thread/startfor the resume half). - #24289 — auto-name thread after the first prompt (confirms the correct ordering).
- #22064 — resume of a missing thread should fall back to
thread/start.
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗