Skip to content

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

Open

Description

@ThicccRacer

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.

Activity

  1. added
    bugSomething isn't working
    appIssues related to the Codex desktop app
    windows-osIssues related to Codex on Windows systems
    app-serverIssues involving app server protocol or interfaces
    sessionIssues involving session (thread) management, resuming, forking, naming, archiving
    on May 24, 2026
  2. yui-stingray commented on May 24, 2026

    @yui-stingray

    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.

  3. huajiexiewenfeng commented on May 31, 2026

    @huajiexiewenfeng

    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.

  4. loertel commented on Jul 15, 2026

    @loertel

    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
    2. state_5.sqlite
      • threads.cwd, using native Windows extended paths
    3. sqlite/codex-dev.db
      • local_thread_catalog.cwd, using normal visible paths
    4. .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.

    Sanitized dry-run-first reference migration script
    #!/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())
  5. TeaShaman-cyber commented on Jul 22, 2026

    @TeaShaman-cyber

    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\\...\).\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\\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.

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 appapp-serverIssues involving app server protocol or interfacesbugSomething isn't workingsessionIssues involving session (thread) management, resuming, forking, naming, archivingwindows-osIssues related to Codex on Windows systems

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions