app-server websocket shared-thread turns complete but rollout never materializes, so sibling thread/resume fails with "no rollout found"

Open 💬 6 comments Opened Apr 5, 2026 by decleezy
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

What issue are you seeing?

On codex-cli 0.117.0, a shared websocket codex app-server can run a real user turn to completion, but
the rollout file is never materialized on disk.

As a result:

  • thread/read with includeTurns: false works
  • thread/read with includeTurns: true fails
  • sibling thread/resume fails with no rollout found for thread id ...

Environment

  • Codex CLI: 0.117.0
  • Release tag: rust-v0.117.0
  • Release commit: 4c70bff480af37b1bf1a9b352b8341060fe55755
  • Transport: websocket app-server
  • Launch:
  • codex app-server --listen ws://127.0.0.1:9891

What steps can reproduce the bug?

Repro

  1. Start a shared websocket app-server.
  2. Client A connects and calls initialize.
  3. Client A calls thread/start.
  4. Client A calls turn/start with:

```json
{
"threadId": "<thread-id>",
"input": [
{
"type": "text",
"text": "Please reply with exactly the word materialized.",
"text_elements": []
}
]
}

  1. Wait for:
  • item/started for userMessage
  • item/completed for userMessage
  • assistant reply
  • turn/completed
  1. On the same client, call:
  • thread/read with includeTurns: false
  • thread/read with includeTurns: true
  1. On sibling Client B, call:
  • thread/resume for the same thread id
  1. Check the rollout path returned by the thread APIs.

What is the expected behavior?

### Expected behavior

After the first real user turn completes on a non-ephemeral shared thread:

  • rollout file should exist at the advertised path
  • thread/read(includeTurns: true) should succeed
  • sibling thread/resume should succeed

Actual behavior

The turn fully runs, but no rollout file is created.

Observed repro thread:

  • thread id:
  • 019d5fe8-3730-7dc0-b126-bd5bd37446bd
  • advertised rollout path:
  • /Users/.../.codex/sessions/2026/04/05/rollout-2026-04-05T18-09-07-019d5fe8-3730-7dc0-b126-

bd5bd37446bd.jsonl

After turn/completed:

  • thread/read(includeTurns: false) succeeds
  • thread/read(includeTurns: true) returns:
  • thread 019d5fe8-3730-7dc0-b126-bd5bd37446bd is not materialized yet; includeTurns is unavailable

before first user message

  • sibling thread/resume returns:
  • no rollout found for thread id 019d5fe8-3730-7dc0-b126-bd5bd37446bd
  • the rollout file does not exist on disk at the advertised path

Waiting a few more seconds does not change the outcome.

Additional information

### Notes

This seems to specifically affect shared-thread / multi-client websocket usage.

Lightweight thread state is visible across clients, but rollout-backed operations never become available
because the rollout is never materialized.

View original on GitHub ↗

6 Comments

github-actions[bot] contributor · 3 months ago

Potential duplicates detected. Please review them and close your issue if it is a duplicate.

  • #16271
  • #15870

Powered by Codex Action

etraut-openai contributor · 3 months ago

What you're describing should work, so this does sound like a bug. Thanks for reporting it.

I asked codex to investigate. It wrote a small app server client that attempts to replicate the steps you've described above, but it wasn't able to duplicate the behavior you're seeing. It was able to theorize a potential cause, but it involves pretty obscure error cases that strike me as pretty implausible.

Perhaps you could ask codex to do something similar to what I did — to write a minimal test case that exercises the app server API via a web socket? If you're able to repro the problem in that way, perhaps you could then share the test case with me?

Alternatively, you could clone the codex code, build it from source, and ask codex to debug the issue on your machine directly.

decleezy · 3 months ago

thank you @etraut-openai

I packaged a minimal standalone websocket repro and ran it against a fresh local app-server on codex-cli 0.117.0.

Setup:

  • launch: codex app-server --listen ws://127.0.0.1:9895
  • client: dependency-free Node script using built-in WebSocket

What the script does:

  1. connect client A
  2. initialize
  3. thread/start
  4. turn/start with valid V2 UserInput[]
  5. wait for turn/completed
  6. verify whether the advertised rollout file exists on disk
  7. attempt to open client B for sibling follow-up

One repro run produced:

  • thread id:
  • 019d62ef-a95d-7742-952a-43ac59aa6733
  • turn id:
  • 019d62ef-aa93-7ec0-b34e-b76b3dcb2213
  • advertised rollout path:
  • /Users/.../.codex/sessions/2026/04/06/rollout-2026-04-06T08-16-06-019d62ef-a95d-7742-952a-43ac59aa6733.jsonl

The turn completed successfully, but the rollout still did not exist on disk afterward:

  • rollout_exists: false

The app-server logs also emitted repeated errors during the run:

```text
failed to record rollout items: failed to queue rollout items: channel closed

decleezy · 3 months ago

Here is the minimal standalone websocket repro script I used locally against codex-cli 0.117.0.

#!/usr/bin/env node

const fs = require("node:fs/promises");

const ENDPOINT = process.env.CODEX_APP_SERVER_URL ?? "ws://127.0.0.1:9891";
const CWD = process.env.CODEX_REPRO_CWD ?? process.cwd();
const MODEL = process.env.CODEX_REPRO_MODEL ?? "gpt-5.4";
const SOURCE = process.env.CODEX_REPRO_SOURCE ?? "vscode";
const PROMPT = process.env.CODEX_REPRO_PROMPT ?? "Please reply with exactly the word materialized.";
const CLIENT_A_NAME = "codex-shared-rollout-repro-a";
const CLIENT_B_NAME = "codex-shared-rollout-repro-b";
const OVERALL_TIMEOUT_MS = Number.parseInt(process.env.CODEX_REPRO_TIMEOUT_MS ?? "90000", 10);

let nextId = 1;

function logPhase(phase, details = {}) {
  console.error(JSON.stringify({ phase, ...details }));
}

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function createRpcClient(clientName) {
  const socket = new WebSocket(ENDPOINT);
  const pending = new Map();

  socket.addEventListener("message", (event) => {
    const msg = JSON.parse(event.data);
    if (!Object.prototype.hasOwnProperty.call(msg, "id")) {
      return;
    }
    const handler = pending.get(msg.id);
    if (!handler) {
      return;
    }
    pending.delete(msg.id);
    if (msg.error) {
      handler.reject(new Error(msg.error.message));
      return;
    }
    handler.resolve(msg.result);
  });

  function call(method, params, timeoutMs = 15000) {
    const id = nextId++;
    socket.send(JSON.stringify({ jsonrpc: "2.0", id, method, params }));
    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => {
        pending.delete(id);
        reject(new Error(`${method} timed out after ${timeoutMs}ms`));
      }, timeoutMs);
      pending.set(id, {
        resolve: (result) => {
          clearTimeout(timer);
          resolve(result);
        },
        reject: (error) => {
          clearTimeout(timer);
          reject(error);
        }
      });
    });
  }

  function waitForNotification(method, predicate = () => true, timeoutMs = 30000) {
    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => {
        socket.removeEventListener("message", onMessage);
        reject(new Error(`${method} timed out after ${timeoutMs}ms`));
      }, timeoutMs);

      const onMessage = (event) => {
        const msg = JSON.parse(event.data);
        if (msg.method !== method) {
          return;
        }
        const params = msg.params ?? {};
        if (!predicate(params)) {
          return;
        }
        clearTimeout(timer);
        socket.removeEventListener("message", onMessage);
        resolve(params);
      };

      socket.addEventListener("message", onMessage);
    });
  }

  async function connect() {
    await new Promise((resolve, reject) => {
      const timer = setTimeout(() => {
        reject(new Error("websocket open timed out after 10000ms"));
      }, 10000);
      socket.addEventListener(
        "open",
        () => {
          clearTimeout(timer);
          resolve();
        },
        { once: true }
      );
      socket.addEventListener(
        "error",
        () => {
          clearTimeout(timer);
          reject(new Error("websocket open failed"));
        },
        {
          once: true
        }
      );
    });
    await call("initialize", {
      clientInfo: {
        name: clientName,
        version: "0.1.0"
      },
      capabilities: {
        experimentalApi: true
      }
    });
  }

  function close() {
    socket.close();
  }

  return {
    call,
    waitForNotification,
    connect,
    close
  };
}

async function pathExists(path) {
  try {
    await fs.access(path);
    return true;
  } catch {
    return false;
  }
}

async function main() {
  const overallTimer = setTimeout(() => {
    console.error(
      JSON.stringify({
        phase: "timeout",
        message: `repro exceeded ${OVERALL_TIMEOUT_MS}ms`
      })
    );
    process.exit(2);
  }, OVERALL_TIMEOUT_MS);

  const clientA = createRpcClient(CLIENT_A_NAME);
  const clientB = createRpcClient(CLIENT_B_NAME);

  try {
    logPhase("connect-a", { endpoint: ENDPOINT });
    await clientA.connect();
    logPhase("connected-a");

    const started = await clientA.call("thread/start", {
      cwd: CWD,
      source: SOURCE,
      model: MODEL
    });
    const thread = started.thread;
    const rolloutPath = thread.path;
    logPhase("thread-started", { thread_id: thread.id, rollout_path: rolloutPath });

    const turnStarted = await clientA.call("turn/start", {
      threadId: thread.id,
      input: [
        {
          type: "text",
          text: PROMPT,
          text_elements: []
        }
      ]
    });
    logPhase("turn-started", { thread_id: thread.id, turn_id: turnStarted.turn.id });

    const completed = await clientA.waitForNotification(
      "turn/completed",
      (params) => params?.threadId === thread.id || params?.thread_id === thread.id,
      45000
    );
    logPhase("turn-completed", { thread_id: thread.id });

    const readWithoutTurns = await clientA.call("thread/read", {
      threadId: thread.id,
      includeTurns: false
    });

    let readWithTurnsError = null;
    try {
      await clientA.call(
        "thread/read",
        {
          threadId: thread.id,
          includeTurns: true
        },
        10000
      );
    } catch (error) {
      readWithTurnsError = String(error.message ?? error);
    }

    await sleep(3000);
    const rolloutExists = await pathExists(rolloutPath);
    logPhase("checked-rollout", { thread_id: thread.id, rollout_exists: rolloutExists });

    clientA.close();
    logPhase("connect-b", { endpoint: ENDPOINT });
    await clientB.connect();
    logPhase("connected-b");

    let resumeError = null;
    try {
      await clientB.call(
        "thread/resume",
        {
          threadId: thread.id
        },
        10000
      );
    } catch (error) {
      resumeError = String(error.message ?? error);
    }

    console.log(
      JSON.stringify(
        {
          endpoint: ENDPOINT,
          thread_id: thread.id,
          turn_id: turnStarted.turn.id,
          rollout_path: rolloutPath,
          turn_completed: completed.turn?.status ?? completed.turn?.state ?? "unknown",
          read_without_turns_status: readWithoutTurns.thread?.status ?? null,
          rollout_exists: rolloutExists,
          read_with_turns_error: readWithTurnsError,
          sibling_resume_error: resumeError
        },
        null,
        2
      )
    );
  } finally {
    clearTimeout(overallTimer);
    clientB.close();
  }
}

main().catch((error) => {
  console.error(error instanceof Error ? error.stack ?? error.message : String(error));
  process.exit(1);
});
etraut-openai contributor · 3 months ago

Thanks for the script. Unfortunately, I'm still not able to repro the problem — at least not with the latest main branch.

YingzuoLiu · 1 month ago

I took a quick look at this locally, and one thing that stood out is that the app-server terminal turn path seems different from the shutdown path.

From what I can tell, TurnComplete notifies clients, but does not appear to force rollout materialization/flush. The shutdown path does call rollout materialization/flush. That seems consistent with the issue showing up more clearly in long-lived shared websocket sessions, where shutdown may not happen soon after the turn completes.

I tried a small local patch that materializes/flushes the rollout after terminal turn events in app-server event handling, and cargo check -p codex-app-server passes locally. Not sure if this is the intended fix direction, but wanted to share the observation in case it helps.