Skip to content

Session disappear. Codex trips over bad JSON history. #24425

Open

Description

@nialse

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.

Activity

  1. added
    CLIIssues related to the Codex CLI
    sessionIssues involving session (thread) management, resuming, forking, naming, archiving
    on May 25, 2026
  2. jshaofa-ui commented on May 25, 2026

    @jshaofa-ui

    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.

  3. yui-stingray commented on May 25, 2026

    @yui-stingray

    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.

  4. etraut-openai commented on May 25, 2026

    @etraut-openai
    Contributor

    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.

  5. nialse commented on May 25, 2026

    @nialse
    Author

    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.

  6. etraut-openai commented on May 25, 2026

    @etraut-openai
    Contributor

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

  7. nialse commented on May 25, 2026

    @nialse
    Author

    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="
    }
    }

  8. nialse commented on May 26, 2026

    @nialse
    Author

    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.

  9. added a commit that references this issue on May 26, 2026
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

    CLIIssues related to the Codex CLIbugSomething isn't workingsessionIssues involving session (thread) management, resuming, forking, naming, archiving

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions