Codex Mac Desktop App: existing threads don't load after app restart

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

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

Version 26.325.31654 (1272)

What subscription do you have?

Pro

What platform is your computer?

Darwin 25.1.0 arm64 arm

What issue are you seeing?

I had a bunch of open threads (not archived) open in the Codex app, after a restart they are all gone in the UI, but still present on disk.
Except the pinned threads, somehow they remain.

<img width="318" height="313" alt="Image" src="https://github.com/user-attachments/assets/64495848-a3a7-4019-bc90-e18ce3be1191" />

From Codex itself:

The first bad loads show up on April 4, 2026 at 12:23:38Z in codex-desktop log (line 1858). Your user config in config.toml was last modified at April 4, 2026 23:15:11, and the current app bundle app.asar is from March 28, 2026. So the timing does not fit “we changed config, then sessions broke.” What the source shows: this is a Codex Desktop subagent-thread bug. I extracted the renderer chunk to app-server-manager-hooks.js. In there, the app hydrates spawned/collab receiver threads with a thread/read using includeTurns:false, and separately the turn/started handler just errors out if the conversation is not already in memory. That creates a race. Your logs match it exactly: for this spawned session (line 1), the session is created at 18:57:21.934Z, then the log (line 21320) shows thread/read, and 95ms later the same log (line 21321) shows Received turn/started for unknown conversation, followed by No turns for conversation. That means the child thread starts emitting turn events before the renderer has inserted conversation state. There’s a second real bug in the bundle too: the desktop code parses source.subAgent, but your stored thread/session metadata is source.subagent lowercase in state_5.sqlite and the session files. That likely breaks subagent-specific UI wiring, but it looks older and latent. The race above is the thing that matches today’s failure pattern.

More clanker slop:

After restarting Codex App, almost all previous sessions stopped loading. The only old sessions that still seem to load are pinned ones. Important detail: this does not look like session data was deleted. The session files still exist locally on disk, but the app fails to load them after restart. From the app logs, the failure pattern looks like this: - the app successfully reads the thread - then it immediately treats incoming turn/item events as belonging to an unknown conversation - then it reports that there are no turns / no conversation state for that same conversation Example from the logs for conversationId=019d59db-5e0a-74c3-88a0-686072ee40fe: - 2026-04-04T18:57:22.374Z: response_routed ... method=thread/read conversationId=019d59db-5e0a-74c3-88a0-686072ee40fe - 2026-04-04T18:57:22.469Z: Received turn/started for unknown conversation conversationId=019d59db-5e0a-74c3-88a0-686072ee40fe - 2026-04-04T18:57:23.416Z: No turns for conversation conversationId=019d59db-5e0a-74c3-88a0-686072ee40fe Another example for conversationId=019d5918-fa4e-74d1-9ed2-cb69be48e455: - 2026-04-04T15:25:02.606Z: Received turn/started for unknown conversation conversationId=019d5918-fa4e-74d1-9ed2-cb69be48e455 - 2026-04-04T15:25:03.452Z: Received item/started for unknown conversation conversationId=019d5918-fa4e-74d1-9ed2-cb69be48e455 - 2026-04-04T15:25:03.452Z: Received item/completed for unknown conversation conversationId=019d5918-fa4e-74d1-9ed2-cb69be48e455 - 2026-04-04T15:25:03.840Z: Conversation state not found conversationId=019d5918-fa4e-74d1-9ed2-cb69be48e455 So the user-visible symptom is: - restart app - pinned sessions still load - non-pinned previous sessions mostly or entirely fail to load - local session data still exists - logs suggest the app is reading thread metadata but failing to reconstruct / retain conversation state

What steps can reproduce the bug?

Uploaded thread: 019d59e6-805e-7f53-af2e-d474db002e07

What is the expected behavior?

All the threads on disk load in the app 😁

Additional information

_No response_

View original on GitHub ↗

13 Comments

github-actions[bot] contributor · 3 months ago

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

  • #16599
  • #15406
  • #15349
  • #16811
  • #15052

Powered by Codex Action

joshp123 · 3 months ago

@etraut-openai I don't think this is a duplicate of https://github.com/openai/codex/issues/15406.

  • That ticket describes threads disappearing randomly - in this case, it's not random threads - its all of them (except pinned ones)
  • Threads stay in Codex CLI history and can be resumed with /resume - in this case, the threads are not visible from the Codex CLI at all, and cannot be resumed.

Can you consider re-opening this?

etraut-openai contributor · 3 months ago

@joshp123, I think this is the same underlying problem. We like to consolidate problems that are likely to be the same issue because it boosts their visibility to the team. If there are a bunch of separate bug reports with little or no engagement from other users, they tend to fly beneath the radar.

That said, if you think this is truly a different issue, I'll reopen this one.

joshp123 · 3 months ago

Hey @etraut-openai , thanks for the response and re-opening the issue - I appreciate it. Totally understand consolidating duplicates, no criticism there :)

One more additional datapoint (that also diverges from the linked ticket) - it seems like my preferences for the Codex app (and possibly CLI) also got reset around the same time. I'm having Codex look into the issue; will keep the issue updated with additional information and enrich the issue with this.

yahuo · 3 months ago

Additional evidence from a renderer/sidebar-only variant on macOS. This may be the same umbrella symptom as #16095, but on my machine the failing layer is clearly the Desktop renderer, not local storage or the app-server.

Environment

  • Codex Desktop: 26.409.20454 (build 1462)
  • macOS: Darwin 25.x on arm64
  • active workspace: /Users/jinxin/Applications/OpenSource/claude-code

What is healthy

  • ~/.codex/state_5.sqlite contains 12 unarchived threads for that exact cwd.
  • The rollout/session files for those threads still exist on disk.
  • Querying the same local app-server that the Desktop app uses returns those missing threads correctly via thread/list for that exact cwd.
  • On this machine, all of the following return the expected rows:
  • thread/list({ cwd=..., archived=false })
  • thread/list({ cwd=..., archived=false, sourceKinds=[] })
  • thread/list({ cwd=..., archived=false, sourceKinds=null })
  • thread/list({ cwd=..., archived=false, sourceKinds=["vscode"] })

Observed UI symptom

  • In project view, the left sidebar showed only the current thread under that project.
  • Restarting the Desktop app did not restore the missing local threads.
  • Pinned / otherwise already-hydrated threads could survive, which made this look like a client-side hydration/rendering problem.

What I validated locally

  • I extracted the current Desktop bundle and patched the renderer chunks (app-server-manager-hooks-*.js and app-server-manager-signals-*.js).
  • A local patch that does the following restored the missing sidebar threads on my machine:
  1. In the recent-conversations manager, refetchThreadList() must page through all thread/list cursors, not only the current 50 * pageCount window.
  2. In the sidebar query hook, the React Query cache must be populated from a full merged listAllThreads({ archived:false }) result, not seeded from the current in-memory recent-conversation subset and then left vulnerable to being overwritten by that partial state.

After applying that renderer-side patch locally:

  • the missing project threads reappeared in the left sidebar
  • the app still launched cleanly after repacking, updating ElectronAsarIntegrity, and re-signing

Important upstreaming blocker

  • I checked the public openai/codex repo to try to send a PR.
  • The public repo contains the CLI / app-server / SDK pieces, but not the Electron Desktop renderer/webview source for this sidebar path.
  • codex app in the public repo only downloads/opens Codex Desktop; it does not contain the sidebar implementation I had to patch.

So from the outside this looks like an internal Desktop-source fix, not something that can currently be fixed with a public PR against openai/codex.

If useful, I can post the exact local patch shape I validated, but the short version is: the backend thread/list layer is healthy here; the failing layer is renderer-side recent-conversation pagination/cache hydration in the Desktop app.

vernon99 · 3 months ago

Just wanted to chime in here, happened to twice in the last 30 minutes, removing first a bunch of chats after restart and then removing the rest. Almost all of mu chats are gone, ugh.

joshp123 · 3 months ago

I just saw this reported bug: https://github.com/openai/codex/issues/17316 , and IIRC it coincided with me also switching providers, and when I switched providers once more back to default ChatGPT, all my threads re-appeared. I think this could be the key?

aboqasem · 2 months ago

Looks like this is the intended behavior: #15494.

I asked codex to create a script to sync all current sessions:

---

Prompt

Codex sessions are stored in: ~/codex/sessions/2026/04/30/rollout-2026-04-30T14-17-40-019dde52-c70f-7af2-b719-20cb032084ff.jsonl

example:
~/.codex/sessions/2026/04/30/rollout-2026-04-30T14-17-40-019dde52-c70f-7af2-b719-20cb032084ff.jsonl
1:{"timestamp":"2026-04-30T12:17:40.663Z","type":"session_meta","payload":{"id":"019dde52-c70f-7af2-b719-20cb032084ff","timestamp":"2026-04-30T12:17:40.127Z","cwd":"...","originator":"codex-tui","cli_version":"0.124.0","source":"cli","model_provider":"openai"...

each session has a session_meta with model_provider

when i change model_provider in my ~/.codex/config.toml, all sessions from other providers will disappear from search.

can we do a script that syncs all sessions that are not current model_provider in config.toml, by copying the session with the same name and UUID v7 time bits (but different random bits), and changing model_provider of the json to the current one?

Codex response

Added a standalone script at sync_session_providers.py. It reads the current model_provider from config.toml, finds session files under ~/.codex/sessions, and for every session whose session_meta.payload.model_provider differs, it creates a cloned .jsonl with:

  • the same filename prefix and timestamp portion
  • a new UUIDv7-style id that preserves the original time bits and changes only the random bits
  • session_meta.payload.model_provider rewritten to the current config value
  • session_meta.payload.id rewritten to the new UUID

Verified in dry-run mode: it currently finds 10 sessions to sync for provider myprovider.

Use:

python3 ~/.codex/bin/sync_session_providers.py --summary-only
python3 ~/.codex/bin/sync_session_providers.py
python3 ~/.codex/bin/sync_session_providers.py --apply

I did not run --apply, so nothing in sessions/ was changed yet.

```py filename=~/.codex/bin/sync_session_providers.py
##!/usr/bin/env python3

from __future__ import annotations

import argparse
import json
import os
import random
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any

SESSION_RE = re.compile(
r"^(?P<prefix>.+-)(?P<uuid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?P<suffix>\.jsonl)$"
)

@dataclass
class SessionRewrite:
source: Path
destination: Path
old_provider: str
new_provider: str
old_id: str
new_id: str

def load_config_provider(config_path: Path) -> str:
for raw_line in config_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or line.startswith("["):
continue

match = re.match(r'^model_provider\s=\s"([^"]+)"\s*$', line)
if match:
return match.group(1)

return "openai"

def iter_session_files(sessions_root: Path) -> list[Path]:
return sorted(path for path in sessions_root.rglob("*.jsonl") if path.is_file())

def parse_session_meta(path: Path) -> tuple[dict[str, Any], list[str]]:
with path.open("r", encoding="utf-8") as handle:
lines = handle.readlines()

for line in lines:
if not line.strip():
continue
item = json.loads(line)
if item.get("type") == "session_meta":
return item, lines

raise ValueError(f"No session_meta found in {path}")

def canonicalize_session_lines(lines: list[str]) -> str:
normalized_lines: list[str] = []
meta_normalized = False

for line in lines:
if not line.strip():
normalized_lines.append("\n")
continue

item = json.loads(line)
if not meta_normalized and item.get("type") == "session_meta":
payload = item.get("payload")
if not isinstance(payload, dict):
raise ValueError("Invalid session_meta payload while canonicalizing session")

normalized_payload = dict(payload)
normalized_payload.pop("id", None)
normalized_payload.pop("model_provider", None)
normalized_payload.pop("provider", None)

normalized_item = dict(item)
normalized_item["payload"] = normalized_payload
normalized_lines.append(json.dumps(normalized_item, sort_keys=True, separators=(",", ":")) + "\n")
meta_normalized = True
continue

normalized_lines.append(json.dumps(item, sort_keys=True, separators=(",", ":")) + "\n")

if not meta_normalized:
raise ValueError("No session_meta found while canonicalizing session")

return "".join(normalized_lines)

def build_existing_session_index(session_files: list[Path]) -> dict[tuple[str, str], list[Path]]:
index: dict[tuple[str, str], list[Path]] = {}

for session_file in session_files:
session_meta, lines = parse_session_meta(session_file)
payload = session_meta.get("payload")
if not isinstance(payload, dict):
raise ValueError(f"Invalid session_meta payload in {session_file}")

provider = payload.get("model_provider") or payload.get("provider") or "openai"
if not isinstance(provider, str):
raise ValueError(f"Missing provider in session_meta payload in {session_file}")

fingerprint = canonicalize_session_lines(lines)
index.setdefault((provider, fingerprint), []).append(session_file)

return index

def generate_uuidv7_with_same_timestamp(old_uuid: str) -> str:
compact = old_uuid.replace("-", "")
if len(compact) != 32 or compact[12] != "7":
raise ValueError(f"Expected UUIDv7 string, got {old_uuid}")

timestamp_hex = compact[:12]
rand_a = random.getrandbits(12)
rand_b_hi = random.getrandbits(14)
rand_b_lo = random.getrandbits(48)

compact_new = (
f"{timestamp_hex}"
f"7{rand_a:03x}"
f"{0x8000 | rand_b_hi:04x}"
f"{rand_b_lo:012x}"
)
return (
f"{compact_new[:8]}-"
f"{compact_new[8:12]}-"
f"{compact_new[12:16]}-"
f"{compact_new[16:20]}-"
f"{compact_new[20:]}"
)

def build_destination(source: Path, new_uuid: str) -> Path:
match = SESSION_RE.match(source.name)
if not match:
raise ValueError(f"Unrecognized session filename format: {source.name}")
return source.with_name(f"{match.group('prefix')}{new_uuid}{match.group('suffix')}")

def compute_rewrite(
path: Path,
target_provider: str,
reserved_destinations: set[Path],
existing_session_index: dict[tuple[str, str], list[Path]],
) -> SessionRewrite | None:
session_meta, lines = parse_session_meta(path)
payload = session_meta.get("payload")
if not isinstance(payload, dict):
raise ValueError(f"Invalid session_meta payload in {path}")

old_provider = payload.get("model_provider") or payload.get("provider") or "openai"
old_id = payload.get("id")
if not isinstance(old_provider, str) or not isinstance(old_id, str):
raise ValueError(f"Missing session_meta payload fields in {path}")

if old_provider == target_provider:
return None

fingerprint = canonicalize_session_lines(lines)
if existing_session_index.get((target_provider, fingerprint)):
return None

new_id = generate_uuidv7_with_same_timestamp(old_id)
destination = build_destination(path, new_id)
while destination.exists() or destination in reserved_destinations:
new_id = generate_uuidv7_with_same_timestamp(old_id)
destination = build_destination(path, new_id)

return SessionRewrite(
source=path,
destination=destination,
old_provider=old_provider,
new_provider=target_provider,
old_id=old_id,
new_id=new_id,
)

def rewrite_session_file(plan: SessionRewrite) -> None:
session_meta, lines = parse_session_meta(plan.source)
payload = session_meta["payload"]
payload["model_provider"] = plan.new_provider
payload["id"] = plan.new_id

output_lines: list[str] = []
meta_rewritten = False
for line in lines:
if not line.strip():
output_lines.append(line)
continue

item = json.loads(line)
if not meta_rewritten and item.get("type") == "session_meta":
output_lines.append(json.dumps(session_meta, separators=(",", ":")) + "\n")
meta_rewritten = True
else:
output_lines.append(line)

plan.destination.write_text("".join(output_lines), encoding="utf-8")

def format_plan(plan: SessionRewrite) -> str:
return (
f"{plan.source} -> {plan.destination} "
f"[provider: {plan.old_provider} -> {plan.new_provider}, id: {plan.old_id} -> {plan.new_id}]"
)

def main(argv: list[str]) -> int:
home = Path(os.path.expanduser("~"))
parser = argparse.ArgumentParser(
description=(
"Clone Codex session files that use a different model_provider and rewrite "
"their session_meta to the provider configured in config.toml."
)
)
parser.add_argument(
"--config",
type=Path,
default=home / ".codex" / "config.toml",
help="Path to config.toml",
)
parser.add_argument(
"--sessions-root",
type=Path,
default=home / ".codex" / "sessions",
help="Root directory containing session JSONL files",
)
parser.add_argument(
"--apply",
action="store_true",
help="Write cloned session files. Without this flag, the script only reports planned changes.",
)
parser.add_argument(
"--limit",
type=int,
default=None,
help="Only process the first N matching sessions after sorting.",
)
parser.add_argument(
"--summary-only",
action="store_true",
help="Only print aggregate counts, not one line per planned rewrite.",
)
args = parser.parse_args(argv)

target_provider = load_config_provider(args.config.expanduser())
session_files = iter_session_files(args.sessions_root.expanduser())
existing_session_index = build_existing_session_index(session_files)

rewrites: list[SessionRewrite] = []
reserved_destinations: set[Path] = set()
for session_file in session_files:
rewrite = compute_rewrite(
session_file,
target_provider,
reserved_destinations,
existing_session_index,
)
if rewrite is not None:
rewrites.append(rewrite)
reserved_destinations.add(rewrite.destination)

if args.limit is not None:
rewrites = rewrites[: args.limit]

if not rewrites:
print(f"No sessions need syncing for provider {target_provider}.")
return 0

print(f"Target provider: {target_provider}")
print(f"Matching sessions: {len(rewrites)}")
if not args.summary_only:
for rewrite in rewrites:
print(format_plan(rewrite))

if not args.apply:
print("Dry run only. Re-run with --apply to create rewritten copies.")
return 0

written = 0
for rewrite in rewrites:
rewrite_session_file(rewrite)
written += 1

print(f"Wrote {written} cloned session files.")
return 0

if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

shaypal5 · 2 months ago

I hit what appears to be the same class of bug, but with a very clear pagination/hydration signal.

Environment:

  • Codex Desktop: 26.429.61741
  • Bundled CLI/app-server: codex-cli 0.128.0-alpha.1
  • Platform: macOS 15.7.4, Darwin 24.6.0, arm64
  • Local state: ~/.codex/state_5.sqlite had 427 unarchived threads and 27 archived threads across 31 cwd/project roots

Symptoms:

  • After restart/reinstall, many project sidebar groups showed zero chats even though ~/.codex/state_5.sqlite, ~/.codex/session_index.jsonl, and rollout files under ~/.codex/sessions were intact.
  • codex resume --all --include-non-interactive could find the missing conversations immediately.
  • A fresh codex app-server --listen stdio:// could return the missing project threads via thread/list when querying directly by cwd.
  • Desktop sidebar/search did not find them until the relevant threads were moved into the globally recent loaded window.
  • A specific project had 20 unarchived local threads in SQLite, but the Desktop project view showed No chats.

Most useful finding:

This looks like the same underlying issue as #14751: the renderer/sidebar is driven by a globally loaded recent subset, rather than fetching enough pages or querying per-project history when expanding a project.

Local proof:

  1. Bumping only SQLite threads.updated_at did not persist, because the app-server scan-and-repair path rewrote the DB timestamps from the rollout JSONL timestamps.
  2. Bumping the last event timestamp in the 20 rollout JSONL files for one missing project made that project reappear. This was a workaround, not a fix: it only worked because those 20 chats were now inside the first globally loaded recent window.
  3. Applying a global chronological timestamp rewrite preserved the overall order, but the project disappeared again because it was pushed below the first loaded window. This showed that the bug is not the project data itself; it is the limited global hydration window.
  4. A renderer hotpatch that changed listRecentThreads to follow thread/list cursors internally, fetching multiple pages of 100 before building the sidebar/project cache, restored all missing projects and conversations.

Important implementation detail for reproducing the hotpatch safely on macOS/Electron:

  • Repacking app.asar without updating ElectronAsarIntegrity in Contents/Info.plist caused the app to crash at startup with EXC_BREAKPOINT / SIGTRAP during Electron bootstrap.
  • Repacking app.asar, updating ElectronAsarIntegrity["Resources/app.asar"].hash to the SHA-256 of the ASAR header string, and then re-signing ad-hoc made the patched app launch.

The effective app-side workaround was conceptually:

async listRecentThreads({ cursor, limit }) {
  const data = [];
  let next = cursor;
  let last = null;

  for (let page = 0; page < 20; page += 1) {
    const res = await this.requestClient.sendRequest("thread/list", {
      limit: 100,
      cursor: next,
      sortKey: this.sortKey,
      modelProviders: null,
      archived: false,
      sourceKinds: C,
    });

    data.push(...res.data);
    last = res;
    next = res.nextCursor;
    if (!next) break;
  }

  return last ? { ...last, data, nextCursor: null } : fallback;
}

After that patch, the Desktop sidebar/project groups showed the missing conversations again. So the likely product fix is for the Desktop renderer/sidebar to page through local thread/list results as needed, or to query thread/list by cwd when expanding/searching a project, instead of assuming the initial global recent window is sufficient.

roymasad · 2 months ago

thank you for the valuable detailed post, any update from the codex team concerning this ?

chenjxpp · 1 month ago

Codex Desktop: Older Project Chats Disappear From Sidebar

Cause

This is usually not data loss. Codex Desktop appears to load only a limited global recent-thread window for the sidebar, roughly the most recently updated conversations. A total 50 list.

When many other threads are newer, older project chats can fall outside that window and disappear from the project sidebar even though they still exist in local storage.

Typical evidence:

  • The chat is missing from the Desktop sidebar.
  • The thread still exists in %USERPROFILE%\.codex\state_5.sqlite.
  • The rollout file still exists under %USERPROFILE%\.codex\sessions\....
  • Archiving newer low-value threads or refreshing the missing thread makes it visible again.

Fixes

1. Archive low-value visible threads

Archive temporary, generated, test, or unneeded threads that currently occupy the sidebar. This frees space in the recent-thread window, so older useful project chats can show again.

Use Codex Desktop UI archive, or app-server thread/archive if you know the thread ids.

2. Resume or refresh the missing thread

If you know the missing thread id, resume it and send a very small test turn, for example:

test, please reply OK only.

This updates updated_at, pushing the thread back into the recent window. After that, restart Codex Desktop if the UI does not update immediately.

3. Pin important threads

For long-running or important project chats, pin them so they are less likely to disappear from the sidebar when they fall outside the recent window.

4. Use local state as the source of truth

Do not treat the sidebar as full history. Check local storage when a chat appears missing:

  • %USERPROFILE%\.codex\state_5.sqlite
  • %USERPROFILE%\.codex\sessions\...\rollout-*.jsonl

Product Bug

The sidebar should page by project, not only depend on the first global recent list. Search should also include all local threads, not only loaded recent threads.

Until that is fixed, the practical workaround is:

archive unneeded recent threads
or
resume the missing thread to refresh its updated_at
then restart Codex Desktop
rrosztoczy · 1 month ago

I just want to mention that this is a frequent issue for me still.

dkkb · 25 days ago
I just want to mention that this is a frequent issue for me still.

Me too