Archived local sessions hidden and fail to resume after switching auth/provider

Resolved 💬 2 comments Opened Apr 24, 2026 by 1saifj Closed Apr 24, 2026
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

What happened

After switching local Codex configuration from an API/custom provider setup to ChatGPT login mode, old local chat history appeared to be gone. The underlying rollout JSONL files still existed, but direct resume failed for an old session ID:

codex resume 019d0250-8664-7713-bfde-19a0e1110b3f
Error: Failed to resume session from ~/.codex/archived_sessions/rollout-2026-03-18T21-58-44-019d0250-8664-7713-bfde-19a0e1110b3f.jsonl

The data was not deleted. The session file was valid JSONL and the state database passed integrity checks. The problem appears to be a local state/archive migration or compatibility issue: archived sessions are hidden from the normal history and direct codex resume <id> fails when the state row points at ~/.codex/archived_sessions/....

Environment

  • Codex CLI: codex-cli 0.124.0
  • Current auth mode: ChatGPT login
  • Old session metadata: cli_version=0.115.0-alpha.27, source=vscode, originator=Codex Desktop, model_provider=cliproxyapi, model=gpt-5.4
  • Current config uses ChatGPT login and a newer default model (gpt-5.5)

Local evidence

For the affected session, state_5.sqlite had a row like:

id=019d0250-8664-7713-bfde-19a0e1110b3f
rollout_path=~/.codex/archived_sessions/rollout-2026-03-18T21-58-44-019d0250-8664-7713-bfde-19a0e1110b3f.jsonl
archived=1
source=vscode
model_provider=cliproxyapi
cli_version=0.115.0-alpha.27
model=gpt-5.4

Checks performed:

jq empty < rollout.jsonl                                      # passed
sqlite3 ~/.codex/state_5.sqlite "pragma integrity_check;"     # ok
sqlite3 ~/.codex/logs_2.sqlite "pragma integrity_check;"      # ok

The affected install had many archived sessions in this state:

select archived, count(*) from threads group by archived;
0|1297
1|254

Expected behavior

Changing auth/provider configuration should not make local history appear lost. Archived local sessions should either:

  1. remain discoverable in history, or
  2. be recoverable via codex resume <id>, or
  3. produce a clear migration/recovery message instead of a generic failed-resume error.

Actual behavior

Old chats appear missing from history after the auth/provider change, and direct resume of a valid archived rollout fails with:

Error: Failed to resume session from ~/.codex/archived_sessions/rollout-...jsonl

Workaround found locally

A manual repair against a copied Codex home worked:

  1. Copy archived rollout files back into the normal ~/.codex/sessions/YYYY/MM/DD/ layout.
  2. Update each affected thread row in state_5.sqlite to point to that active sessions path.
  3. Set archived=0 and archived_at=NULL.

After doing this in a disposable Codex home copy, archived remaining became 0, DB integrity stayed ok, and codex resume --no-alt-screen 019d0250-8664-7713-bfde-19a0e1110b3f opened the old conversation successfully.

Why this seems like a Codex bug

The archived rollout files are still present and parse correctly. The failure is not data loss or JSONL corruption. It looks like newer Codex versions/login-mode history handling do not gracefully handle older Desktop/API-provider sessions that are marked archived and stored under archived_sessions.

A built-in migration, an unarchive all, or better resume fallback from archived rollouts would prevent users from thinking their chat history was deleted.

View original on GitHub ↗

2 Comments

github-actions[bot] contributor · 4 months ago

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

  • #19318
  • #19088
  • #19345

Powered by Codex Action

1saifj · 4 months ago

Adding the recovery script I used to validate the workaround. It restores archived local sessions by copying archived rollout JSONL files back into the normal sessions/YYYY/MM/DD/ layout and updating the matching state_5.sqlite rows.

It creates timestamped backups first, supports DRY_RUN=1, leaves archived_sessions untouched, and uses $HOME/CODEX_HOME instead of machine-specific paths.

#!/usr/bin/env bash
set -euo pipefail

CODEX_HOME_DIR="${CODEX_HOME:-${HOME}/.codex}"
STATE_DB="${CODEX_HOME_DIR}/state_5.sqlite"
SESSION_INDEX="${CODEX_HOME_DIR}/session_index.jsonl"
ARCHIVED_DIR="${CODEX_HOME_DIR}/archived_sessions"
DEFAULT_ARCHIVED_DIR="${HOME}/.codex/archived_sessions"
SESSIONS_DIR="${CODEX_HOME_DIR}/sessions"
BACKUP_ROOT="${HOME}/codex-recovered-sessions/all-archived-chats"
BACKUP_DIR="${BACKUP_ROOT}/backup-$(date +%Y%m%dT%H%M%S)"
DRY_RUN="${DRY_RUN:-0}"

if [[ ! -f "${STATE_DB}" ]]; then
  echo "Missing Codex state DB: ${STATE_DB}" >&2
  exit 1
fi

if [[ ! -d "${ARCHIVED_DIR}" ]]; then
  echo "Missing archived sessions directory: ${ARCHIVED_DIR}" >&2
  exit 1
fi

mkdir -p "${BACKUP_DIR}"

ARCHIVED_COUNT="$(sqlite3 "${STATE_DB}" "SELECT count(*) FROM threads WHERE archived = 1;")"

if [[ "${ARCHIVED_COUNT}" == "0" ]]; then
  echo "No archived Codex chats found."
  exit 0
fi

echo "Archived chats to restore: ${ARCHIVED_COUNT}"
echo "Backup directory: ${BACKUP_DIR}"

sqlite3 "${STATE_DB}" ".backup '${BACKUP_DIR}/state_5.sqlite.before'"

if [[ -f "${SESSION_INDEX}" ]]; then
  cp -p "${SESSION_INDEX}" "${BACKUP_DIR}/session_index.jsonl.before"
fi

sqlite3 -header -csv "${STATE_DB}" \
  "SELECT id, rollout_path, archived_at, title, cwd, model_provider, model, cli_version FROM threads WHERE archived = 1 ORDER BY created_at;" \
  > "${BACKUP_DIR}/archived_threads_before.csv"

if [[ "${DRY_RUN}" != "1" ]]; then
  probe_path="${SESSIONS_DIR}/.codex-restore-write-test-$$"

  if ! ( : > "${probe_path}" ) 2>/dev/null; then
    echo "Cannot write to ${SESSIONS_DIR}. Run this script from a normal local terminal, outside the Codex sandbox." >&2
    exit 1
  fi

  rm -f "${probe_path}"

  if ! sqlite3 "${STATE_DB}" "BEGIN IMMEDIATE; ROLLBACK;" >/dev/null 2>&1; then
    echo "Cannot write to ${STATE_DB}. Run this script from a normal local terminal, outside the Codex sandbox." >&2
    exit 1
  fi
fi

restore_one() {
  local thread_id="$1"
  local rollout_path="$2"
  local source_path="${rollout_path}"

  if [[ ! -f "${source_path}" ]]; then
    source_path="$(find "${ARCHIVED_DIR}" -maxdepth 1 -name "*-${thread_id}.jsonl" -print -quit)"
  fi

  if [[ -z "${source_path}" && "${ARCHIVED_DIR}" != "${DEFAULT_ARCHIVED_DIR}" && -d "${DEFAULT_ARCHIVED_DIR}" ]]; then
    source_path="$(find "${DEFAULT_ARCHIVED_DIR}" -maxdepth 1 -name "*-${thread_id}.jsonl" -print -quit)"
  fi

  if [[ -z "${source_path}" || ! -f "${source_path}" ]]; then
    echo "missing source: ${thread_id}" >&2
    return 2
  fi

  local filename
  filename="$(basename "${source_path}")"

  if [[ ! "${filename}" =~ ^rollout-([0-9]{4})-([0-9]{2})-([0-9]{2})T ]]; then
    echo "bad rollout filename: ${source_path}" >&2
    return 2
  fi

  local year="${BASH_REMATCH[1]}"
  local month="${BASH_REMATCH[2]}"
  local day="${BASH_REMATCH[3]}"
  local active_dir="${SESSIONS_DIR}/${year}/${month}/${day}"
  local active_path="${active_dir}/${filename}"

  if [[ "${DRY_RUN}" == "1" ]]; then
    echo "dry-run: ${thread_id} -> ${active_path}"
    return 0
  fi

  mkdir -p "${active_dir}"

  if [[ -e "${active_path}" ]]; then
    if ! cmp -s "${source_path}" "${active_path}"; then
      echo "destination conflict: ${active_path}" >&2
      return 2
    fi
  else
    cp -p "${source_path}" "${active_path}"
  fi

  local changed
  changed="$(
    sqlite3 "${STATE_DB}" <<SQL
.timeout 10000
UPDATE threads
SET rollout_path = '${active_path}',
    archived = 0,
    archived_at = NULL
WHERE id = '${thread_id}' AND archived = 1;
SELECT changes();
SQL
  )"

  if [[ "${changed}" != "1" ]]; then
    echo "thread row not updated (${changed} rows): ${thread_id}" >&2
    return 2
  fi

  echo "restored: ${thread_id}"
  return 0
}

restored=0
failed=0

while IFS=$'\t' read -r thread_id rollout_path; do
  if restore_one "${thread_id}" "${rollout_path}"; then
    restored=$((restored + 1))
  else
    failed=$((failed + 1))
  fi
done < <(sqlite3 -separator $'\t' "${STATE_DB}" \
  "SELECT id, rollout_path FROM threads WHERE archived = 1 ORDER BY created_at;")

if [[ "${DRY_RUN}" == "1" ]]; then
  echo
  echo "Dry run complete. Planned restores: ${restored}; failed mappings: ${failed}."
  exit 0
fi

sqlite3 "${STATE_DB}" "PRAGMA wal_checkpoint(FULL);" >/dev/null

remaining="$(sqlite3 "${STATE_DB}" "SELECT count(*) FROM threads WHERE archived = 1;")"
integrity="$(sqlite3 "${STATE_DB}" "PRAGMA integrity_check;")"

echo
echo "Restored: ${restored}"
echo "Failed: ${failed}"
echo "Archived remaining: ${remaining}"
echo "State DB integrity: ${integrity}"
echo "Backup saved at: ${BACKUP_DIR}"

if [[ "${failed}" != "0" || "${remaining}" != "0" || "${integrity}" != "ok" ]]; then
  exit 1
fi

echo
echo "Verify with:"
echo "  codex resume --no-alt-screen 019d0250-8664-7713-bfde-19a0e1110b3f"