BigPizzaV3/CodexPlusPlus · error

.codex-global-state.json changed while deleting thread

Error message

.codex-global-state.json changed while deleting thread {thread_id}

What it means

When deleting a thread from .codex-global-state.json, the function re-reads the file after performing in-memory removal and bails if its bytes differ from the snapshot taken at the start, refusing to write. This optimistic-concurrency check prevents clobbering concurrent modifications (e.g. codex CLI updating sidebar state) with a stale snapshot.

Solutions

  1. Retry the deletion: re-read the file fresh and re-apply the removal to the new state.
  2. Close/pause the codex app or CLI that is concurrently writing .codex-global-state.json and retry.
  3. Serialize deletes through a single process (file lock or in-process mutex) to avoid racing writers.
  4. If the change is expected, refresh the snapshot and merge removals instead of failing.

Example fix

// before
if fs::read(&path)? != original_bytes {
    anyhow::bail!(".codex-global-state.json changed while deleting thread {thread_id}");
}
// after
let mut current_bytes = original_bytes.to_vec();
for _ in 0..3 {
    match try_delete_from_bytes(&current_bytes, thread_id) {
        Ok(result) => return Ok(result),
        Err(StaleState) => current_bytes = fs::read(&path)?, // re-read and retry
    }
}
Defensive patterns

Strategy: retry

Validate before calling

let before = fs::read(&global_state_path)?;
// ... perform delete ...
if fs::read(&global_state_path)? != before {
    eprintln!("global state is being modified concurrently; retry later");
}

Try / catch

for attempt in 0..3 {
    match delete_thread(codex_home, thread_id) {
        Ok(n) => break Ok(n),
        Err(e) if e.to_string().contains("changed while deleting thread") => {
            std::thread::sleep(Duration::from_millis(200 * (attempt + 1)));
        }
        Err(e) => break Err(e),
    }
}

Prevention

When it happens

Trigger: Calling the thread-deletion API while codex (CLI/app) concurrently writes .codex-global-state.json between the initial read and the write; another process (watcher, sync tool) touches the file during the delete; two manager instances deleting different threads at once.

Common situations: Running the manager app while codex TUI is open and recording new thread activity; backup agents or file watchers modifying global state; two undo/delete operations racing.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of BigPizzaV3/CodexPlusPlus@b1ed92e5e4 (2026-09-19). Data as JSON: /api/errors/a2a903847abab41e. Report an issue: GitHub.

Appendix: source

Thrown at crates/codex-plus-data/src/provider_sync.rs:3109

            .keys()
            .filter(|key| {
                *key == &client_id
                    || *key == &client_id_encoded
                    || *key == &reference_capability
                    || *key == &reference_capability_encoded
            })
            .cloned()
            .collect::<Vec<_>>();
        for key in keys {
            atom.remove(&key);
            removed += 1;
        }
    }
    if removed == 0 {
        return Ok(0);
    }
    if fs::read(&path)? != original_bytes {
        anyhow::bail!(".codex-global-state.json changed while deleting thread {thread_id}");
    }
    codex_plus_core::settings::atomic_write(&path, serde_json::to_string_pretty(&state)?.as_bytes())?;
    Ok(removed)
}

fn remove_thread_from_catalog_dbs(codex_home: &Path, thread_id: &str) -> anyhow::Result<usize> {
    let mut removed_total = 0usize;
    for path in codex_plus_core::codex_sqlite::codex_thread_reference_db_paths_from_home(codex_home) {
        if !path.exists() {
            continue;
        }
        let mut db = Connection::open(&path)?;
        db.busy_timeout(std::time::Duration::from_millis(500))?;
        let tx = db.transaction()?;
        let mut removed = 0usize;
        for table in [
            "local_thread_catalog",
            "thread_timeline_ledger",

View on GitHub (pinned to b1ed92e5e4)