Skip to content

Codex Desktop session stuck as follower after backend thread is idle #24217

Open

Description

@sdro

What version of the Codex App are you using (From “About Codex” dialog)?

26.519.41501

What subscription do you have?

Pro

What platform is your computer?

Darwin 25.5.0 arm64 arm

What issue are you seeing?

Environment:

  • Codex Desktop macOS
  • Version: 26.519.41501
  • CLI could continue the same thread successfully
  • Desktop rendered the thread but kept spinner/status active and blocked Send/Stop

Symptoms:

  • Desktop showed the session as still running.
  • Send was blocked.
  • Stop did not recover the session.
  • Restarting Desktop did not fix it.
  • CLI could load/continue the session.

Diagnostics:

  • Backend thread/read returned the thread as idle/complete.
  • Backend thread/resume returned idle/complete state.
  • Desktop renderer thread-role-for-host returned follower.
  • assert-thread-follower-owner-for-host failed with:
    "Please continue this conversation on the window where it was started."
  • Clearing Desktop stream roles and then running the normal resume path with workspaceRoots changed the role to owner and recovered the thread.

Expected:
If backend state is idle/complete and no owner window exists, Desktop should not keep the thread blocked as a follower. It should either clear the stale follower role or resume ownership.

Actual:
Desktop kept a stale follower/owner routing state even though the backend thread was idle.

Related: #20754, #12852, #21360. This may be the same family of bug, but this report adds a more specific diagnostic: Desktop stayed in follower mode while backend thread state was idle/complete.

What steps can reproduce the bug?

I do not have a deterministic minimal reproduction yet.

Observed reproduction pattern:

  1. Use Codex Desktop on macOS with a local workspace.
  2. Work in a thread for multiple turns.
  3. At some point, the Desktop UI keeps showing the thread as active/spinning even though the underlying task has completed.
  4. Reopen/restart Codex Desktop.
  5. The same thread still renders content, but the status remains active/spinning and Send/Stop are blocked.
  6. Load or continue the same thread from Codex CLI.
  7. CLI can continue the thread successfully, but Desktop still treats the thread as blocked.
  8. Inspecting Desktop renderer state showed the thread role as follower, while backend thread/read and thread/resume returned the thread as idle/complete.
  9. Clearing Desktop stream roles and then triggering the normal resume path with workspaceRoots changed the role to owner and recovered the thread.

The important diagnostic shape is: Desktop follower/owner routing state was stale while backend thread state was idle.

What is the expected behavior?

Codex Desktop should not keep a local thread blocked as a follower when the backend thread state is idle/complete and no owner window is available.

Expected behavior:

  • If the backend reports the thread as idle/complete, Desktop should clear any stale active/spinning UI state.
  • If a follower/owner route is stale, Desktop should recover ownership or fall back to a normal resume path.
  • Send and Stop should not remain blocked indefinitely.
  • Restarting Desktop should not restore the same stale follower state.
  • Desktop and CLI should agree on whether the thread is usable.

Additional information

Additional diagnostics from the affected run:

  • Codex Desktop version: 26.519.41501
  • Platform: macOS arm64
  • CLI could load/continue the same thread while Desktop could not.
  • Desktop thread-role-for-host returned follower.
  • Desktop assert-thread-follower-owner-for-host failed with:
    "Please continue this conversation on the window where it was started."
  • Backend thread/read returned the thread as idle/complete.
  • Backend thread/resume returned the thread as idle/complete.
  • The Desktop app-state snapshot showed stale streaming/follower counters before repair, including streaming follower threads without active runtime.
  • Running discard-conversation-from-cache for the affected conversation reduced the stale counters but did not fully recover the thread.
  • Running mark-all-conversations-need-resume-after-reconnect-for-host cleared the streaming/follower counters.
  • Triggering the normal resume path with the required workspaceRoots then changed the thread role from follower to owner, and the thread became usable again.

Activity

  1. added
    appIssues related to the Codex desktop app
    on May 23, 2026
  2. added
    bugSomething isn't working
    sessionIssues involving session (thread) management, resuming, forking, naming, archiving
    on May 23, 2026
  3. github-actions commented on May 23, 2026

    @github-actions
    Contributor

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

    Powered by Codex Action

  4. sdro commented on May 23, 2026

    @sdro
    Author

    Advanced workaround that recovered my session

    Warning: this uses internal Codex Desktop renderer APIs and may change in future versions. Do not paste session transcripts, local paths, logs, prompts, screenshots, or private project data into public issues.

    Replace:

    • <THREAD_ID> with the affected Codex thread/session id
    • <WORKSPACE_ROOT> with the workspace root for that thread
    1. Quit Codex and relaunch it with DevTools enabled:
    pkill -f "/Applications/Codex.app"
    open -na /Applications/Codex.app --args --remote-debugging-port=9223
    1. Confirm DevTools is reachable:
    curl -s http://127.0.0.1:9223/json
    1. Run this local diagnostic/repair script:
    /Applications/Codex.app/Contents/Resources/node - <<'NODE'
    const threadId = "<THREAD_ID>";
    const workspaceRoot = "<WORKSPACE_ROOT>";
    
    async function cdpEval(expr) {
      const pages = await (await fetch("http://127.0.0.1:9223/json")).json();
      const page = pages.find(
        (p) => p.type === "page" && p.url.startsWith("app://-/index.html"),
      );
      if (!page) throw new Error("Codex renderer page not found");
    
      const ws = new WebSocket(page.webSocketDebuggerUrl);
      let id = 0;
      const pending = new Map();
    
      ws.onmessage = (event) => {
        const msg = JSON.parse(event.data);
        if (msg.id && pending.has(msg.id)) {
          const { resolve, reject } = pending.get(msg.id);
          pending.delete(msg.id);
          msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result);
        }
      };
    
      await new Promise((resolve, reject) => {
        ws.onopen = resolve;
        ws.onerror = reject;
      });
    
      const send = (method, params = {}) =>
        new Promise((resolve, reject) => {
          const requestId = ++id;
          pending.set(requestId, { resolve, reject });
          ws.send(JSON.stringify({ id: requestId, method, params }));
        });
    
      await send("Runtime.enable");
    
      const result = await send("Runtime.evaluate", {
        expression: expr,
        awaitPromise: true,
        returnByValue: true,
        userGesture: true,
        timeout: 60000,
      });
    
      ws.close();
    
      if (result.exceptionDetails) {
        throw new Error(JSON.stringify(result.exceptionDetails));
      }
    
      return result.result?.value ?? result.result?.description;
    }
    
    const expr = `(async () => {
      const threadId = ${JSON.stringify(threadId)};
      const workspaceRoot = ${JSON.stringify(workspaceRoot)};
      const m = await import("./assets/app-server-manager-signals-Csopz8aM.js");
    
      const out = {};
    
      out.beforeRole = await m.ts("thread-role-for-host", {
        hostId: "local",
        conversationId: threadId,
      }).catch((e) => ({ error: String(e), message: e?.message }));
    
      out.beforeSnapshot = await m.ts("collect-app-state-snapshot-for-host", {
        hostId: "local",
        reason: "before-stale-follower-workaround",
      }).catch((e) => ({ error: String(e), message: e?.message }));
    
      out.read = await m.ts("send-cli-request-for-host", {
        hostId: "local",
        method: "thread/read",
        params: { threadId, includeTurns: false },
      }).catch((e) => ({ error: String(e), message: e?.message }));
    
      await m.ts("mark-all-conversations-need-resume-after-reconnect-for-host", {
        hostId: "local",
      });
    
      await new Promise((resolve) => setTimeout(resolve, 1000));
    
      out.resume = await m.ts("maybe-resume-conversation", {
        hostId: "local",
        conversationId: threadId,
        model: null,
        serviceTier: null,
        reasoningEffort: null,
        workspaceRoots: [workspaceRoot],
        permissions: null,
        collaborationMode: null,
        showThreadGoalResumeConfirmation: false,
      }).catch((e) => ({ error: String(e), message: e?.message }));
    
      await new Promise((resolve) => setTimeout(resolve, 1000));
    
      out.afterRole = await m.ts("thread-role-for-host", {
        hostId: "local",
        conversationId: threadId,
      }).catch((e) => ({ error: String(e), message: e?.message }));
    
      out.afterSnapshot = await m.ts("collect-app-state-snapshot-for-host", {
        hostId: "local",
        reason: "after-stale-follower-workaround",
      }).catch((e) => ({ error: String(e), message: e?.message }));
    
      return JSON.stringify(out, null, 2);
    })()`;
    
    cdpEval(expr)
      .then((value) => console.log(value))
      .catch((error) => {
        console.error(error.stack || error);
        process.exit(1);
      });
    NODE
    1. In my recovered case, the important result was:
    beforeRole: "follower"
    backend thread/read: idle/complete
    afterRole: "owner"
    

    After that, the affected Desktop thread accepted input again.

    This workaround should not edit the session JSONL. It resets in-memory Desktop stream ownership state and then asks Desktop to resume the affected thread normally.

  5. nos1609 commented on Aug 28, 2026

    @nos1609

    Additional sanitized reproduction from Codex Desktop on Windows ARM64 controlling a Remote SSH Linux task.

    Environment:

    • Codex Desktop package: 26.825.4187.0
    • renderer telemetry release: codex@26.825.32147
    • Chromium/Electron runtime: 151.0.7922.174
    • remote Codex CLI/app-server: 0.150.1

    Observed:

    • The remote backend reported the affected task as idle/completed.
    • A fresh exact task read returned the completed turn and all 35 structured items.
    • Sending from the Desktop composer created no new remote turn and no new rollout write.
    • Repeated renderer state snapshots reported:
    thread_count_total=2
    thread_count_active=2
    thread_count_streaming_owner=2
    thread_count_streaming_with_active_runtime=1
    thread_count_streaming_without_active_runtime=1
    thread_count_with_inflight_turn=1
    pending_request_count=2
    

    This is the owner-side variant of the stale role bug: one task remained a streaming_owner despite having no active runtime. Navigating away and unloading/reloading only the affected task restored its transcript projection, but did not clear the stale owner state or re-enable submission.

    Expected:

    • A streaming owner without an active runtime should be detached or rebound automatically.
    • An idle/completed backend task should accept a new turn without requiring a full Desktop renderer restart.

    No thread IDs, turn IDs, paths, hostnames, addresses, repository names, prompts, credentials, account data, or raw logs are included.

  6. berkyuo2-cpu commented on Aug 28, 2026

    @berkyuo2-cpu

    Additional sanitized local Windows x64 reproduction (2026-08-28). This overlaps #24287, but the strongest diagnostic matches this issue: Desktop reports an active turn while the conversation service says there is no active turn ID.

    Environment

    • Codex Desktop Microsoft Store/MSIX package: OpenAI.Codex_26.818.8289.0_x64__2p2nqsd0c76g0
    • Windows 10 Pro x64, build 19045
    • Local codex-cli 0.144.1
    • Long, tool-heavy local-workspace thread with subagents; the app instance had been open for several days

    Observed

    1. A previous turn was explicitly interrupted. A later short follow-up was submitted in the same thread.

    2. Desktop stayed on Thinking with the Stop control visible.

    3. Repeated read-only task snapshots reported the thread as active and the latest turn as inProgress, but the turn had zero items, with no assistant message and no tool marker, for more than 68 minutes.

    4. Two supported app-level follow-up/steer attempts were rejected after waiting with this error (conversation ID redacted):

      Cannot steer conversation <redacted> without an active turn id
      
    5. Navigating away/back and refreshing the exact task did not reconcile the state.

    6. The background PowerShell row visible in the UI did not correspond to another live matching PowerShell process at diagnosis time. The Codex process registry JSON also passed its guarded health audit (valid JSON array, zero NUL bytes, no repair needed).

    7. A same-directory fork from completed history accepted a new follow-up, but the child also immediately remained active / inProgress with zero items, no assistant message, and no tool marker for repeated 30-second observation windows.

    8. Other Desktop/app-server operations remained responsive (list, read, and wait worked), so this was not a full application or network outage.

    The important state split is therefore:

    Desktop/task index: active + inProgress
    visible turn stream: zero items / no assistant / no tool marker
    conversation service: no active turn id
    

    This looks like stale active/owner routing or long-history hydration causing the next prompt to be classified as turn/steer even though the backend has no steerable turn. The fork reproduction suggests the stale classification can also survive reconstruction from completed history, rather than being caused only by a dead shell command.

    Expected

    • If the backend has no active turn ID, Desktop should clear stale active/spinning state and submit the next prompt via a normal turn/start path.
    • If cached Desktop state and app-server state disagree, the app should automatically detach/rebind ownership or show an explicit recoverable error instead of indefinite Thinking.
    • A fork from completed history should not inherit or recreate a stale active-turn classification.
    • A watchdog should surface/recover an inProgress turn that has produced zero events for an extended interval.

    I intentionally did not restart the app or mutate internal thread state before collecting the evidence. No thread/turn IDs, usernames, local paths, repository names, prompts, device identifiers, screenshots, credentials, or raw logs are included.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    appIssues related to the Codex desktop appbugSomething isn't workingsessionIssues involving session (thread) management, resuming, forking, naming, archiving

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions