Skip to content

Chat: old tabs retry a live recovered Codex conversation #9368

Open

Description

@toddagent01

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

  1. 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.
  2. Switch away from that workspace. Close the synthetic chat using superset sessions close, then resume its session id using superset sessions resume. Keep the returned replacement live. This models an external controller recovering a stopped chat.
  3. Return to the workspace and focus the original chat tab. Compare superset sessions list --local --workspace "$WS" --json before and after. Read a new dead row with superset 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.ts
  • apps/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

  1. github-actions commented on Nov 4, 2026

    @github-actions
    Contributor

    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 same harnessSessionId but a different terminalId. Two separate mechanisms disagree about what to do with B:

    • Auto-adopt suppresses B because the conversation already has an attached tab.
    • The old tab sees that A has stopped. It never looks for B. It tries to load the same provider conversation again under its own 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 by harnessSessionId ?? 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: attached is built from each pane's terminalId/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: recoverable is true when A is stopped or dead and the harness is known. The only check for a surviving executor is continuedInTerminal (:458-465), which looks at terminal agent bindings with no chatSessionId. A live chat session such as B with the same harnessSessionId is not checked.
    • :471-478: recover() closes A, then calls start(harness, agentSessionId, true), which runs createSession with resume.harnessSessionId set to the original Codex conversation id and the pane's own terminalId (:199-223).
    • :54, :469-470, :593-608: there are up to MAX_RECOVERIES = 3 attempts with backoff. recoveries.current is 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 goes dead and its lastError becomes 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/handOff is the only path that reuses an existing live session for a resume. It is keyed by terminalId (resumedByTerminal). The pane's createSession uses the pane's terminalId, which differs from B's, so there is no handoff and a new session is minted every time (:432-461). Nothing in createSession checks for an already-live session with the same harness + resume.harnessSessionId in the scope.
    • commands.ts:412-418: listSessions reports terminalId only for live sessions (live?.terminalId ?? null). The stopped A row therefore matches the pane only by sessionId, while B carries its own new terminalId. 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":

    1. Auto-adopt treats "the conversation has an attached pane" as "the conversation is shown". It doesn't check whether that pane's session is the live one.
    2. The pane treats "my session stopped" as "nobody holds this conversation". It starts a second executor, and the provider rejects it.
    3. The host doesn't deduplicate a resume of a conversation that is already live under a different terminalId.

    I could not confirm why the dead rows have a null harnessSessionId. Most likely session/load fails 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, through sessions resume or sessions create with a resume id.

    Unit test sketch (bun:test), auto-adopt half, in latestOpenChatSessions.test.ts:

    const old = { sessionId: "A", harnessSessionId: "codex-1", terminalId: null, live: false, closedAt: 1, updatedAt: 1 };
    const repl = { sessionId: "B", harnessSessionId: "codex-1", terminalId: "T2", live: true, closedAt: null, updatedAt: 2 };
    // Pane still bound to A / T1
    expect(latestOpenChatSessions([old, repl], new Set(["A", "T1"]))).toEqual([]); // current behaviour: B is hidden

    Recovery half: a component or hook test for AcpChatPane with these conditions:

    • the stored session A has live: false;
    • the host's listSessions contains a live B with the same harness and harnessSessionId.

    Assert that transport.createSession is not called and that onSessionCreated("B") is called. On current code, createSession is called with resume.harnessSessionId: "codex-1".

    Suggested fix direction

    • Pane recovery (AcpChatPane.tsx, before recover()): look up the workspace's sessions for exactly one live session with the same harness and the same non-empty harnessSessionId. If one exists, rebind the pane to it through the onSessionCreated(replacement) / carryReaderPosition path already used at :548-554, instead of calling createSession. If the lookup fails or returns more than one match, show AcpRecovery and do not auto-resume.
    • Auto-adopt (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.
    • Host (defence in depth): in createSession, when resume.harnessSessionId matches 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.
    • Retry cap: keep the recovery attempt count somewhere that survives a pane remount (for example per sessionId), so repeated focuses can't create more than three failed sessions in total.
    • Regression test: an old attached tab plus a live replacement with a different terminalId. Assert no createSession call and no new session row.
  2. toddagent01 commented on Nov 7, 2026

    @toddagent01
    Author

    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.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions