Codex Desktop project chat history disappears after update due to WSL cwd/path migration mismatch

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

Summary

Codex Desktop can show an empty project chat history even though local session files and state DB rows still exist. In this case, every project sidebar appeared empty except the current chat. The local repair showed two related migration/path compatibility problems after a Codex Desktop/app-server update:

  1. state_5.sqlite and logs_2.sqlite contained _sqlx_migrations checksum rows from an older build. A fresh codex app-server from the installed binary refused to initialize with:
failed to initialize sqlite state db: failed to initialize state runtime at <CODEX_HOME>: migration 1 was previously applied but has been modified
  1. Older sessions had cwd values persisted as Windows paths such as:
C:\Users\<user>\Documents\codex-projects\surfaceguru

When queried by the current WSL app-server, thread/list treated those Windows paths as relative strings and produced unusable cwd values like:

/mnt/c/Users/<user>/Documents/codex-projects/surfaceguru/C:\Users\<user>\Documents\codex-projects\surfaceguru

As a result, project-scoped thread/list queries returned data: [] even though matching rows and rollout files existed.

Environment

  • Product: Codex Desktop
  • Windows package observed: OpenAI.Codex_26.513.4821.0_x64__2p2nqsd0c76g0
  • App-server / CLI: codex-cli 0.131.0-alpha.9
  • App-server command: codex app-server --analytics-default-enabled
  • WSL: Ubuntu 24.04
  • CODEX_HOME: Windows user profile .codex, exposed in WSL as /mnt/c/Users/<user>/.codex
  • Desktop app-server env included CODEX_INTERNAL_ORIGINATOR_OVERRIDE=Codex Desktop

User-visible symptom

  • Project chat sidebar showed only the current chat.
  • Previous chats under multiple projects were missing from the UI.
  • Local data was not actually gone:
  • 47 local session .jsonl files existed.
  • session_index.jsonl existed.
  • state_5.sqlite contained thread rows.

Local diagnostic evidence

Direct app-server probe before migration checksum repair:

$ codex app-server --analytics-default-enabled
failed to initialize sqlite state db: failed to initialize state runtime at /mnt/c/Users/<user>/.codex: migration 1 was previously applied but has been modified

After matching _sqlx_migrations checksums against a clean state created by the same installed codex-cli 0.131.0-alpha.9, app-server initialized successfully.

Then thread/list exposed the path mismatch. Before cwd repair, querying the current project returned no history:

{"id":2,"method":"thread/list","params":{"archived":false,"cwd":"C:\\Users\\<user>\\Documents\\codex-projects\\surfaceguru","limit":20,"useStateDbOnly":true,"sourceKinds":["vscode","cli","appServer","unknown"]}}

Response:

{"id":2,"result":{"data":[],"nextCursor":null,"backwardsCursor":null}}

Unfiltered results contained malformed cwd values:

/mnt/c/Users/<user>/Documents/codex-projects/surfaceguru/C:\Users\<user>\Documents\codex-projects\surfaceguru

After converting persisted cwd values in both state_5.sqlite and session JSONL session_meta / turn_context payloads from Windows paths to WSL paths, project-scoped thread/list returned the expected chats:

surface project: 4
network project: 4
burbflix project: 31
burbflix-iptv project: 4
burbfinity project: 1
playground: 1

Example successful query after repair:

{"id":2,"method":"thread/list","params":{"archived":false,"cwd":"/mnt/c/Users/<user>/Documents/codex-projects/surfaceguru","limit":20,"useStateDbOnly":true,"sourceKinds":["vscode","cli","exec","appServer","subAgent","subAgentReview","subAgentCompact","subAgentThreadSpawn","subAgentOther","unknown"]}}

Response contained the expected 4 project threads.

Expected behavior

Codex Desktop should preserve and display existing project chat history across updates and across Windows/WSL execution mode changes. Older rollout files containing Windows-style cwd values should still match project filters, or should be migrated automatically.

Actual behavior

Older project chats became invisible in the sidebar. The app-server could read the files but project filtering failed because persisted Windows paths were interpreted relative to the current WSL cwd instead of normalized/canonicalized.

Suggested fix areas

  • Add a startup/read-repair migration for persisted thread cwd values that canonicalizes Windows paths when running under WSL.
  • Normalize both the query-side cwd filter and stored cwd before exact matching in thread/list.
  • Avoid treating C:\... as a relative Unix path under WSL.
  • Add defensive handling when _sqlx_migrations checksum metadata changes across packaged Desktop/app-server builds, especially for state_5.sqlite and logs_2.sqlite.
  • Add a regression test with old Windows-style rollout cwd fields and WSL-style project cwd filters.

Privacy note

This report intentionally omits private chat content and uses redacted paths. The original local data contained normal project chat history; the important failure was path/state classification, not missing files.

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.

  • #23927
  • #23251
  • #24348
  • #23193
  • #24178

Powered by Codex Action

yui-stingray · 3 months ago

This looks consistent with a cwd normalization mismatch in the thread listing path. thread/list normalizes cwd filters with AbsolutePathBuf::relative_to_current_dir(...), so a legacy Windows-style cwd can be interpreted relative to the WSL cwd. When useStateDbOnly is used, the lookup then goes directly to exact threads.cwd matching and skips the scan-and-repair path that would normalize/backfill existing rows. That could make older sessions remain invisible until they are rewritten or repaired.

huajiexiewenfeng · 2 months ago

This looks very close to the local recovery case I ran into, especially the path/cwd migration angle.

I collected my recovery workflow here:

https://github.com/huajiexiewenfeng/codex-session-recovery

It is an unofficial workaround, not an upstream fix. The workflow dry-runs first, checks session_index.jsonl, .codex-global-state.json, SQLite threads.cwd, exact UI cwd matching, and Windows/WSL-style path mismatches such as \\?\ prefixes. It backs up files before writing and verifies the recovery afterward.

The part that may be relevant here is the cwd_exact_match_risk check: in some cases the data is still present locally, but the Desktop UI cannot find it because the stored thread cwd and the visible project root do not match as exact strings.

loertel · 1 month ago

Confirmed reverse-direction case: WSL → native Windows

I reproduced the same class of failure in the opposite migration direction and completed a successful local recovery.

Environment

  • Codex Desktop: 26.707.9981.0
  • Codex CLI/app-server: 0.139.0
  • Migration: WSL mode → native Windows mode
  • Shared Windows CODEX_HOME

Symptom

  • All saved projects still appeared in the Desktop sidebar, but every project section was empty.
  • All session JSONL files, session_index.jsonl, .codex-global-state.json, and SQLite thread rows still existed.
  • Resuming a known thread by ID worked, confirming that the conversation data was intact.
  • Duplicate project entries appeared where both \\wsl$\<distro>\... and \\wsl.localhost\<distro>\... had been saved.

Historical session metadata contained paths such as:

/home/<user>/<project>
/mnt/c/<workspace>/<project>

The native Windows thread catalog exposed unusable equivalents such as:

C:\home\<user>\<project>
C:\mnt\c\<workspace>\<project>

while the visible Desktop projects used:

\\wsl$\<distro>\home\<user>\<project>
C:\<workspace>\<project>

Important native-Windows detail

For JSONL metadata and Desktop project state, normal native paths were correct:

/home/<user>/...  ->  \\wsl$\<distro>\home\<user>\...
/mnt/c/...        ->  C:\...
/mnt/f/...        ->  F:\...

However, storing those same non-extended strings in state_5.sqlite.threads.cwd was not sufficient. A project-scoped request with useStateDbOnly: true still returned an empty result.

The state DB had to use the native Windows extended forms that the query-side path normalization compares against:

\\?\UNC\wsl$\<distro>\home\<user>\<project>
\\?\C:\<workspace>\<project>

After converting threads.cwd to those forms, the same project-scoped thread/list requests returned the expected threads immediately.

Recovery scope

With Codex fully stopped, the recovery updated these sources together:

  1. sessions/**/*.jsonl and archived_sessions/**/*.jsonl
  • payload.cwd in session_meta, turn_context, and other cwd-bearing records
  1. state_5.sqlite
  • threads.cwd, using native Windows extended paths
  1. sqlite/codex-dev.db
  • local_thread_catalog.cwd, using normal visible paths
  1. .codex-global-state.json and its backup
  • saved workspace roots
  • project order
  • active workspace roots
  • thread workspace hints
  • duplicate wsl$ / wsl.localhost project entries

Validation after restart

  • 276 session files remained present.
  • More than 310k JSONL records parsed successfully; zero malformed records.
  • Zero legacy /home/..., /mnt/<drive>/..., C:\home\..., or inconsistent \\?\... JSONL cwd values remained.
  • PRAGMA integrity_check returned ok for both SQLite databases.
  • All saved project roots existed and were unique.
  • Historical tasks reappeared under their projects after one Desktop restart.
  • Original backups remained byte-for-byte unchanged.

This strongly suggests the upstream fix should canonicalize both stored and query-side cwd values symmetrically in both migration directions, and migrate JSONL, state DB, catalog DB, and Desktop project state as one coordinated operation.

I also have a sanitized, dry-run-first reference migration script that creates per-file backups and validates JSONL/SQLite before reporting success. It contains no local usernames, project names, thread IDs, titles, or private paths.

<details>
<summary>Sanitized dry-run-first reference migration script</summary>

#!/usr/bin/env python3
"""Unofficial dry-run-first Codex cwd migration reference.

This is a sanitized reference for WSL -> native Windows migrations. It does
not delete sessions. Apply mode refuses to run while Codex.exe is active and
backs up every changed file before replacement.
"""

from __future__ import annotations

import argparse
import datetime as dt
import json
import os
from pathlib import Path
import re
import shutil
import sqlite3
import subprocess
import sys
from typing import Any


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser()
    parser.add_argument("--codex-home", type=Path, default=Path.home() / ".codex")
    parser.add_argument("--wsl-distro", required=True, help="Example: Ubuntu-24.04")
    parser.add_argument("--wsl-user", required=True, help="Linux username only")
    parser.add_argument("--backup-root", type=Path)
    parser.add_argument("--apply", action="store_true", help="Write changes; default is dry-run")
    return parser.parse_args()


def codex_is_running() -> bool:
    if os.name != "nt":
        return False
    result = subprocess.run(
        ["tasklist", "/FI", "IMAGENAME eq Codex.exe", "/FO", "CSV", "/NH"],
        capture_output=True,
        text=True,
        check=False,
    )
    return '"Codex.exe"' in result.stdout


class Migrator:
    def __init__(self, args: argparse.Namespace):
        self.args = args
        self.home = args.codex_home.resolve()
        self.wsl_root = rf"\\wsl$\{args.wsl_distro}\home\{args.wsl_user}"
        self.alias_roots = (
            rf"\\wsl$\{args.wsl_distro}\home\{args.wsl_user}",
            rf"\\wsl.localhost\{args.wsl_distro}\home\{args.wsl_user}",
            rf"C:\home\{args.wsl_user}",
        )
        self.canonical_roots: list[str] = []
        self.backup_dir: Path | None = None
        self.backed_up: set[Path] = set()
        self.stats: dict[str, Any] = {
            "mode": "apply" if args.apply else "dry-run",
            "jsonl_files": 0,
            "jsonl_records": 0,
            "jsonl_changed_files": 0,
            "jsonl_changed_records": 0,
            "invalid_jsonl_records": 0,
            "state_db_changed_rows": 0,
            "catalog_db_changed_rows": 0,
            "global_state_changed": 0,
        }

    def map_base(self, value: str) -> str:
        path = value
        if path.startswith("\\\\?\\UNC\\"):
            path = "\\\\" + path[8:]
        elif re.match(r"^\\\\\?\\[A-Za-z]:\\", path):
            path = path[4:]

        for prefix in self.alias_roots:
            if path.casefold() == prefix.casefold():
                return self.wsl_root
            if path.casefold().startswith(prefix.casefold() + "\\"):
                return self.wsl_root + path[len(prefix) :]

        linux_home = f"/home/{self.args.wsl_user}"
        if path == linux_home:
            return self.wsl_root
        if path.startswith(linux_home + "/"):
            return self.wsl_root + "\\" + path[len(linux_home) + 1 :].replace("/", "\\")

        match = re.match(r"^/mnt/([A-Za-z])(?:/(.*))?$", path)
        if match:
            drive = match.group(1).upper()
            suffix = (match.group(2) or "").replace("/", "\\")
            return f"{drive}:\\{suffix}" if suffix else f"{drive}:\\"

        match = re.match(r"^[Cc]:\\mnt\\([A-Za-z])(?:\\(.*))?$", path)
        if match:
            drive = match.group(1).upper()
            suffix = match.group(2) or ""
            return f"{drive}:\\{suffix}" if suffix else f"{drive}:\\"
        return path

    def map_visible(self, value: str) -> str:
        path = self.map_base(value)
        folded = path.casefold()
        for root in sorted(self.canonical_roots, key=len, reverse=True):
            root_folded = root.casefold()
            if folded == root_folded:
                return root
            if folded.startswith(root_folded + "\\"):
                return root + path[len(root) :]
        return path

    def map_state_db(self, value: str) -> str:
        path = self.map_visible(value)
        if path.startswith("\\\\?\\"):
            return path
        if path.startswith("\\\\"):
            return "\\\\?\\UNC\\" + path[2:]
        if re.match(r"^[A-Za-z]:\\", path):
            return "\\\\?\\" + path
        return path

    def prepare_roots(self) -> None:
        state_path = self.home / ".codex-global-state.json"
        if not state_path.exists():
            return
        state = json.loads(state_path.read_text(encoding="utf-8"))
        roots = state.get("electron-saved-workspace-roots", [])
        seen: set[str] = set()
        for value in roots:
            if not isinstance(value, str):
                continue
            mapped = self.map_base(value)
            if mapped.casefold() not in seen:
                seen.add(mapped.casefold())
                self.canonical_roots.append(mapped)

    def start_backup(self) -> None:
        if not self.args.apply:
            return
        root = self.args.backup_root or self.home.parent / "codex-migration-backups"
        stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
        self.backup_dir = root.resolve() / f"cwd-migration-{stamp}"
        self.backup_dir.mkdir(parents=True, exist_ok=False)

    def backup(self, path: Path) -> None:
        if not self.args.apply or not path.exists() or path in self.backed_up:
            return
        assert self.backup_dir is not None
        target = self.backup_dir / path.relative_to(self.home)
        target.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(path, target)
        self.backed_up.add(path)

    def migrate_jsonl(self) -> None:
        files = [
            path
            for folder_name in ("sessions", "archived_sessions")
            for path in (self.home / folder_name).rglob("*.jsonl")
        ]
        self.stats["jsonl_files"] = len(files)
        for path in files:
            changed = 0
            output: list[bytes] = []
            with path.open("rb") as source:
                for line in source:
                    if not line.strip():
                        output.append(line)
                        continue
                    try:
                        obj = json.loads(line)
                    except Exception:
                        self.stats["invalid_jsonl_records"] += 1
                        output.append(line)
                        continue
                    self.stats["jsonl_records"] += 1
                    payload = obj.get("payload")
                    cwd = payload.get("cwd") if isinstance(payload, dict) else None
                    mapped = self.map_visible(cwd) if isinstance(cwd, str) else cwd
                    if isinstance(cwd, str) and mapped != cwd:
                        changed += 1
                        payload["cwd"] = mapped
                        ending = b"\r\n" if line.endswith(b"\r\n") else b"\n"
                        line = json.dumps(obj, ensure_ascii=False, separators=(",", ":")).encode() + ending
                    output.append(line)
            if not changed:
                continue
            self.stats["jsonl_changed_files"] += 1
            self.stats["jsonl_changed_records"] += changed
            if self.args.apply:
                self.backup(path)
                temp = path.with_suffix(path.suffix + ".cwd-migration.tmp")
                with temp.open("wb") as target:
                    target.writelines(output)
                    target.flush()
                    os.fsync(target.fileno())
                os.replace(temp, path)

    def dedupe_mapped(self, values: list[Any]) -> list[Any]:
        result: list[Any] = []
        seen: set[str] = set()
        for value in values:
            mapped = self.map_visible(value) if isinstance(value, str) else value
            key = mapped.casefold() if isinstance(mapped, str) else json.dumps(mapped, sort_keys=True)
            if key not in seen:
                seen.add(key)
                result.append(mapped)
        return result

    def map_tree(self, value: Any) -> Any:
        if isinstance(value, str):
            return self.map_visible(value)
        if isinstance(value, list):
            return [self.map_tree(item) for item in value]
        if isinstance(value, dict):
            return {key: self.map_tree(item) for key, item in value.items()}
        return value

    def migrate_global_state_file(self, path: Path) -> None:
        if not path.exists():
            return
        state = json.loads(path.read_text(encoding="utf-8"))
        original = json.dumps(state, ensure_ascii=False, separators=(",", ":"))
        for key in ("electron-saved-workspace-roots", "project-order"):
            if isinstance(state.get(key), list):
                state[key] = self.dedupe_mapped(state[key])
        for key in (
            "active-workspace-roots",
            "thread-workspace-root-hints",
            "thread-writable-roots",
            "thread-projectless-output-directories",
        ):
            if key in state:
                state[key] = self.map_tree(state[key])
        updated = json.dumps(state, ensure_ascii=False, separators=(",", ":"))
        if updated == original:
            return
        self.stats["global_state_changed"] += 1
        if self.args.apply:
            self.backup(path)
            temp = path.with_suffix(path.suffix + ".cwd-migration.tmp")
            temp.write_text(updated, encoding="utf-8")
            json.loads(temp.read_text(encoding="utf-8"))
            os.replace(temp, path)

    def migrate_db(self, path: Path, table: str, key_columns: tuple[str, ...], state_db: bool) -> int:
        if not path.exists():
            return 0
        if self.args.apply:
            self.backup(path)
            self.backup(Path(str(path) + "-wal"))
            self.backup(Path(str(path) + "-shm"))
        connection = sqlite3.connect(path)
        try:
            columns = ", ".join((*key_columns, "cwd"))
            rows = connection.execute(f"SELECT {columns} FROM {table}").fetchall()
            changes = []
            for row in rows:
                keys, cwd = row[:-1], row[-1]
                mapped = self.map_state_db(cwd) if state_db else self.map_visible(cwd)
                if mapped != cwd:
                    changes.append((mapped, *keys))
            if self.args.apply and changes:
                where = " AND ".join(f"{column}=?" for column in key_columns)
                with connection:
                    connection.executemany(f"UPDATE {table} SET cwd=? WHERE {where}", changes)
            integrity = connection.execute("PRAGMA integrity_check").fetchone()[0]
            if integrity != "ok":
                raise RuntimeError(f"SQLite integrity check failed for {path}: {integrity}")
            return len(changes)
        finally:
            connection.close()

    def run(self) -> dict[str, Any]:
        if not (self.home / "sessions").exists():
            raise RuntimeError(f"Not a Codex home: {self.home}")
        if self.args.apply and codex_is_running():
            raise RuntimeError("Codex.exe is running; close Codex before --apply")
        self.prepare_roots()
        self.start_backup()
        self.migrate_jsonl()
        for name in (".codex-global-state.json", ".codex-global-state.json.bak"):
            self.migrate_global_state_file(self.home / name)
        self.stats["state_db_changed_rows"] = self.migrate_db(
            self.home / "state_5.sqlite", "threads", ("id",), True
        )
        self.stats["catalog_db_changed_rows"] = self.migrate_db(
            self.home / "sqlite" / "codex-dev.db",
            "local_thread_catalog",
            ("host_id", "thread_id"),
            False,
        )
        self.stats["backup_dir"] = str(self.backup_dir) if self.backup_dir else None
        return self.stats


def main() -> int:
    args = parse_args()
    try:
        stats = Migrator(args).run()
    except Exception as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        return 1
    print(json.dumps(stats, indent=2))
    if stats["invalid_jsonl_records"]:
        return 2
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

</details>

TeaShaman-cyber · 1 month ago

Additional current-build datapoint (2026-07-22):\n\n- Codex Desktop Windows package: 26.715.9757.0\n- codex-cli: 0.144.5\n- app-server runs in Ubuntu 24.04 WSL with CODEX_HOME on the mounted Windows profile.\n- The thread row in state_5.sqlite keeps the correct canonical Windows cwd (\\?\\C:\\Users\\<user>\\...\\<project>).\n- Codex core turn logs also use the correct Windows cwd.\n- However, an emitted turn_context.cwd / workspace root was malformed by joining an app resources directory with the Windows path:\n /mnt/c/Program Files/WindowsApps/OpenAI.Codex_26.715.4045.0.../app/resources/C:\\Users\\<user>\\Documents\\Codex\n- The currently installed package is 26.715.9757.0, so the malformed context also retained an older package-version path.\n- In the same affected project, a valid repo-local .codex/hooks.json disappeared from the Hooks UI; the runtime later logged a legacy after-agent hook failure with Windows os error 206.\n\nThis looks like split path normalization across the thread registry, turn runtime, and workspace/hook discovery layers. Relative hook commands avoid the hardcoded project path but do not repair the malformed workspace root itself.

ofekron · 1 month ago

One additional angle worth checking is that project/thread visibility should probably be treated as a single backend-owned projection, not as separate path-normalized views in the sidebar, thread DB, JSONL/session metadata, and hook/workspace discovery layers.

I maintain Better Agent (https://github.com/ofekron/better-agent), and the failure mode I would guard against here is: one layer has a canonical Windows/WSL path while another layer stores a display/runtime path, then an update changes which representation the query path compares against. That makes the data intact but invisible.

For the upstream fix, I would expect a migration/repair path to record and compare at least:

  • original persisted cwd
  • normalized/canonical cwd
  • OS surface / WSL/native mode
  • thread DB cwd
  • JSONL/session cwd
  • workspace root used by sidebar filtering
  • hook/workspace discovery root
  • hidden-thread count before and after repair

That would make this class of issue easier to distinguish from actual session loss and should also catch the hook-discovery regression described above.