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_
13 Comments
Potential duplicates detected. Please review them and close your issue if it is a duplicate.
Powered by Codex Action
@etraut-openai I don't think this is a duplicate of https://github.com/openai/codex/issues/15406.
/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?
@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.
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.
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
26.409.20454(build1462)Darwin 25.xonarm64/Users/jinxin/Applications/OpenSource/claude-codeWhat is healthy
~/.codex/state_5.sqlitecontains12unarchived threads for that exactcwd.thread/listfor that exactcwd.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
What I validated locally
app-server-manager-hooks-*.jsandapp-server-manager-signals-*.js).refetchThreadList()must page through allthread/listcursors, not only the current50 * pageCountwindow.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:
ElectronAsarIntegrity, and re-signingImportant upstreaming blocker
openai/codexrepo to try to send a PR.codex appin 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/listlayer is healthy here; the failing layer is renderer-side recent-conversation pagination/cache hydration in the Desktop app.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.
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?
Looks like this is the intended behavior: #15494.
I asked codex to create a script to sync all current sessions:
---
Prompt
Codex response
Added a standalone script at
sync_session_providers.py. It reads the currentmodel_providerfromconfig.toml, finds session files under~/.codex/sessions, and for every session whosesession_meta.payload.model_providerdiffers, it creates a cloned.jsonlwith:session_meta.payload.model_providerrewritten to the current config valuesession_meta.payload.idrewritten to the new UUIDVerified in dry-run mode: it currently finds
10sessions to sync for providermyprovider.Use:
I did not run
--apply, so nothing insessions/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:]))
I hit what appears to be the same class of bug, but with a very clear pagination/hydration signal.
Environment:
26.429.61741codex-cli 0.128.0-alpha.115.7.4, Darwin24.6.0, arm64~/.codex/state_5.sqlitehad427unarchived threads and27archived threads across31cwd/project rootsSymptoms:
~/.codex/state_5.sqlite,~/.codex/session_index.jsonl, and rollout files under~/.codex/sessionswere intact.codex resume --all --include-non-interactivecould find the missing conversations immediately.codex app-server --listen stdio://could return the missing project threads viathread/listwhen querying directly bycwd.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:
threads.updated_atdid not persist, because the app-server scan-and-repair path rewrote the DB timestamps from the rollout JSONL timestamps.listRecentThreadsto followthread/listcursors 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:
app.asarwithout updatingElectronAsarIntegrityinContents/Info.plistcaused the app to crash at startup withEXC_BREAKPOINT/SIGTRAPduring Electron bootstrap.app.asar, updatingElectronAsarIntegrity["Resources/app.asar"].hashto 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:
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/listresults as needed, or to querythread/listbycwdwhen expanding/searching a project, instead of assuming the initial global recent window is sufficient.thank you for the valuable detailed post, any update from the codex team concerning this ?
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:
%USERPROFILE%\.codex\state_5.sqlite.%USERPROFILE%\.codex\sessions\....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/archiveif 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:
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-*.jsonlProduct 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:
I just want to mention that this is a frequent issue for me still.
Me too