TUI /resume intermittently forks the selected session into a new thread ID
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:
- I type
/resumein the Codex TUI. - I select an existing CLI session.
- The selected conversation opens with its previous history.
- Codex also creates a new rollout file with a new session ID.
- The new rollout's
session_metacontainsforked_from_idpointing to the selected session. - Both the original and the new session remain visible in the
/resumepicker 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:
- Start Codex TUI inside a project.
- Work in a normal CLI session and exit or switch away from it.
- Later, type
/resume. - Select that existing session.
- Continue using the restored conversation.
- Open
/resumeagain and inspect the session list. - Sometimes a second session with the same history is present.
- Inspecting its rollout shows a new ID and
forked_from_idpointing 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:
/resumeshould 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_idand 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.
3 Comments
Potential duplicates detected. Please review them and close your issue if it is a duplicate.
Powered by Codex Action
Analysis (community)
Thanks for the careful forensic write-up — the
forked_from_idchain + identicalfirst_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/resumeitself appears to call only resume.codex-cli 0.145.0, Ubuntu 20.04 x86_64, modelgpt-5.6-sol; one parent created under0.143.0, but a same-version parent→child was also observed under0.145.0./resume, a new rollout/thread appears withsession_meta.forked_from_id= selected (or prior unexpected child),originator=codex-tui,source=cli,thread_source=user, samefirst_user_message; chain can grow Original → child1 → child2./resumecontinues 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::Resumedreusesresumed_history.conversation_idand opens viaLiveThreadresume params.InitialHistory::Forked/New/ClearedmintThreadId::default()and go throughLiveThread::createwithforked_from_idtaken fromsession_configuration.forked_from_thread_idorinitial_history.forked_from_id().InitialHistory::forked_from_id()(codex-rs/protocol/src/protocol.rs) is decisive for the report shape:SessionMeta.id→ new child withforked_from_id = parent.meta.forked_from_idfrom history; it does not invent a new parent link for a fresh UUID.So a new rollout path + new UUID + new
forked_from_idpointing at the selected session is the signature of a fork create, not of normal id-preserving resume.What TUI
/resumeactually does (does not call fork)Call chain (current tree):
SlashCommand::Resume→AppEvent::OpenResumePicker—tui/src/chatwidget/slash_dispatch.rsresume_target_session—tui/src/app/event_dispatch.rs→tui/src/app/session_lifecycle.rsresume_target_session→app_server.resume_thread(...)only (nofork_thread/fork_thread_at)AppServerSession::resume_thread→ClientRequest::ThreadResumewiththread_resume_params_from_config—tui/src/app_server_session.rsthread_id+ config overrides (cwd/sandbox/approval/model…); no history / path payload (..ThreadResumeParams::default())On the server (
app-server/.../thread_processor.rs):thread_resume_innerwithouthistoryloads the stored thread and buildsInitialHistory::Resumedviaload_resume_initial_history_from_stored_thread→stored_thread_to_initial_history(conversation_id: stored thread_id).forked_from_idlineage.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 explicitlyfork_thread_at(tui/src/app/safety_buffering.rs→AppServerSession::fork_thread_at). Esc-Esc prompt edit emitsForkSessionForPromptEdit;/fork→ForkCurrentSession(event_dispatch.rs/slash_dispatch.rs). All produce real user-thread forks with lineage. || (B) Silent mis-route into fork /
Forkedunder some loaded-idle / cold-resume condition | Weaker / unproven in source | Would need a log showingthread/fork(or resume returning a new id) at the bad moment. || (C)
resume_thread_from_historyfootgun | Real code smell, weaker match for this report | Ifparams.historyis set,resume_thread_from_historyreturnsInitialHistory::Forkedof ResponseItems only — new ID under the resume RPC name. Stock TUI does not pass history today. History-only forks also usually lackSessionMeta, soforked_from_id()may be empty unless another path setsforked_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
/forksemantics, rollout format, or listing of deliberate forks unless the team wants clearer labeling.thread/resume:response.thread.idmust equal requestedthread_id. On mismatch, returninvalid_request/internal_errorinstead of attaching a new lineage under resume.resume_thread_from_history: either construct trueInitialHistory::Resumedwith an explicitconversation_idif history-resume must keep identity, or stop calling it “resume” and requirethread/forkfor copy-on-write. Silent Forked-under-resume is a footgun even if not this bug’s main path.LiveThread::createruns withforked_from_idfororiginator=codex-tui/thread_source=user: include call source (slash_fork,safety_buffer,prompt_edit,thread_forkRPC,history_resume, etc.). That turns the next intermittent into a one-line root cause./forkstays unchanged.resume_thread: ifstarted.session.thread_id != target_session.thread_id(or new thread’sforked_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.rsandtuiapp/tests/session_lifecycle_requests.rs):thread/resumeby id with typical TUI overrides → assertresponse.thread.id == source id, lineage unchanged, no new rollout UUID for that logical thread.first_user_messagestays 1.forked_from_idlineage underthread/resume.forked_from_id) and either labeled/hidden from the resume picker or clearly distinct from resume.InitialHistory::Forked(with SessionMeta)setsforked_from_idto parent id;InitialHistory::Resumednever 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.
thread/resume,thread/fork, or both? One local log/trace from a repro would split (A) vs (B) cleanly./fork, or/side?historyever non-null onthread/resumefrom this client build (custom client, remote app-server, binary drift)?thread_idequal 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.
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
mainas of5dd992a(2026-07-24). The central question - whether the OP's specific/resumewent 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_historybuildsInitialHistory::ForkedofResponseItemrollout items only (noSessionMeta), soinitial_history.forked_from_id()is empty there. (InitialHistory::Forkedis not always ResponseItems-only - intentional forks convert a fullResumedhistory,SessionMetaincluded, toForkedviafork_history_from_snapshot(thread_manager.rs:1935-1936) - but that is not the resume path.) Separately, at create timeCreateThreadParams(session.rs:617-626) setssourcefrom the spawnsession_sourceargument andthread_source/originatorfromsession_configurationfor all ofInitialHistory::New | Cleared | Forked(_)- a different layer from the history items, so create-timeforked_from_id(andparent_thread_id) can still be empty even whensource/thread_source/originatorare populated (session.rs:529-532). Andresume_thread_with_historypassesforked_from_thread_id: None(thread_manager.rs:834), so the history-on-resume path still does not produce the OP'sforked_from_idlineage. So the residual footgun is a new id minted under the resume RPC, not aSessionMetaquestion.Metadata match - narrows, does not confirm, the fork class.
app-server/tests/suite/v2/thread_fork.rs:191asserts forked threads havethread_source == Some(ThreadSource::User), matching the OP'ssession_meta thread_source = user. This is consistent with a user-thread fork and is inconsistent with non-Usersources such asSubagent(guardian/subagent threads carryThreadSource::Subagent); it does not distinguish among user-thread create paths, since normal user resume/create can also carryThreadSource::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), notresumed.id == thread_id- contrast the no-history tests at:3981and: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-idResumed, or an explicit error / separate fork API) - not necessarilyresumed.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) hasSubmitFailed(:3519) andTimedOut(:3522) fallthrough branches that preserve identity (re-attach with overrides ignored, stillHandled). 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 throughfork_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_historysymbol 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, theforked_from_id/parent_thread_idpersistence 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 theresume_thread_from_historysymbol, so I am not conflating them with the footgun's origin.resume_thread_from_historyfootgun untouched as of5dd992a. Four post-issue commits tothread_processor.rs(#34986, #34996, #35013, #35031, all 2026-07-23) do not touchresume_thread_from_history.