TUI /resume intermittently forks the selected session into a new thread ID

Open 💬 3 comments Opened Jul 23, 2026 by siyunye
💡 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.145.0

One affected session was originally created with 0.143.0, but the issue also reproduced when both the parent and child were created with 0.145.0.

What subscription do you have?

ChatGPT subscription; the exact tier is not exposed by the local diagnostics used for this report.

Which model were you using?

gpt-5.6-sol

What platform is your computer?

Ubuntu 20.04, Linux x86_64.

What terminal emulator and version are you using?

GNOME Terminal; exact version unknown.

What issue are you seeing?

The TUI /resume command intermittently creates a new thread ID instead of resuming the selected thread in place.

This does not happen on every resume. I observed it twice on 2026-07-23, but I do not yet have a deterministic trigger.

When the issue occurs:

  1. I type /resume in the Codex TUI.
  2. I select an existing CLI session.
  3. The selected conversation opens with its previous history.
  4. Codex also creates a new rollout file with a new session ID.
  5. The new rollout's session_meta contains forked_from_id pointing to the selected session.
  6. Both the original and the new session remain visible in the /resume picker with nearly identical content.

This makes /resume behave like /fork even though I did not request a fork.

Persisted-state evidence

I inspected state_5.sqlite and the affected rollout files using read-only queries. No transcript contents, full session IDs, account identifiers, or project paths are included in this report.

Three affected entries form this chain:

| Role | Created | CLI version | Source | forked_from_id |
|---|---|---:|---|---|
| Original (019f4283…) | 2026-07-08 | 0.143.0 | cli, thread_source=user | absent |
| Unexpected child 1 (019f8973…) | 2026-07-22 | 0.145.0 | cli, thread_source=user | original ID |
| Unexpected child 2 (019f8e63…) | 2026-07-23 | 0.145.0 | cli, thread_source=user | unexpected child 1 ID |

The three session IDs are different, but their persisted first_user_message values are exactly equal, and each has a valid, distinct rollout file.

The relevant session_meta shape is:

{
  "originator": "codex-tui",
  "source": "cli",
  "thread_source": "user",
  "cli_version": "0.145.0",
  "forked_from_id": "<previous session id>"
}

codex doctor --json reports:

state DB integrity: ok
rollout DB duplicate DB paths: 0
rollout DB duplicate rollout thread ids: 0
rollout DB scan errors: 0

The doctor report also contains two unrelated stale rows, but neither stale row belongs to the affected chain. Every affected session has a valid rollout file.

Internal Guardian/subagent threads also exist in the inventory, but these three affected entries are ordinary cli/user threads, not Guardian or subagent sessions.

What steps can reproduce the bug?

The issue is intermittent.

Observed workflow:

  1. Start Codex TUI inside a project.
  2. Work in a normal CLI session and exit or switch away from it.
  3. Later, type /resume.
  4. Select that existing session.
  5. Continue using the restored conversation.
  6. Open /resume again and inspect the session list.
  7. Sometimes a second session with the same history is present.
  8. Inspecting its rollout shows a new ID and forked_from_id pointing to the selected session.

Repeating this can produce a chain in which each unexpected child points to the previous child.

Not every /resume produces a new ID, so there may be a race, loaded/unloaded thread-state distinction, app-server state difference, or another conditional path deciding between thread/resume and thread/fork. This is a hypothesis, not a confirmed root cause.

What is the expected behavior?

/resume should continue the selected thread using its existing thread ID.

Only an explicit /fork or codex fork operation should create a new thread ID with forked_from_id.

At minimum:

  • /resume should consistently use the resume path;
  • it should not create a new top-level session unexpectedly;
  • the resume picker should not accumulate multiple copies of the same logical conversation;
  • if persistence requires an internal continuation record, the UI should represent the chain as one logical session rather than multiple independent entries.

Additional information

Possibly related issues:

  • #29531 — resume creates new date-based rollout files containing duplicated historical context
  • #22593 — forked sessions store forked_from_id and copy parent history
  • #19267 — intermittent duplicate same-title thread records with distinct IDs and rollout files

None appears to cover the exact combination observed here: Codex CLI/TUI, /resume, intermittent behavior, and an unexpected forked_from_id chain.

I can provide a redacted codex doctor --json report and sanitized session_meta records if maintainers need them. I will not post transcript contents publicly.

View original on GitHub ↗

3 Comments

github-actions[bot] contributor · 1 month ago

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

  • #34180

Powered by Codex Action

bbingz · 1 month ago

Analysis (community)

Thanks for the careful forensic write-up — the forked_from_id chain + identical first_user_message + clean doctor integrity is much more useful than a pure “duplicate showed up” report.

I tried to map the report onto current main (symbols as anchors; line numbers drift). Static reading only; I have not hit a deterministic local repro. Confidence is medium: the persisted artifacts strongly match a fork CREATE path, while TUI /resume itself appears to call only resume.

  • Environment / versions (from issue): codex-cli 0.145.0, Ubuntu 20.04 x86_64, model gpt-5.6-sol; one parent created under 0.143.0, but a same-version parent→child was also observed under 0.145.0.
  • Repro status: partial / not independently reproduced. Issue reports intermittent (twice). No deterministic trigger yet. Forensic evidence is strong; causal path is still multi-hypothesis.
  • Observed vs expected:
  • Observed: after selecting a session in /resume, a new rollout/thread appears with session_meta.forked_from_id = selected (or prior unexpected child), originator=codex-tui, source=cli, thread_source=user, same first_user_message; chain can grow Original → child1 → child2.
  • Expected: /resume continues the selected thread ID in place; only explicit /fork / thread/fork (or other intentionally documented fork UX) should mint a new user-thread ID with lineage.

Root-cause hypothesis

What the artifacts match (high confidence on mechanism class)

In core session construction (codex-rs/core/src/session/session.rs):

  • InitialHistory::Resumed reuses resumed_history.conversation_id and opens via LiveThread resume params.
  • InitialHistory::Forked / New / Cleared mint ThreadId::default() and go through LiveThread::create with forked_from_id taken from session_configuration.forked_from_thread_id or initial_history.forked_from_id().

InitialHistory::forked_from_id() (codex-rs/protocol/src/protocol.rs) is decisive for the report shape:

  • Forked: uses parent SessionMeta.id → new child with forked_from_id = parent.
  • Resumed: only propagates an already-stored meta.forked_from_id from history; it does not invent a new parent link for a fresh UUID.

So a new rollout path + new UUID + new forked_from_id pointing at the selected session is the signature of a fork create, not of normal id-preserving resume.

What TUI /resume actually does (does not call fork)

Call chain (current tree):

  1. SlashCommand::ResumeAppEvent::OpenResumePickertui/src/chatwidget/slash_dispatch.rs
  2. Picker resume action → resume_target_sessiontui/src/app/event_dispatch.rstui/src/app/session_lifecycle.rs
  3. resume_target_sessionapp_server.resume_thread(...) only (no fork_thread / fork_thread_at)
  4. AppServerSession::resume_threadClientRequest::ThreadResume with thread_resume_params_from_configtui/src/app_server_session.rs
  5. Params set thread_id + config overrides (cwd/sandbox/approval/model…); no history / path payload (..ThreadResumeParams::default())

On the server (app-server/.../thread_processor.rs):

  • thread_resume_inner without history loads the stored thread and builds InitialHistory::Resumed via load_resume_initial_history_from_stored_threadstored_thread_to_initial_history (conversation_id: stored thread_id).
  • That path should keep identity and not create a new forked_from_id lineage.

Therefore the intermittent user-visible fork is unlikely to be “TUI resume maps to fork” in the happy path. More likely:

| Hypothesis | Fit to artifacts | Notes |
|---|---|---|
| (A) Post-resume fork the user attributes to /resume | Strong | Safety-buffer retry explicitly fork_thread_at (tui/src/app/safety_buffering.rsAppServerSession::fork_thread_at). Esc-Esc prompt edit emits ForkSessionForPromptEdit; /forkForkCurrentSession (event_dispatch.rs / slash_dispatch.rs). All produce real user-thread forks with lineage. |
| (B) Silent mis-route into fork / Forked under some loaded-idle / cold-resume condition | Weaker / unproven in source | Would need a log showing thread/fork (or resume returning a new id) at the bad moment. |
| (C) resume_thread_from_history footgun | Real code smell, weaker match for this report | If params.history is set, resume_thread_from_history returns InitialHistory::Forked of ResponseItems only — new ID under the resume RPC name. Stock TUI does not pass history today. History-only forks also usually lack SessionMeta, so forked_from_id() may be empty unless another path sets forked_from_thread_id — less perfect match for the reported chain. |

(A) best explains “resume opened history, later picker shows clones” if a safety-buffer auto-fork (or prompt-edit / explicit fork) happened between resumes and the picker lists intentional forks next to the parent. Chain growth fits always selecting the latest row.

I am not claiming (A) is proven without a single local log/trace from a bad transition.

High-level fix outline (not a PR)

Keeping this small and aligned with “resume ≠ fork”; no change to intentional /fork semantics, rollout format, or listing of deliberate forks unless the team wants clearer labeling.

  1. Hard invariant on non-history thread/resume: response.thread.id must equal requested thread_id. On mismatch, return invalid_request / internal_error instead of attaching a new lineage under resume.
  2. Clarify resume_thread_from_history: either construct true InitialHistory::Resumed with an explicit conversation_id if history-resume must keep identity, or stop calling it “resume” and require thread/fork for copy-on-write. Silent Forked-under-resume is a footgun even if not this bug’s main path.
  3. Telemetry when LiveThread::create runs with forked_from_id for originator=codex-tui / thread_source=user: include call source (slash_fork, safety_buffer, prompt_edit, thread_fork RPC, history_resume, etc.). That turns the next intermittent into a one-line root cause.
  4. Safety-buffer auto-forks: mark so the resume picker de-duplicates/labels them, or prefer in-place retry when safe so inventories do not accumulate clones. Explicit /fork stays unchanged.
  5. Optional defensive TUI check after resume_thread: if started.session.thread_id != target_session.thread_id (or new thread’s forked_from_id == target), show an error and do not replace the primary widget.

Non-goals: broad session lifecycle rewrite; changing intentional fork UX; picker listing of deliberate forks beyond labeling/de-dupe of automatic forks.

Test ideas

Failing-first integration (prefer patterns in app-server/tests/suite/v2/thread_resume.rs and tui app/tests/session_lifecycle_requests.rs):

  1. Start a user thread, materialize one turn, thread/resume by id with typical TUI overrides → assert response.thread.id == source id, lineage unchanged, no new rollout UUID for that logical thread.
  2. Resume N times in one process after unsubscribe-without-unload (loaded-idle + override-mismatch path if still relevant) → still same id; inventory count of threads with that first_user_message stays 1.
  3. If history-on-resume remains supported: assert either same-id Resumed semantics or an explicit error / separate API — must not silently create forked_from_id lineage under thread/resume.
  4. Safety-buffer retry: assert fork is intentional (new id + forked_from_id) and either labeled/hidden from the resume picker or clearly distinct from resume.
  5. Unit: InitialHistory::Forked(with SessionMeta) sets forked_from_id to parent id; InitialHistory::Resumed never takes the create path.

Questions for maintainers

Happy to be corrected on any of this — especially if resume is supposed to copy-on-write under some override condition I missed.

  1. At the moment a bad child appears, did app-server handle thread/resume, thread/fork, or both? One local log/trace from a repro would split (A) vs (B) cleanly.
  2. Between resume and re-opening the picker, was there a model safety-buffer retry, Esc-Esc edit, /fork, or /side?
  3. Was the selected row already a prior unexpected child (chain growth by always picking latest)?
  4. Is history ever non-null on thread/resume from this client build (custom client, remote app-server, binary drift)?
  5. After a bad resume, does the UI-attached thread_id equal the new child or the original? (silent fork-under-resume vs post-resume fork left as sibling/primary.)

If useful I can refine this analysis further (e.g. narrow which TUI events always mint user-thread forks). I am not opening a PR; if the team wants an invited change later after approach alignment, I would be glad to help under that invitation — no rush and no expectation.

bbingz · 1 month ago

Building on the root-cause analysis above with a layering refinement, a metadata match that narrows the mechanism, and a test-gap note. Traced against main as of 5dd992a (2026-07-24). The central question - whether the OP's specific /resume went through a fork-create path - still depends on the OP's exact repro steps; what follows sharpens the map rather than closing that question.

SessionMeta / forked_from_id layering (refining the above, which is correct). The first comment's point stands for the history-on-resume path: resume_thread_from_history builds InitialHistory::Forked of ResponseItem rollout items only (no SessionMeta), so initial_history.forked_from_id() is empty there. (InitialHistory::Forked is not always ResponseItems-only - intentional forks convert a full Resumed history, SessionMeta included, to Forked via fork_history_from_snapshot (thread_manager.rs:1935-1936) - but that is not the resume path.) Separately, at create time CreateThreadParams (session.rs:617-626) sets source from the spawn session_source argument and thread_source / originator from session_configuration for all of InitialHistory::New | Cleared | Forked(_) - a different layer from the history items, so create-time forked_from_id (and parent_thread_id) can still be empty even when source/thread_source/originator are populated (session.rs:529-532). And resume_thread_with_history passes forked_from_thread_id: None (thread_manager.rs:834), so the history-on-resume path still does not produce the OP's forked_from_id lineage. So the residual footgun is a new id minted under the resume RPC, not a SessionMeta question.

Metadata match - narrows, does not confirm, the fork class. app-server/tests/suite/v2/thread_fork.rs:191 asserts forked threads have thread_source == Some(ThreadSource::User), matching the OP's session_meta thread_source = user. This is consistent with a user-thread fork and is inconsistent with non-User sources such as Subagent (guardian/subagent threads carry ThreadSource::Subagent); it does not distinguish among user-thread create paths, since normal user resume/create can also carry ThreadSource::User.

Test gap - the contract is weakly pinned, not sanctioned. thread_resume_supports_history_and_overrides (thread_resume.rs:4029-4071) covers the history-on-resume path but asserts only !resumed.id.is_empty() (:4065), not resumed.id == thread_id - contrast the no-history tests at :3981 and :4017, which do assert identity. So the divergence between "resume preserves id" and "fork mints a new id" is not pinned by an assertion. A regression test could lock whatever contract maintainers intend (same-id Resumed, or an explicit error / separate fork API) - not necessarily resumed.id == thread_id.

On the central mechanism (honest hedge, scoped). For the non-history / running-thread rejoin path, resume_running_thread's override-mismatch path (thread_processor.rs:3507-3525) has SubmitFailed (:3519) and TimedOut (:3522) fallthrough branches that preserve identity (re-attach with overrides ignored, still Handled). This is separate from the history-on-resume footgun (resume_thread_from_history -> InitialHistory::Forked), which remains a residual risk. Whether the OP hit the fork-create path via a specific TUI action (e.g. a prompt-edit / safety-buffer fork routed through fork_thread_at_with_presentation) is the part I can't confirm from source alone - it needs the OP's exact click sequence.

Commit archaeology (full history, not a shallow clone). The resume_thread_from_history symbol was introduced by #11756 (2026-02-13, "fix thread resume rejoin semantics") and split into its own file by #20940 (2026-05-04, "Split app-server request processors") - git log -S "resume_thread_from_history" returns both. Separately, the forked_from_id / parent_thread_id persistence evolved through #24160 ("Add forked_from_thread_id turn metadata"), #25113 ("store and expose parent_thread_id on Threads"), #29327 (2026-06-22, "Persist session IDs across thread resume"), and #33432 (2026-07-15, "Preserve paginated history for spawned subagents") - these are session-id / subagent-history work, not introducers of the resume_thread_from_history symbol, so I am not conflating them with the footgun's origin.

resume_thread_from_history footgun untouched as of 5dd992a. Four post-issue commits to thread_processor.rs (#34986, #34996, #35013, #35031, all 2026-07-23) do not touch resume_thread_from_history.