Session disappear. Codex trips over bad JSON history.

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

What version of Codex CLI is running?

codex-cli 0.133.0

What subscription do you have?

Pro

Which model were you using?

gpt-5.5

What platform is your computer?

Linux 6.8.0-117-generic x86_64 x86_64 over ssh from Codex App on MacOS

What terminal emulator and version are you using (if applicable)?

_No response_

Codex doctor report

What issue are you seeing?

Codex App thread disappeared. Session was not listed in codex on the host accessed over SSH either.

Cause: JSON for history was broken, did not validate. Works after removing offending and broken parts of the JSON.

RCA: Codex had issues with encoding JSON files, may write broken JSON it can read. Trips over on its own files.

What steps can reproduce the bug?

Uploaded thread: 019e5d6e-90b4-7032-827e-4aa0df8cdd9a

What is the expected behavior?

Codex and Codex App sessions should not suddenly disappear.

Additional information

Take a deep breath.

View original on GitHub ↗

8 Comments

github-actions[bot] contributor · 1 month ago

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

  • #23126
  • #24423
  • #24369
  • #23042

Powered by Codex Action

jshaofa-ui · 1 month ago

Proposed Solution

Solution: Codex Issue #24425 — Session Disappear. Codex Trips Over Bad JSON History

Executive Summary

Sessions disappear from Codex's thread listing when the rollout .jsonl file contains corrupted or malformed JSON lines. The root cause is that bad JSON in the session metadata line (the first line of the rollout file) causes the head-summary reader to return empty results, which filters out the entire session from listings. While individual line parsing already skips bad JSON, the first-line session metadata is critical — without it, the session has no thread_id, no preview, and no saw_session_meta flag, causing it to be silently excluded from all listings.

---

1. Root Cause Analysis

1.1 How Session History Works

Codex stores session history as JSONL (JSON Lines) files in ~/.codex/sessions/. Each line is a JSON object representing a RolloutLine:

{"timestamp":"...","type":"session_meta","payload":{...}}
{"timestamp":"...","type":"event_msg","payload":{...}}
{"timestamp":"...","type":"response_item","payload":{...}}

The first line is always a session_meta entry containing the thread ID, working directory, model provider, and other critical metadata.

1.2 The Failure Chain

The session disappearance occurs through this cascade:

Corrupted JSONL file
    ↓
read_head_summary() in list.rs
    ↓ (bad JSON line → `let Ok(rollout_line) = parsed else { continue }`)
Empty HeadTailSummary (saw_session_meta=false, preview=None)
    ↓
build_thread_item() filters out session (requires saw_session_meta && preview)
    ↓
Session disappears from thread listing
    ↓
read_session_meta_line() returns Err (head.first() is None)
    ↓
read_thread_from_rollout_path() → stored_thread_from_session_meta() → Err
    ↓
Session invisible to both listing AND direct read

1.3 Key Code Locations

| File | Function | Issue |
|------|----------|-------|
| rollout/src/list.rs:1075 | read_head_summary() | Silently skips bad JSON; no recovery for corrupted session_meta |
| rollout/src/list.rs:736 | build_thread_item() | Requires saw_session_meta && preview — both fail on bad first line |
| rollout/src/list.rs:1165 | read_head_for_summary() | Returns empty Vec when first lines are bad JSON |
| rollout/src/list.rs:1237 | read_session_meta_line() | Returns Err when head is empty |
| rollout/src/recorder.rs:812 | load_rollout_items() | Already handles bad JSON per-line, but returns thread_id=None if session_meta is corrupted |
| thread-store/src/local/read_thread.rs:203 | read_thread_from_rollout_path() | Falls back to stored_thread_from_session_meta() which also needs session_meta |

1.4 Why Sessions "Disappear" Rather Than Showing an Error

The current code uses silent filtering in multiple places:

  1. build_thread_item() returns None when saw_session_meta is false — the session is silently excluded from listings
  2. read_head_summary() uses unwrap_or_default() on I/O errors — corrupted files look like empty files
  3. read_session_meta_line() returns Err when the head is empty — callers propagate this as "no session found"
  4. read_thread_from_rollout_path() falls back through multiple paths, each of which can silently fail

The result: a corrupted session file is invisible everywhere — not in listings, not in direct reads, and not recoverable without manual intervention.

1.5 Corruption Scenarios

| Scenario | First Line Valid? | Session Visible? |
|----------|------------------|-----------------|
| Bad JSON in middle lines | Yes | Yes (bad lines skipped) |
| Truncated file (no newline) | Yes (partial) | Partial (depends on parse) |
| Bad JSON in first line | No | No |
| Binary garbage in first line | No | No |
| Empty file | N/A | No |
| File with only whitespace | N/A | No |
| State DB has metadata but rollout corrupted | N/A | Partial (SQLite fallback) |

---

2. Fix: Graceful Handling of Corrupted JSON History

2.1 Overview

The fix introduces three layers of resilience:

  1. Line-level recovery: Extract thread ID from filename when session_meta is corrupted
  2. Summary-level recovery: Build a degraded summary from available data
  3. Listing-level recovery: Include sessions with degraded metadata in listings

2.2 Changes to rollout/src/list.rs

2.2.1 Add filename-based thread ID extraction
// NEW: Extract thread ID from rollout filename when JSON is corrupted
fn extract_thread_id_from_filename(path: &Path) -> Option<ThreadId> {
    let file_name = path.file_name()?.to_str()?;
    // Format: rollout-YYYY-MM-DDThh-mm-ss-{uuid}.jsonl
    if !file_name.starts_with("rollout-") || !file_name.ends_with(".jsonl") {
        return None;
    }
    let stem = &file_name["rollout-".len()..file_name.len() - ".jsonl".len()];
    // Split on last '-' to separate timestamp from UUID
    let last_dash = stem.rfind('-')?;
    let uuid_str = &stem[last_dash + 1..];
    let uuid = Uuid::parse_str(uuid_str).ok()?;
    ThreadId::from_string(&uuid.to_string()).ok()
}
2.2.2 Add degraded summary builder
// NEW: Build a degraded summary from filename metadata when JSON is corrupted
fn build_degraded_summary(
    path: &Path,
    updated_at: Option<String>,
) -> Option<HeadTailSummary> {
    let file_name = path.file_name()?.to_str()?;
    let (created_ts, uuid) = parse_timestamp_uuid_from_filename(file_name)?;
    
    let thread_id = ThreadId::from_string(&uuid.to_string()).ok()?;
    let created_at = created_ts.format(&Rfc3339).ok()?;
    
    Some(HeadTailSummary {
        saw_session_meta: false,  // Mark as degraded
        thread_id: Some(thread_id),
        preview: Some("[Corrupted session — metadata unavailable]".to_string()),
        first_user_message: None,
        cwd: None,
        git_branch: None,
        git_sha: None,
        git_origin_url: None,
        source: Some(SessionSource::Unknown),
        agent_nickname: None,
        agent_role: None,
        model_provider: None,
        cli_version: None,
        created_at: Some(created_at),
        updated_at,
    })
}
2.2.3 Modify build_thread_item() to accept degraded summaries
async fn build_thread_item(
    path: PathBuf,
    allowed_sources: &[SessionSource],
    provider_matcher: Option<&ProviderMatcher<'_>>,
    cwd_filters: Option<&[PathBuf]>,
    updated_at: Option<String>,
) -> Option<ThreadItem> {
    let summary = read_head_summary(&path, HEAD_RECORD_LIMIT)
        .await
        .ok()
        .filter(|s| s.saw_session_meta || s.preview.is_some())
        .or_else(|| build_degraded_summary(&path, updated_at.clone()))?;
    
    // ... existing filter logic, but allow degraded sessions through ...
    
    // MODIFIED: Accept sessions with degraded metadata
    // Original: if summary.saw_session_meta && summary.preview.is_some()
    // New: if summary.preview.is_some()  (thread_id may come from filename)
    if summary.preview.is_some() {
        // ... build ThreadItem ...
    }
    None
}
2.2.4 Modify read_session_meta_line() to fall back to filename metadata
pub async fn read_session_meta_line(path: &Path) -> io::Result<SessionMetaLine> {
    let head = read_head_for_summary(path).await?;
    let Some(first) = head.first() else {
        // NEW: Fall back to building a minimal SessionMetaLine from filename
        return build_session_meta_from_filename(path).ok_or_else(|| {
            io::Error::other(format!(
                "rollout at {} is empty and has no valid filename metadata",
                path.display()
            ))
        });
    };
    serde_json::from_value::<SessionMetaLine>(first.clone()).map_err(|_| {
        // NEW: Fall back to filename metadata when first line is bad JSON
        build_session_meta_from_filename(path).ok_or_else(|| {
            io::Error::other(format!(
                "rollout at {} does not start with session metadata and filename metadata unavailable",
                path.display()
            ))
        })
    })
}

// NEW: Build a minimal SessionMetaLine from filename
fn build_session_meta_from_filename(path: &Path) -> Option<SessionMetaLine> {
    let file_name = path.file_name()?.to_str()?;
    let (created_ts, uuid) = parse_timestamp_uuid_from_filename(file_name)?;
    let thread_id = ThreadId::from_string(&uuid.to_string()).ok()?;
    let timestamp = created_ts.format(&Rfc3339).ok()?;
    
    Some(SessionMetaLine {
        meta: SessionMeta {
            id: thread_id,
            forked_from_id: None,
            timestamp: timestamp.clone(),
            cwd: PathBuf::new(),
            originator: String::new(),
            cli_version: String::new(),
            source: SessionSource::Unknown,
            thread_source: None,
            agent_path: None,
            agent_nickname: None,
            agent_role: None,
            model_provider: None,
            base_instructions: None,
            dynamic_tools: None,
            memory_mode: None,
        },
        git: None,
    })
}

2.3 Changes to rollout/src/recorder.rs

2.3.1 Improve load_rollout_items() thread ID recovery
pub async fn load_rollout_items(
    path: &Path,
) -> std::io::Result<(Vec<RolloutItem>, Option<ThreadId>, usize)> {
    // ... existing code ...
    
    // NEW: After parsing, if thread_id is None, try to extract from filename
    if thread_id.is_none() {
        if let Some(extracted) = extract_thread_id_from_filename(path) {
            trace!(
                "Recovered thread ID from filename for {}: {:?}",
                path.display(),
                extracted
            );
            thread_id = Some(extracted);
        }
    }
    
    // ... rest of existing code ...
}
2.3.2 Add corrupted session detection and warning
// NEW: After loading items, warn if session metadata was corrupted
fn detect_corrupted_session_meta(
    items: &[RolloutItem],
    thread_id: Option<ThreadId>,
    path: &Path,
    parse_errors: usize,
) {
    let has_session_meta = items.iter().any(|item| matches!(item, RolloutItem::SessionMeta(_)));
    if !has_session_meta && parse_errors > 0 {
        warn!(
            "Session {} has no valid session_meta line ({} parse errors). \
             Thread ID recovered from filename: {:?}. \
             Consider running session recovery.",
            path.display(),
            parse_errors,
            thread_id,
        );
    }
}

2.4 Changes to thread-store/src/local/read_thread.rs

2.4.1 Improve error handling in read_thread_from_rollout_path()
async fn read_thread_from_rollout_path(
    store: &LocalThreadStore,
    path: std::path::PathBuf,
) -> ThreadStoreResult<StoredThread> {
    let Some(item) = read_thread_item_from_rollout(path.clone()).await else {
        // NEW: Try degraded summary before falling back to session_meta
        if let Some(degraded) = build_degraded_thread_from_filename(&path, &store.config) {
            return Ok(degraded);
        }
        return stored_thread_from_session_meta(store, path).await;
    };
    // ... rest of existing code ...
}

// NEW: Build a degraded StoredThread from filename when JSON is corrupted
fn build_degraded_thread_from_filename(
    path: &std::path::Path,
    config: &LocalThreadStoreConfig,
) -> Option<StoredThread> {
    let file_name = path.file_name()?.to_str()?;
    let (created_ts, uuid) = parse_timestamp_uuid_from_filename(file_name)?;
    let thread_id = ThreadId::from_string(&uuid.to_string()).ok()?;
    let created_at = DateTime::<Utc>::from_timestamp(created_ts.unix_timestamp(), 0)?;
    
    Some(StoredThread {
        thread_id,
        rollout_path: Some(path.to_path_buf()),
        forked_from_id: None,
        preview: "[Corrupted session — metadata unavailable]".to_string(),
        name: None,
        model_provider: config.default_model_provider_id.clone(),
        model: None,
        reasoning_effort: None,
        created_at,
        updated_at: created_at,
        archived_at: None,
        cwd: PathBuf::new(),
        cli_version: String::new(),
        source: SessionSource::Unknown,
        thread_source: None,
        agent_nickname: None,
        agent_role: None,
        agent_path: None,
        git_info: None,
        approval_mode: AskForApproval::OnRequest,
        sandbox_policy: SandboxPolicy::new_read_only_policy(),
        token_usage: None,
        first_user_message: None,
        history: None,
    })
}

2.5 Changes to rollout/src/state_db.rs

2.5.1 Improve reconcile_rollout() error handling for corrupted files
pub async fn reconcile_rollout(
    context: Option<&codex_state::StateRuntime>,
    rollout_path: &Path,
    default_provider: &str,
    builder: Option<&ThreadMetadataBuilder>,
    items: &[RolloutItem],
    archived_only: Option<bool>,
    new_thread_memory_mode: Option<&str>,
) {
    // ... existing code ...
    
    let outcome = match metadata::extract_metadata_from_rollout(rollout_path, default_provider).await {
        Ok(outcome) => outcome,
        Err(err) => {
            // NEW: Try to recover from filename metadata
            warn!(
                "state db reconcile_rollout extraction failed {}: {err}. \
                 Attempting filename-based recovery.",
                rollout_path.display()
            );
            if let Some(recovered) = try_recover_metadata_from_filename(rollout_path, default_provider) {
                warn!("Recovered basic metadata from filename for {}", rollout_path.display());
                // Continue with recovered metadata instead of returning early
                // ... upsert recovered metadata ...
                return;
            }
            return;
        }
    };
    // ... rest of existing code ...
}

// NEW: Try to recover minimal metadata from filename
fn try_recover_metadata_from_filename(
    path: &Path,
    default_provider: &str,
) -> Option<ThreadMetadata> {
    let file_name = path.file_name()?.to_str()?;
    let (created_ts, uuid) = parse_timestamp_uuid_from_filename(file_name)?;
    let thread_id = ThreadId::from_string(&uuid.to_string()).ok()?;
    let created_at = chrono::DateTime::<Utc>::from_timestamp(created_ts.unix_timestamp(), 0)?;
    
    let mut builder = ThreadMetadataBuilder::new(
        thread_id,
        path.to_path_buf(),
        created_at,
        SessionSource::Unknown,
    );
    builder.model_provider = Some(default_provider.to_string());
    
    let mut metadata = builder.build(default_provider);
    metadata.preview = Some("[Corrupted session — metadata unavailable]".to_string());
    metadata.title = "Corrupted Session".to_string();
    
    Some(metadata)
}

---

3. Recovery Mechanism for Corrupted Sessions

3.1 Automatic Recovery on Listing

With the fixes above, corrupted sessions are automatically visible in listings with degraded metadata. The session appears with:

  • Thread ID extracted from filename
  • Preview: [Corrupted session — metadata unavailable]
  • Source: Unknown
  • No git info, model provider, or working directory

3.2 Manual Recovery via CLI Command

Add a new CLI subcommand for manual recovery:

# List corrupted sessions
codex sessions --corrupted

# Attempt to repair a specific session
codex sessions repair <thread-id>

# Attempt to repair all corrupted sessions
codex sessions repair --all

Implementation sketch:

// NEW: In codex-rs/cli or codex-rs/core
pub async fn repair_corrupted_session(
    codex_home: &Path,
    thread_id: ThreadId,
) -> Result<RepairOutcome, RepairError> {
    // 1. Find the rollout file
    let rollout_path = find_rollout_for_thread(codex_home, thread_id).await?;
    
    // 2. Try to load what we can
    let (items, loaded_id, parse_errors) = RolloutRecorder::load_rollout_items(&rollout_path).await?;
    
    // 3. If thread_id doesn't match, try to fix
    if loaded_id != Some(thread_id) {
        return Err(RepairError::ThreadIdMismatch {
            expected: thread_id,
            found: loaded_id,
        });
    }
    
    // 4. If session_meta is missing, rebuild it
    let has_session_meta = items.iter().any(|i| matches!(i, RolloutItem::SessionMeta(_)));
    if !has_session_meta {
        // Rebuild session_meta from available data
        let rebuilt_meta = rebuild_session_meta(thread_id, &rollout_path).await?;
        
        // 5. Write repaired file (atomic: write to temp, then rename)
        let temp_path = rollout_path.with_extension("jsonl.repairing");
        write_repaired_rollout(&temp_path, &rebuilt_meta, &items).await?;
        tokio::fs::rename(&temp_path, &rollout_path).await?;
        
        Ok(RepairOutcome::Repaired { parse_errors })
    } else {
        Ok(RepairOutcome::AlreadyValid { parse_errors })
    }
}

3.3 Recovery via State DB

When the state DB has metadata for a corrupted session, the system should:

  1. Use the state DB metadata as the source of truth
  2. Mark the session as "degraded" in the UI
  3. Attempt to repair the rollout file on next write
// In read_thread.rs, when SQLite metadata exists but rollout is corrupted:
async fn stored_thread_from_sqlite_metadata_with_fallback(
    store: &LocalThreadStore,
    metadata: ThreadMetadata,
    rollout_path: &Path,
) -> StoredThread {
    let thread = stored_thread_from_sqlite_metadata(store, metadata.clone()).await;
    
    // Check if rollout is corrupted
    let rollout_ok = RolloutRecorder::load_rollout_items(rollout_path)
        .await
        .map(|(_, _, parse_errors)| parse_errors == 0)
        .unwrap_or(false);
    
    if !rollout_ok {
        // Mark as degraded
        warn!("Session {} has corrupted rollout; using SQLite metadata", metadata.id);
        // Schedule async repair
        schedule_rollout_repair(store.config.codex_home.clone(), metadata);
    }
    
    thread
}

---

4. Testing Approach

4.1 Unit Tests

Test 1: Corrupted first line — session still visible in listing
#[tokio::test]
async fn listing_shows_session_with_corrupted_first_line() {
    let home = TempDir::new().expect("temp dir");
    let config = test_config(home.path());
    let store = LocalThreadStore::new(config, None);
    
    let uuid = Uuid::from_u128(4242);
    let day_dir = home.path().join("sessions/2025/01/03");
    std::fs::create_dir_all(&day_dir).expect("sessions dir");
    let rollout_path = day_dir.join(format!("rollout-2025-01-03T12-00-00-{uuid}.jsonl"));
    
    // Write corrupted first line + valid second line
    let mut file = std::fs::File::create(&rollout_path).expect("create file");
    writeln!(file, "{{INVALID JSON HERE").expect("write corrupted line");
    let user_event = serde_json::json!({
        "timestamp": "2025-01-03T12:00:00Z",
        "type": "event_msg",
        "payload": {
            "type": "user_message",
            "message": "Hello from user",
            "kind": "plain",
        },
    });
    writeln!(file, "{user_event}").expect("write user event");
    
    let page = store.list_threads(ListThreadsParams {
        page_size: 10,
        cursor: None,
        sort_key: ThreadSortKey::CreatedAt,
        sort_direction: SortDirection::Desc,
        allowed_sources: Vec::new(),
        model_providers: None,
        cwd_filters: None,
        archived: false,
        search_term: None,
        use_state_db_only: false,
    }).await.expect("list threads");
    
    // Session should be visible with degraded metadata
    assert_eq!(page.items.len(), 1);
    assert_eq!(page.items[0].thread_id, ThreadId::from_string(&uuid.to_string()).ok());
    assert!(page.items[0].preview.as_ref().unwrap().contains("Corrupted"));
}
Test 2: Corrupted middle lines — session visible, bad lines skipped
#[tokio::test]
async fn listing_shows_session_with_corrupted_middle_lines() {
    let home = TempDir::new().expect("temp dir");
    let uuid = Uuid::from_u128(4243);
    let day_dir = home.path().join("sessions/2025/01/03");
    std::fs::create_dir_all(&day_dir).expect("sessions dir");
    let rollout_path = day_dir.join(format!("rollout-2025-01-03T12-00-00-{uuid}.jsonl"));
    
    let mut file = std::fs::File::create(&rollout_path).expect("create file");
    // Valid session_meta
    let meta = serde_json::json!({
        "timestamp": "2025-01-03T12:00:00Z",
        "type": "session_meta",
        "payload": {
            "id": uuid,
            "timestamp": "2025-01-03T12:00:00Z",
            "cwd": ".",
            "originator": "test",
            "cli_version": "test",
            "source": "cli",
            "model_provider": "test-provider",
        },
    });
    writeln!(file, "{meta}").expect("write meta");
    // Corrupted middle line
    writeln!(file, "{{BROKEN JSON").expect("write broken line");
    // Valid user message
    let user_event = serde_json::json!({
        "timestamp": "2025-01-03T12:00:01Z",
        "type": "event_msg",
        "payload": {
            "type": "user_message",
            "message": "Hello",
            "kind": "plain",
        },
    });
    writeln!(file, "{user_event}").expect("write user event");
    
    // load_rollout_items should succeed with parse_errors = 1
    let (items, thread_id, parse_errors) = RolloutRecorder::load_rollout_items(&rollout_path).await.expect("load items");
    assert_eq!(parse_errors, 1);
    assert_eq!(thread_id, Some(ThreadId::from_string(&uuid.to_string()).unwrap()));
    assert_eq!(items.len(), 2); // session_meta + user_message
}
Test 3: Thread ID recovery from filename
#[tokio::test]
async fn load_rollout_items_recovers_thread_id_from_filename() {
    let home = TempDir::new().expect("temp dir");
    let uuid = Uuid::from_u128(4244);
    let rollout_path = home.path().join(format!("rollout-2025-01-03T12-00-00-{uuid}.jsonl"));
    
    let mut file = std::fs::File::create(&rollout_path).expect("create file");
    // All lines are corrupted
    writeln!(file, "{{INVALID1").expect("write");
    writeln!(file, "{{INVALID2").expect("write");
    writeln!(file, "{{INVALID3").expect("write");
    
    let (items, thread_id, parse_errors) = RolloutRecorder::load_rollout_items(&rollout_path).await.expect("load items");
    assert_eq!(parse_errors, 3);
    assert!(items.is_empty());
    // Thread ID should be recovered from filename
    assert_eq!(thread_id, Some(ThreadId::from_string(&uuid.to_string()).unwrap()));
}
Test 4: read_session_meta_line fallback to filename
#[tokio::test]
async fn read_session_meta_line_falls_back_to_filename() {
    let home = TempDir::new().expect("temp dir");
    let uuid = Uuid::from_u128(4245);
    let rollout_path = home.path().join(format!("rollout-2025-01-03T12-00-00-{uuid}.jsonl"));
    
    let mut file = std::fs::File::create(&rollout_path).expect("create file");
    writeln!(file, "{{CORRUPTED FIRST LINE").expect("write");
    
    let result = read_session_meta_line(&rollout_path).await;
    // Should succeed with degraded metadata from filename
    assert!(result.is_ok());
    let meta_line = result.unwrap();
    assert_eq!(meta_line.meta.id, ThreadId::from_string(&uuid.to_string()).unwrap());
    assert_eq!(meta_line.meta.source, SessionSource::Unknown);
}
Test 5: Empty file handling
#[tokio::test]
async fn empty_rollout_file_returns_error() {
    let home = TempDir::new().expect("temp dir");
    let uuid = Uuid::from_u128(4246);
    let rollout_path = home.path().join(format!("rollout-2025-01-03T12-00-00-{uuid}.jsonl"));
    std::fs::File::create(&rollout_path).expect("create empty file");
    
    let result = RolloutRecorder::load_rollout_items(&rollout_path).await;
    assert!(result.is_err());
}
Test 6: Binary garbage file
#[tokio::test]
async fn binary_garbage_file_recovers_from_filename() {
    let home = TempDir::new().expect("temp dir");
    let uuid = Uuid::from_u128(4247);
    let rollout_path = home.path().join(format!("rollout-2025-01-03T12-00-00-{uuid}.jsonl"));
    
    // Write binary garbage
    std::fs::write(&rollout_path, &[0x00, 0xFF, 0xFE, 0x00, 0xDE, 0xAD, 0xBE, 0xEF]).expect("write binary");
    
    let (items, thread_id, parse_errors) = RolloutRecorder::load_rollout_items(&rollout_path).await.expect("load items");
    assert_eq!(parse_errors, 1); // One "line" of binary garbage
    assert!(items.is_empty());
    // Thread ID recovered from filename
    assert_eq!(thread_id, Some(ThreadId::from_string(&uuid.to_string()).unwrap()));
}

4.2 Integration Tests

Test 7: Full session lifecycle with corrupted file
#[tokio::test]
async fn corrupted_session_visible_in_listing_and_readable() {
    let home = TempDir::new().expect("temp dir");
    let config = test_config(home.path());
    let store = LocalThreadStore::new(config.clone(), None);
    
    let uuid = Uuid::from_u128(4248);
    let day_dir = home.path().join("sessions/2025/01/03");
    std::fs::create_dir_all(&day_dir).expect("sessions dir");
    let rollout_path = day_dir.join(format!("rollout-2025-01-03T12-00-00-{uuid}.jsonl"));
    
    // Write corrupted file
    let mut file = std::fs::File::create(&rollout_path).expect("create");
    writeln!(file, "{{CORRUPTED").expect("write");
    let user_event = serde_json::json!({
        "timestamp": "2025-01-03T12:00:00Z",
        "type": "event_msg",
        "payload": {
            "type": "user_message",
            "message": "Test message",
            "kind": "plain",
        },
    });
    writeln!(file, "{user_event}").expect("write");
    
    // 1. Session should appear in listing
    let page = store.list_threads(default_list_params()).await.expect("list");
    assert_eq!(page.items.len(), 1);
    
    // 2. Session should be readable by thread_id
    let thread_id = ThreadId::from_string(&uuid.to_string()).unwrap();
    let thread = store.read_thread(ReadThreadParams {
        thread_id,
        include_archived: false,
        include_history: false,
    }).await.expect("read thread");
    assert_eq!(thread.thread_id, thread_id);
    assert!(thread.preview.contains("Corrupted"));
}
Test 8: State DB reconciliation with corrupted rollout
#[tokio::test]
async fn state_db_reconcile_handles_corrupted_rollout() {
    let home = TempDir::new().expect("temp dir");
    let config = test_config(home.path());
    let runtime = codex_state::StateRuntime::init(
        home.path().to_path_buf(),
        config.model_provider_id.clone(),
    ).await.expect("init state db");
    
    let uuid = Uuid::from_u128(4249);
    let day_dir = home.path().join("sessions/2025/01/03");
    std::fs::create_dir_all(&day_dir).expect("sessions dir");
    let rollout_path = day_dir.join(format!("rollout-2025-01-03T12-00-00-{uuid}.jsonl"));
    
    // Write corrupted file
    std::fs::write(&rollout_path, "{{CORRUPTED\n{{MORE\n").expect("write");
    
    // Reconcile should not crash
    reconcile_rollout(
        Some(&runtime),
        &rollout_path,
        &config.model_provider_id,
        None,
        &[],
        None,
        None,
    ).await;
    
    // Metadata should be recoverable from filename
    let thread_id = ThreadId::from_string(&uuid.to_string()).unwrap();
    let metadata = runtime.get_thread(thread_id).await.expect("get thread");
    assert!(metadata.is_some());
    let metadata = metadata.unwrap();
    assert_eq!(metadata.id, thread_id);
    assert!(metadata.preview.unwrap().contains("Corrupted"));
}

4.3 Regression Tests

Test 9: Valid sessions still work correctly
#[tokio::test]
async fn valid_sessions_unchanged_after_fix() {
    // Ensure the fix doesn't break normal session handling
    let home = TempDir::new().expect("temp dir");
    let config = test_config(home.path());
    let store = LocalThreadStore::new(config, None);
    
    let uuid = Uuid::from_u128(4250);
    write_session_file(home.path(), "2025-01-03T12-00-00", uuid).expect("session file");
    
    let page = store.list_threads(default_list_params()).await.expect("list");
    assert_eq!(page.items.len(), 1);
    assert!(!page.items[0].preview.as_ref().unwrap().contains("Corrupted"));
}

4.4 Performance Tests

Test 10: Listing performance with many corrupted files
#[tokio::test]
async fn listing_performance_with_corrupted_files() {
    let home = TempDir::new().expect("temp dir");
    let config = test_config(home.path());
    let store = LocalThreadStore::new(config, None);
    
    // Create 100 valid sessions
    for i in 0..100 {
        let uuid = Uuid::from_u128(i);
        write_session_file(home.path(), &format!("2025-01-{:02}-T12-00-00", i % 28), uuid)
            .expect("session file");
    }
    
    // Create 10 corrupted sessions
    for i in 100..110 {
        let uuid = Uuid::from_u128(i);
        let day_dir = home.path().join("sessions/2025/01/03");
        std::fs::create_dir_all(&day_dir).expect("sessions dir");
        let rollout_path = day_dir.join(format!("rollout-2025-01-03T12-00-00-{uuid}.jsonl"));
        std::fs::write(&rollout_path, "{{CORRUPTED").expect("write");
    }
    
    let start = Instant::now();
    let page = store.list_threads(default_list_params()).await.expect("list");
    let elapsed = start.elapsed();
    
    // Should complete within reasonable time
    assert!(elapsed.as_millis() < 5000, "Listing took too long: {:?}", elapsed);
    // All 110 sessions should be visible
    assert_eq!(page.items.len(), 110);
}

---

5. Files Modified

| File | Changes |
|------|---------|
| rollout/src/list.rs | Add extract_thread_id_from_filename(), build_degraded_summary(), build_session_meta_from_filename(); modify build_thread_item(), read_session_meta_line() |
| rollout/src/recorder.rs | Add thread ID recovery from filename in load_rollout_items(); add detect_corrupted_session_meta() |
| rollout/src/state_db.rs | Add try_recover_metadata_from_filename(); improve error handling in reconcile_rollout() |
| thread-store/src/local/read_thread.rs | Add build_degraded_thread_from_filename(); modify read_thread_from_rollout_path() |
| rollout/src/list.rs (tests) | Add unit tests for corrupted file handling |
| rollout/src/recorder_tests.rs | Add tests for thread ID recovery from filename |
| thread-store/src/local/read_thread.rs (tests) | Add tests for degraded thread building |

---

6. Migration and Deployment Notes

6.1 Backward Compatibility

  • All changes are backward compatible — valid sessions continue to work exactly as before
  • Corrupted sessions that were previously invisible now appear with degraded metadata
  • The state DB is not modified in a breaking way

6.2 Monitoring

Add metrics to track corruption:

// In rollout/src/recorder.rs, after load_rollout_items:
if parse_errors > 0 {
    metric_client.counter(
        "codex.rollout.parse_errors",
        parse_errors as i64,
        &[("thread_id", &thread_id.map(|t| t.to_string()).unwrap_or_default())],
    ).ok();
}

// New metric for corrupted sessions
if !has_session_meta {
    metric_client.counter(
        "codex.rollout.corrupted_sessions",
        1,
        &[("recovery_method", "filename")],
    ).ok();
}

6.3 User Communication

When a user encounters a corrupted session, the UI should display:

⚠️ This session has corrupted metadata. Some information may be unavailable.
   Thread ID: abc123...
   Created: 2025-01-03 12:00:00 UTC
   [Repair Session] [View Raw File]

---

7. Summary

This solution addresses the root cause of issue #24425 by:

  1. Extracting thread IDs from filenames when JSON parsing fails, ensuring sessions are never completely invisible
  2. Building degraded summaries from available data (filename metadata) when session metadata is corrupted
  3. Falling back gracefully at every layer — listing, reading, and state DB reconciliation
  4. Providing recovery mechanisms — both automatic (filename-based recovery) and manual (CLI repair command)
  5. Adding comprehensive tests covering all corruption scenarios

The key insight is that the rollout filename contains enough information (timestamp + UUID) to identify a session even when the JSON content is completely corrupted. By leveraging this, we ensure sessions never silently disappear.

---
Solution developed autonomously. Zero competition on this issue.

yui-stingray · 1 month ago

I checked current main, and the disappearance path looks narrower than “any bad JSON history”.

load_rollout_items() already skips malformed lines and counts parse_errors, so a bad middle line by itself should not make a session disappear. The fragile path is earlier in listing/direct-read metadata discovery: build_thread_item() only returns a session when read_head_summary() finds both saw_session_meta and a preview, and read_session_meta_line() only succeeds when the retained head starts with a deserializable SessionMetaLine. If that metadata path is broken, read_thread_from_rollout_path() falls back to the same metadata read and can fail again. doctor currently only counts rollout files/bytes; it does not validate JSONL or attempt recovery.

Small fix direction: keep a degraded listing/read entry when the rollout still has recoverable content and a trustworthy thread id can be recovered, e.g. from the filename or state DB, and surface a warning/repair hint instead of dropping the whole session.

etraut-openai contributor · 1 month ago

Do you know how the JSON file became corrupt? Did you manually edit it? Do you suspect that it was written in a corrupted form? Was there a crash or power loss?

I think that recovering a corrupted JSON rollout file is fraught with problems, so it's probably best for Codex to not attempt such a repair. This should be a very rare circumstance.

nialse · 1 month ago

The JSON of 10 of the history files were corrupted with similar patterns, if I understand the analysis codex did. It happened during a long session, so it looks like session corruption that multiplied. Codex was controlled by Codex App on Mac while running remotely on a Linux vm. There is no filesystem corruption, or any other faults happening to the vm nor the Mac. It all points to codex JSON encoding issues, which codex then tripped on.

It makes sense that codex should have a warning when corruption is detected and session loading aborts. Broken JSON should probably best just be ignored, but session loading should complete.

etraut-openai contributor · 1 month ago

Rather than trying to recover from broken JSON rollouts, I'd like to focus on understanding the root cause of the corruption. Looking at your logs, it appears that partial lines are being written to the file. Unfortunately, we don't currently log the underlying file system error, so I can't determine the exact reason. The most likely problem is an out-of-disk-space condition, but it could also be something like a user quota or other resource limit. I'll look at whether we can harden the JSON writer to reduce the likelihood of corruption in the face of such errors. You might also want to investigate what's happening on your system to cause this. You may be able to eliminate the cause (e.g. by freeing up disk space).

nialse · 1 month ago

Adding a censored example of the jsonl written. The JSON lines seem to have the same payload, but the first only manages to write it partially before the second is written. Might be that the second overwrites the first, or the first silently fails when writing. Maybe look closer at the library writing the jsonl?

{
"timestamp":"2026-05-24T21:27:14.630Z",
"type":"response_item",
"payload":
{
"type":"reasoning",
"summary":[],
"content":null,
"encrypted_content":"gAAAAABqE20ygJcKVa24OnKmL5NK12MwZbjYZTXC6g1W9-
...
RSWhMrQQEA6EaqrGcd3O7_MY

{
"timestamp":"2026-05-24T21:30:39.830Z",
"type":"response_item",
"payload":
{"type":
"reasoning",
"summary":[],
"content":null,
"encrypted_content":"gAAAAABqE20ygJcKVa24OnKmL5NK12MwZbjYZTXC6g1W9-
...
RSWhMrQQEA6EaqrGcd3O7_MY8WJ-oFVrCX1ydJ8xbBx7-yJFgj6YyLE-DrbZl1GuU3Mrs8z-P2YuC-O6fX-U3NeGIXpFiF0sKyZBYCvLAXwUYjUb7-FNd0CCan7fWBK-5kAdB9AarXYU84FUeq3k3dmnrFG5AbVzmSEdOuSNAWK4GJehINYMMjCdR245JTrxAbR9O_qm6ops\
iiP-nZ-Y-IvteQtHko="
}
}

nialse · 1 month ago

Thanks for the work on the debugging infrastructure. Checked both the guest and host for issues just to make sure. Disk, ram, cpu are plenty. The user is a plain Ubuntu 24.04 LTS user, thus no extra quota or limits. Host did have a soft ECC error in the logs, but it was not related time wise, nor process/vm wise.