bug: Compaction can overwrite concurrent history writes and loop indefinitely

Resolved 💬 6 comments Opened Mar 8, 2026 by William17738 Closed Jun 29, 2026
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

Summary

The context compaction system has two correctness issues:

  1. Race condition: history is rebuilt from a stale snapshot then fully replaced, silently dropping any items recorded during the compaction window.
  2. No retry cap: mid-turn compaction can loop indefinitely if it fails to reduce token usage below the threshold.

Finding 1: Clone-then-replace race condition

Local compaction path

File: codex-rs/core/src/compact.rs

line 109:  history = sess.clone_history()              // snapshot ①
line 136:  drain_to_completed(sess, ...)                // model API call (2-10 seconds)
line 198:  history_snapshot = sess.clone_history()      // snapshot ②
line 230:  sess.replace_compacted_history(new_history)  // full replacement

Between snapshot ② (line 198) and replacement (line 230), any new items added to the session history (e.g., subagent notifications via inject_user_message_without_turn, or background events) are silently overwritten by replace_compacted_history.

Remote compaction path (wider window)

File: codex-rs/core/src/compact_remote.rs

line  93:  history = sess.clone_history()                       // snapshot
line 124:  compact_conversation_history(...)                     // remote API call (2-10+ seconds)
line 165:  sess.replace_compacted_history(new_history)           // full replacement

The remote API call adds significantly more latency, making the race window wider.

The replacement is not atomic with persistence

File: codex-rs/core/src/codex.rs, lines 2850-2865

pub(crate) async fn replace_compacted_history(...) {
    self.replace_history(items, ...).await;                    // step 1: replace in memory
    self.persist_rollout_items(&[Compacted(...)]).await;        // step 2: persist compaction
    self.persist_rollout_items(&[TurnContext(...)]).await;      // step 3: persist context
}

A crash between steps leaves memory and disk out of sync.

Concurrent write sources during compaction

  • codex-rs/core/src/agent/control.rs:421 — completion watcher injects subagent notification
  • codex-rs/core/src/codex_thread.rs:103inject_user_message_without_turn
  • codex-rs/core/src/codex.rs:3222notify_background_event

Finding 2: Mid-turn compaction infinite loop

File: codex-rs/core/src/codex.rs, line 4951

The run_turn function (line 4869) is a loop. When mid-turn compaction triggers and a tool follow-up continues, there is no maximum compaction attempt counter. The code comment acknowledges this:

"as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop."

If compaction produces a summary nearly as large as the original (e.g., dense conversation with many tool results), the loop will trigger compaction on every subsequent iteration indefinitely.

Secondary note

File: codex-rs/core/src/codex.rs, line 4720 — an existing TODO acknowledges that pre-turn compaction does not account for the upcoming user message size, which can cause context-window-exceeded errors immediately after compaction.

File: codex-rs/core/src/codex.rs, lines 3099-3130 — recompute_token_usage after compaction zeroes out input_tokens, cached_input_tokens, and output_tokens, filling only total_tokens via byte estimation. This degrades subsequent auto-compact threshold accuracy.

Suggested improvements

  1. Add a version counter or generation ID to session history; replace_compacted_history should fail-and-retry (or merge) if the generation has advanced since the snapshot.
  2. Add a max_compact_attempts counter to the mid-turn loop to prevent unbounded retries.
  3. Make replacement + persistence a single atomic operation (or at least log a warning on partial failure).

I have a fix ready and can submit a PR if invited.

View original on GitHub ↗

6 Comments

github-actions[bot] contributor · 4 months ago

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

  • #13279

Powered by Codex Action

Lordanakun · 4 months ago

I traced both issues and have a proposed fix. Sharing here since external PRs are restricted.

Root cause analysis

Finding 1: Race condition in compact.rs and compact_remote.rs

The window between the final snapshot and replace_compacted_history() is larger than just snapshot ②→replacement — the compaction task itself takes 2–10 seconds (model API call). Any items appended during that entire window (e.g. subagent notifications via inject_user_message_without_turn, background events) are silently dropped.

Fix: take one more snapshot immediately before calling replace_compacted_history() and merge any items that appeared since the original snapshot:

// compact.rs (same pattern applies to compact_remote.rs)
// add before the CompactedItem { ... } block
let latest_history_snapshot = sess.clone_history().await;
let latest_history_items = latest_history_snapshot.raw_items();
if latest_history_items.len() > history_items.len() {
    new_history.extend_from_slice(&latest_history_items[history_items.len()..]);
}

This assumes append-only semantics during compaction (new items only grow the tail), which matches the current architecture.

Finding 2: Mid-turn compaction infinite loop in codex.rs

run_turn() retries compaction in a loop with no cap. If the summary itself is large enough to keep token usage above auto_compact_limit, the loop never exits.

Fix: add a counter and abort after 3 attempts:

// add before the mid-turn compaction block in run_turn()
let mut mid_turn_compaction_attempts: u32 = 0;

// inside the if token_limit_reached && needs_follow_up block:
mid_turn_compaction_attempts += 1;
if mid_turn_compaction_attempts > 3 {
    error!(
        turn_id = %turn_context.sub_id,
        mid_turn_compaction_attempts,
        "mid-turn compaction exceeded retry limit"
    );
    return None;
}

Working implementation

Full implementation with both fixes: https://github.com/Lordanakun/codex/commit/a460195acabbcaa7238ef886a1264c654e5544ef

3 files changed, 25 insertions. Happy to adjust the approach if maintainers prefer a different strategy (e.g. holding a write lock vs. the merge-on-replace approach).

Lordanakun · 4 months ago

Update: Just validated by building from source on Apple M4 (Rust 1.93.0 / aarch64). One borrow checker fix needed in compact_remote.rs — the temporary clone needs to be bound to a named variable:

// compact_remote.rs — corrected version
let history_clone = history.clone();
let history_items = history_clone.raw_items();
let ghost_snapshots: Vec<ResponseItem> = history_items
    .iter()
    .filter(|item| matches!(item, ResponseItem::GhostSnapshot { .. }))
    .cloned()
    .collect();

// ... (later, before replace_compacted_history)
let latest_history_snapshot = sess.clone_history().await;
let latest_history_items = latest_history_snapshot.raw_items();
if latest_history_items.len() > history_items.len() {
    new_history.extend_from_slice(&latest_history_items[history_items.len()..]);
}

The same split is not needed in compact.rs because that file already uses history_snapshot (a prior clone) rather than moving history directly.

Updated branch: https://github.com/Lordanakun/codex/tree/fix/compaction-race-and-infinite-loop

charley-oai contributor · 4 months ago

Hey there was a bug in the Responses API /compact endpoint that did not properly truncate replacement_history, causing compaction to barely reduce token % in longrunning conversations. This was causing lots of reports of compaction looping or happening very frequently. This should be fixed by tomorrow

However I think the 2 code issues you flagged here are probably still work fixing

charley-oai contributor · 4 months ago

https://github.com/openai/codex/pull/14563 would be the PR if we want to fix these edge cases

etraut-openai contributor · 21 days ago

Thanks for reporting this problem. Until recently, Codex used a separate endpoint and server-side logic for compaction. We recently switched to a more robust approach where the compaction logic is moved locally using the same endpoint as normal turns. This eliminates "remote compaction" errors and increases the stability and reliability of compaction operations. If you see further problems with compaction, please use /feedback and open a new bug report.