Repository navigation
Description
Summary
- After a stopped Codex chat is resumed through the CLI, opening its old app tab tries to load the provider conversation again even though the replacement is live.
- Codex rejects the second load with “This Codex session is in use by another Codex client”. The old tab shows the stopped/recovery view and repeated visits leave empty dead chats.
- Reproduced in a synthetic conversation without restarting the app or interrupting real work.
Steps to reproduce
- In a disposable workspace, run
superset sessions create --local --workspace "$WS" --agent codex --prompt 'Reply OK; use no tools.' --json. Let the chat become idle; open it in the app so it has a tab. - Switch away from that workspace. Close the synthetic chat using
superset sessions close, then resume its session id usingsuperset sessions resume. Keep the returned replacement live. This models an external controller recovering a stopped chat. - Return to the workspace and focus the original chat tab. Compare
superset sessions list --local --workspace "$WS" --jsonbefore and after. Read a new dead row withsuperset sessions read.
Expected
The existing tab attaches to the live replacement for the same workspace, harness and nonempty provider conversation id. No second executor starts and no empty chats appear.
Actual
The original and replacement share one harnessSessionId but have different terminalIds. Across two focuses I observed five additional rows with null harnessSessionId: four dead and one interrupted/closed while starting. Each failed row carries a notice: “Could not reopen this conversation. session/load: Invalid request: This Codex session is in use by another Codex client (the Codex app, the CLI or an IDE extension). Close the session there or quit that client, then try again.” The valid replacement stays live/idle. No real app restart was required. All synthetic sessions were closed afterwards.
Investigation
The installed host's sessions.resume creates a new chat with a new terminal UUID. In current source, latestOpenChatSessions groups by harnessSessionId and suppresses the entire conversation if any row is already attached. The old attached tab therefore prevents auto-adoption of the live replacement. AcpChatPane independently detects its old session as stopped, closes that old session and calls createSession with the original provider id; MAX_RECOVERIES is three. This explains the repeated rejected loads. The same pattern also occurs when an external controller uses sessions.create with resumeSessionId to preserve launch settings.
Relevant source at 24654a1:
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/latestOpenChatSessions/latestOpenChatSessions.tsapps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/AcpChatPane/AcpChatPane.tsx
Fix direction
Before any automatic resume, look for an unambiguously live replacement with the same workspace, harness and nonempty provider binding, and attach the existing pane to it. Make auto-adoption and recovery agree on the surviving session. A failed or incomplete inventory must not authorize another executor; unrelated conversations must stay separate. Include a regression covering an old attached tab plus a live replacement with a different terminalId, asserting no createSession call and no added empty session.
Environment
Paradev Canary 2.0.3-canary.20261104015009, bundled CLI 2.0.3, macOS arm64. Desktop 2.0.4 has since been released; it was not installed during this probe. The source paths above were inspected on main on 2026-11-04 at commit 24654a1 and still contain this pattern. The public Chat UI and Agent Sessions documentation has no recovery action for an already-live replacement.
Activity
github-actions commentedon Nov 4, 2026
Triage summary
I checked the report against the source at
24654a10, and the behaviour it describes follows from the code. Here is the sequence. A chat tab is bound to session A. The CLI closes A and resumes A's Codex conversation. The host then starts session B, which has the sameharnessSessionIdbut a differentterminalId. Two separate mechanisms disagree about what to do with B:terminalId, and Codex rejects that because B already holds it.Each failed attempt leaves a dead session with a null
harnessSessionId. The retry cap resets on each focus, so repeated visits add more of them. I read the code only; I did not run the repro or any tests.Affected code
Auto-adopt
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useAutoAdoptChatSessions/utils/latestOpenChatSessions/latestOpenChatSessions.ts:14-24: sessions are grouped byharnessSessionId ?? sessionId. If any row in a group is attached, the whole group returns[]. The old tab's A is attached, so B is never offered..../useAutoAdoptChatSessions/useAutoAdoptChatSessions.ts:144-154:attachedis built from each pane'sterminalId/sessionId. The old pane still holds A's ids, so B counts as part of an attached conversation even though no pane shows it.Recovery in the old tab (
.../usePaneRegistry/components/ChatPane/components/AcpChatPane/AcpChatPane.tsx):447-468:recoverableis true when A is stopped or dead and the harness is known. The only check for a surviving executor iscontinuedInTerminal(:458-465), which looks at terminal agent bindings with nochatSessionId. A live chat session such as B with the sameharnessSessionIdis not checked.:471-478:recover()closes A, then callsstart(harness, agentSessionId, true), which runscreateSessionwithresume.harnessSessionIdset to the original Codex conversation id and the pane's ownterminalId(:199-223).:54,:469-470,:593-608: there are up toMAX_RECOVERIES = 3attempts with backoff.recoveries.currentis a ref, so the cap starts again whenever the pane remounts, for example on a focus or workspace switch. That would explain why rows keep accumulating across visits.:556-565: each failed replacement goesdeadand itslastErrorbecomes the shown failure. This matches the "Could not reopen this conversation… in use by another Codex client" notice.Host side
packages/chat-runtime/src/commands/commands/commands.ts:353-373:resumedFor/handOffis the only path that reuses an existing live session for a resume. It is keyed byterminalId(resumedByTerminal). The pane'screateSessionuses the pane'sterminalId, which differs from B's, so there is no handoff and a new session is minted every time (:432-461). Nothing increateSessionchecks for an already-live session with the sameharness+resume.harnessSessionIdin the scope.commands.ts:412-418:listSessionsreportsterminalIdonly for live sessions (live?.terminalId ?? null). The stopped A row therefore matches the pane only bysessionId, while B carries its own newterminalId. This is consistent with what the reporter saw.Suspected root cause
Auto-adopt and pane recovery each decide on their own which session owns a provider conversation, and neither one considers "another live chat session already holds this
harnessSessionId":terminalId.I could not confirm why the dead rows have a null
harnessSessionId. Most likelysession/loadfails before the adapter reports a session id.Reproduction
Manual: follow the steps in the issue. The key condition is that the old tab stays in the layout while a replacement is created with a different
terminalId, throughsessions resumeorsessions createwith a resume id.Unit test sketch (
bun:test), auto-adopt half, inlatestOpenChatSessions.test.ts:Recovery half: a component or hook test for
AcpChatPanewith these conditions:live: false;listSessionscontains a live B with the same harness andharnessSessionId.Assert that
transport.createSessionis not called and thatonSessionCreated("B")is called. On current code,createSessionis called withresume.harnessSessionId: "codex-1".Suggested fix direction
AcpChatPane.tsx, beforerecover()): look up the workspace's sessions for exactly one live session with the same harness and the same non-emptyharnessSessionId. If one exists, rebind the pane to it through theonSessionCreated(replacement)/carryReaderPositionpath already used at:548-554, instead of callingcreateSession. If the lookup fails or returns more than one match, showAcpRecoveryand do not auto-resume.latestOpenChatSessions): when the attached row in a group is not live and a different row in the group is live, don't drop the group silently. Either surface the live row to the attached pane, or let one shared helper used by both paths decide the surviving session, so the two always agree.createSession, whenresume.harnessSessionIdmatches a live, non-dead session in the same scope and harness, return that session instead of minting a new one. Or reject the call before the provider is touched, which would also prevent the dead empty rows.sessionId), so repeated focuses can't create more than three failed sessions in total.terminalId. Assert nocreateSessioncall and no new session row.toddagent01 commentedon Nov 7, 2026
Additional evidence: opening an explicit session can select a retained chat even when the requested session already has its own pane.
A coordinator observed sessions open targeting B select retained starting pane A; focusing B by exact pane identity reached the existing worker without closing or replacing either chat. A subsequent read-only host inventory independently confirmed A and B were both live, idle, open OpenCode chats with the same nonempty harnessSessionId and different session/terminal IDs. This extends the presentation symptom beyond Codex; it does not establish another Codex load failure.
At released Canary 19db630, focusChatSession traverses panes and returns on either an exact session match OR a same-provider match. Thus an earlier A can win before B is examined. This supports that selection explanation; we did not replay the focus on protected work.
An acceptance case should include earlier retained A plus later exact B. Both existing chats were preserved.