Skip to content

fix(agent-session): never open a sibling terminal on an unproven create - #18735

Merged
brennanb2025 merged 1 commit into
mainfrom
brennanb2025/bug1-duplicate-session
Sep 5, 2026
Merged

brennanb2025 merged 1 commit into
mainfrom
brennanb2025/bug1-duplicate-session

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 2 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​230 $\color{#cf222e}{\Huge{\mathbf{−}}}$​1 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​229
Prod 1 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​38 $\color{#cf222e}{\Huge{\mathbf{−}}}$​12 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​26

#18697 has merged (2026-09-05), so this now targets main directly and was rebased onto it — the stale stacked history is gone and only this PR's own commit remains.

What this PR is, precisely: the duplicate-session behaviour is already fixed on main. This adds the typed StructuredAgentSessionCreateUnknownOutcomeError, closes a latent classification hole, and pins the behaviour with a regression suite so it cannot silently come back. It is hardening, not a behaviour fix — read the before/after below with that in mind.

Re-expressed onto a moved base (2026-09-04). brennanb2025/bug2-refusal-envelope advanced and renamed the module this PR edits — launch-structured-codex-session.ts → launch-structured-agent-session.ts — as part of the agent-agnostic Codex→agent rename, and in the same move landed 8491e84 "gate legacy fallback on definitive refusals", which already carries a code on the refusal error and already keeps a non-definitive refusal out of StructuredAgentSessionCreateRefusalError. This PR was re-expressed against the renamed module and narrowed to what the base still lacks; the "what changed" section below says exactly what that is. The older-runtime method_not_found commit is fully superseded by the base and has been dropped.

ELI5

You click "start a Codex chat". Orca asks the host to create one. Usually the host says "done" or "no". But sometimes it says "I might have done it — I can't confirm": it really did create the session, then failed on the last step of showing you the tab.

If the desktop files that maybe under no, it helpfully opens a plain terminal for you instead — right next to the chat session that already exists. You asked for one thing and got two, and the extra one is the one you didn't ask for.

The base branch already stopped that from happening. What it did not do is write the difference down anywhere a reader — or the code — can see it: an unconfirmed outcome came back as a plain Error with a code string stapled on. This PR gives that outcome a name, so "I don't know" is a thing the type system knows about rather than a convention every future consumer has to re-derive, and adds the end-to-end test that actually counts the terminals.

Before / after

You click "new Codex chat" and the host… Before the stack After
creates it and confirms it 1 chat 1 chat
creates it but can't confirm the tab (agent_session_operation_unknown) 1 orphaned chat + 1 terminal you didn't ask for 1 chat — the retry finishes publishing its tab
can't say who owns the session (agent_session_ownership_unknown) 1 possible chat + 1 terminal nothing extra; "couldn't confirm", and clicking again reconciles
genuinely can't do structured chat here (structured_agent_session_unsupported) 1 terminal 1 terminal — unchanged
never answers (transport error, timeout) nothing extra nothing extra — unchanged

The only row that gets a terminal is the one where the host proved it created nothing.

What changed in this PR

Already on the base, not re-added here: the isDefinitiveAgentSessionCreateRefusal import, code on StructuredAgentSessionCreateRefusalError, definitiveStructuredAgentSessionCreateErrorCode, the thrown-error classification, and the mocked-module unit test in structured-agent-session-launch.test.ts.

What this PR adds:

  1. StructuredAgentSessionCreateUnknownOutcomeError. The base rejected an unconfirmed create with Object.assign(new Error(message), { code }) — an anonymous error whose verdict existed only as a string. It is now a named sibling of StructuredAgentSessionCreateRefusalError (both extend a private StructuredAgentSessionCreateError that owns code), so "the host may already hold this session" is a type, not a convention.

  2. Classification short-circuits on our own classes. This closes a real latent hole, not just a naming one. definitiveStructuredAgentSessionCreateErrorCode falls through to hasRuntimeRpcErrorCode, which — deliberately, for re-wrapped transport errors — also matches the message. An unknown refusal carrying a host message like Owner check failed: method_not_found therefore classified as definitive on the base: isDefinitiveStructuredAgentSessionCreateError returned true for an outcome that proves nothing. Verified by ablation (below). With the typed class, the verdict is read off the class and a message we wrote can never invert it.

  3. The end-to-end guard. structured-agent-session-launch-refusal-fallback.test.ts (new; absent on the base) drives the real classifier through startStructuredAgentLaunch and counts legacy terminals through the fallback every caller uses. The base's coverage of this behaviour mocks launch-structured-agent-session out entirely, so it asserts the caller's contract but never the classification itself.

Making the class carry the verdict rather than adding a definitive flag was deliberate, and that finding still holds after the rename — the consumer list grew from eight sites to nine (structured-agent-session-launch{,-recovery,-callers}.ts, NonGitFolderDialog.tsx, folder-workspace-composer-submit.ts, full-creation-structured-launch.ts, launch-work-item-direct-agent-routing.ts, worktree-creation-structured-session.ts, repo-add-actions.ts), and because the unknown-outcome error is a sibling rather than a subclass, all nine instanceof checks keep meaning what they say with zero edits. A flag would have needed the same nine-site audit and left the original mistake re-makeable.

Two consequences worth naming:

  • The replay repairs the tab. agent_session_operation_unknown on the create route means publishStructuredAgentSessionTab threw after the session committed. Replaying the same envelope replays the attach and re-attempts the publication, so recovery doesn't just avoid a duplicate — it usually produces the chat the user asked for.
  • An unknown outcome keeps its focus intent. abandonStructuredAgentSessionLaunchIntent runs only on a definitive refusal; abandoning it on a maybe would leave a session that later publishes without focusing.

Per CORRECTIONS.md, agentSessionRefusalOperationState is deliberately not reused for this decision — it answers "did this durably settle?", for which structured_agent_session_unsupported is correctly pending-admission, the opposite of what the fallback needs.

Tests

structured-agent-session-launch-refusal-fallback.test.ts (new, 4 cases) mocks the RPC rather than the module under test:

  • agent_session_operation_unknown ⇒ no sibling, visibility-unknown, one error toast;
  • agent_session_ownership_unknown ⇒ no sibling;
  • unknown-then-published ⇒ the replay adopts the existing session, no sibling, no toast;
  • structured_agent_session_unsupported ⇒ exactly one terminal and exactly one create (no pointless replay);
  • plus an older runtime with no agentSession.create method ⇒ exactly one terminal, one call, launch state back to idle.

launch-structured-agent-session.test.ts gains the class assertion on the unknown outcome and the message-sniffing regression case.

Ablation (two, both run).

  • Collapse unknown into definitive (every { ok: false } becomes a refusal — the original defect): 5 tests go red, and with the class assertion relaxed so it can't short-circuit, the "no sibling" cases fail on expected [ 'legacy-terminal' ] to deeply equal []. The guard catches the duplicate session itself, not just a type name.
  • Restore the base's anonymous error (Object.assign(new Error(message), { code })): exactly 4 tests go red, all on class identity — which is the honest measure of what this PR adds over the base. A targeted probe under that ablation confirms isDefinitiveStructuredAgentSessionCreateError returns true for agent_session_ownership_unknown with the message Owner check failed: method_not_found, i.e. hole (2) above is real on the base and closed here.

Verification

pnpm tc clean (exit 0, run once) · 8 consumer suites / 66 tests green (renderer launch, launch-recovery, structured-chat guard, work-item routing, worktree-creation ×2, composer-state full-creation, and the new fallback suite) · pnpm run check:code-quality:changed — 0 new findings · oxfmt on changed paths only · git status --porcelain empty.

Scope

Bug 1 only. Bugs 3 and 4 from the plan are out of scope here; bug 4 in particular is not desktop-inert (it also gates restoreStructuredTabsIfSupported) and ships separately.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • Definitive-vs-unknown refusal classification — launchStructuredCodexSession now gates the legacy-terminal fallback on isDefinitiveAgentSessionCreateRefusal, so agent_session_operation_unknown and agent_session_ownership_unknown route down the existing lost-reply path (verify publication, replay the identical envelope, park as visibility-unknown) instead of opening a sibling terminal.
  • Error class split — a shared base (StructuredAgentSessionCreateError) carries code; StructuredAgentSessionCreateRefusalError and the new StructuredAgentSessionCreateUnknownOutcomeError extend it so instanceof is the verdict. All eight consumers keep meaning what they say, and each already has an unknown-outcome branch to route into (verified: structured-agent-session-launch{,-recovery,-callers}.ts, NonGitFolderDialog.tsx, folder-workspace-composer-submit.ts, full-creation-structured-launch.ts, launch-work-item-direct-agent-routing.ts, worktree-creation-structured-session.ts, repo-add-actions.ts).
  • Focus-intent asymmetry — abandonStructuredAgentSessionLaunchIntent now runs only on a definitive refusal, so an unknown outcome keeps its focus intent for recovery to adopt the published tab.
  • Tests — the new fallback suite drives the real launchStructuredCodexSession through startStructuredCodexLaunch (RPC mocked, not the module under test) and counts legacy terminals, with unit coverage pinning the carried code and the focus-intent asymmetry. I ran both changed files: 9/9 green, and the "no sibling" cases are exact assertions that would fail under the old classification.

ℹ️ One pre-commit "proven no" stays off the allowlist

The allowlist is narrower than the set of refusals the host can emit before attach. agentSessionFingerprintConflict returns agent_session_operation_conflict inside resolveUncommittedStructuredCreate — before anything commits — yet it is not allowlisted, so a fingerprint-mismatch create now reconciles rather than falling back to a terminal. That is a defensible reading (a fingerprint mismatch implies the client and host disagree, so "reconcile, don't spawn a sibling" is the safer default, and it is only reachable under version skew), but it is worth confirming this was intended rather than an oversight.

Pullfrog  | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

Since the prior pullfrog review, this PR added handling for thrown transport-level failures on agentSession.create:

  • Thrown method_not_found is now definitive — launchStructuredCodexSession wraps the create RPC in a try/catch; a thrown RuntimeRpcCallError whose code is on the shared allowlist (currently just method_not_found) is abandoned and rethrown as StructuredAgentSessionCreateRefusalError, so an older runtime that never registered agentSession.create falls back to a single legacy terminal instead of failing silently.
  • Thrown unknown failures stay unknown — a thrown runtime_timeout / remote_runtime_unavailable, or any non-RuntimeRpcCallError, is rethrown untouched, preserving the focus intent so the existing lost-reply recovery can still reconcile a session the host may have created.
  • Tests — unit coverage pins the thrown-path classification (allowlisted code vs. a bare Error('method_not_found') vs. unknown codes), and the end-to-end fallback suite adds an "older runtime has no create method" case asserting exactly one terminal and a fully-settled (idle) launch state.

I ran both changed files: 14/14 green, and the new method_not_found fallback case is stable across repeated runs.

Pullfrog  | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Visual proof @ fc295901f88

Isolated Electron launch of this worktree (window.api.app.getIdentity() → /Users/brennanbenson/orca/workspaces/orca/pr18735-codex-loop, branch brennanb2025/pr18735-review). Codex was started from the real New tab → Codex menu, not a store action.

Tested scenario

Fresh profile, temp git workspace. Enabled structured native-chat flags as setup, then clicked Codex in the visible tab-bar menu.

Visible evidence

Before: only Terminal 1. Resource Manager = 1 terminal session.

05-before-structured-codex-launch.png

Surface: New tab menu with Codex.

06-structured-codex-menu.png

After: Terminal 1 + one Codex Chat tab. Chat empty state “Start a chat with Codex”. Resource Manager still 1 session — no extra sibling terminal.

07-structured-codex-one-chat-no-sibling.png

Supplementary (structured flags off): one Codex TUI tab beside Terminal 1, still no second extra terminal.

04-codex-tui-fallback-without-structured-flags.png

Tiny white pill at the bottom of the frames is the Playwright comment overlay, not product UI.

Backing state

  • Unified tabs: 1 terminal (Terminal 1) + 1 agent-session (codex_93a381f5_ff87_4f3b_8e9f_47d6d4aa1f52)
  • Only Terminal 1 has a PTY; ptyIdsByTabId has one binding
  • Active tab = structured agent-session
  • Launch status idle

Remaining gap

Could not inject agent_session_operation_unknown / agent_session_ownership_unknown via a renderer-local CDP stub: window.api.runtime is a frozen contextBridge object, so replacing call is a no-op, and repo files were not edited. The unconfirmed-create row is not visually proven here (unit tests still cover it). This is success-path evidence only — not merge confidence for the unknown-outcome classification.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Final review status at fc295901f8867a86c8d2cd2bb4865c019b47fb11:

  • The review-until-clean loop found and fixed one real mixed-version defect: a thrown typed RuntimeRpcCallError with exact method_not_found bypassed the definitive-refusal classifier, so an older runtime could fail to take the intended single legacy fallback. The fix matches the machine code exactly; message-only lookalikes remain unknown to preserve the no-duplicate-session guarantee.
  • Validation passed: 81 targeted tests, 13 local cross-version contract tests, pnpm tc:web, changed-code quality, formatting, commit hooks, and a final independent 58-test readiness subset. Current required GitHub checks are green, including macOS/Linux tests and Windows packaging.
  • Electron proof used the real New tab → Codex UI in an isolated instance. It showed one structured Codex Chat tab beside the existing terminal, with one PTY and no extra sibling terminal. The exact unknown/refusal row could not be safely injected because the contextBridge API is frozen, so that row remains test-backed rather than visually injected; screenshots and backing state are in the visual-proof comment above.
  • Final readiness found no proven P0, P1, or P2 code defect and no performance/resource regression.

Integration gate: PR #18697 is still open, and its branch moved during this review. The base remains correctly stacked on brennanb2025/bug2-refusal-envelope, but GitHub now reports this PR as CONFLICTING; the parent has generalized/replaced the touched implementation and appears to contain the equivalent behavior. Do not merge, retarget, or rebase this PR onto main while #18697 is open. After #18697 settles, either close #18735 as superseded or restack only any behavior that remains unique onto the final parent, then rerun the bounded checks and Electron proof.

@brennanb2025
brennanb2025 force-pushed the brennanb2025/bug1-duplicate-session branch from fc29590 to c3bfc03 Compare September 5, 2026 02:18

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

Since the prior pullfrog review, this PR was re-expressed onto the advanced base brennanb2025/bug2-refusal-envelope — which renamed the edited module launch-structured-codex-session.ts → launch-structured-agent-session.ts and landed the thrown method_not_found handling the earlier revision added. The delta is a faithful rename plus a narrowing to what the base still lacks:

  • Agent-agnostic rename — launchStructuredCodexSession → launchStructuredAgentSession, startStructuredCodexLaunch → startStructuredAgentLaunch(worktreeId, agent), with no codex-named references left behind in the module or its callers.
  • Named unknown-outcome error — the base's anonymous Object.assign(new Error(message), { code }) becomes StructuredAgentSessionCreateUnknownOutcomeError, a sibling of StructuredAgentSessionCreateRefusalError over a shared private StructuredAgentSessionCreateError base.
  • Classification short-circuit — definitiveStructuredAgentSessionCreateErrorCode returns on instanceof StructuredAgentSessionCreateError before the transport-error matcher runs, so a refusal message ending in a definitive token (e.g. Owner check failed: method_not_found) can no longer invert an unknown outcome into a definitive one; the !result.ok branch now gates directly on isDefinitiveAgentSessionCreateRefusal(code).
  • End-to-end guard test — the new structured-agent-session-launch-refusal-fallback.test.ts drives the real classifier through startStructuredAgentLaunch and counts legacy terminals.

I ran the two changed test files (22/22) plus the consumer suites (42/42), all green, and confirmed the rename is complete and the classification logic matches the previously reviewed behavior.

Pullfrog  | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

@brennanb2025
brennanb2025 changed the base branch from brennanb2025/bug2-refusal-envelope to main September 5, 2026 07:56
An `agentSession.create` the host could not confirm — it committed the session but
could not publish its tab, and answered `agent_session_operation_unknown` — was
rejected with a bare `Error` carrying a `code`. Nothing in the type said "unknown",
so the verdict lived only in the code string, and the shared transport matcher was
still free to re-read that error's *message*: an unknown refusal whose text ends in
a definitive token (`Owner check failed: method_not_found`) classified as definitive,
which is exactly the answer that permits a legacy sibling terminal.

Make the class the verdict. `StructuredAgentSessionCreateUnknownOutcomeError` is a
sibling of `StructuredAgentSessionCreateRefusalError`, not a subclass, so the nine
existing `instanceof` consumers keep reading "refusal" as "you may fall back" with
zero edits, and an unknown outcome flows down the lost-reply path instead —
replaying the same envelope, re-publishing the tab the host failed to publish, and
parking as visibility-unknown rather than creating anything. Classification now
short-circuits on our own classes, so a message we wrote can never invert the
verdict we already reached.

Adds an end-to-end guard that drives the real classifier through
`startStructuredAgentLaunch`: an unknown outcome opens zero legacy terminals, a
definitive refusal opens exactly one. Ablating the branch turns that green suite red
with `['legacy-terminal']` — the duplicate session the guard exists to prevent.
@brennanb2025
brennanb2025 force-pushed the brennanb2025/bug1-duplicate-session branch from c3bfc03 to 9855d1d Compare September 5, 2026 07:57
@coderabbitai

coderabbitai Bot commented Sep 5, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Team

Run ID: e9fc3dff-4761-43c7-b108-5283d7077bbe

📥 Commits

Reviewing files that changed from the base of the PR and between e95d247 and 9855d1d.

📒 Files selected for processing (3)
  • src/renderer/src/lib/launch-structured-agent-session.test.ts
  • src/renderer/src/lib/launch-structured-agent-session.ts
  • src/renderer/src/lib/structured-agent-session-launch-refusal-fallback.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 1 remains after this review.


📝 Walkthrough

Walkthrough

The change adds a shared session-create error base and a distinct unknown-outcome error. Launch handling keeps focus for unknown outcomes and abandons it for definitive refusals. Tests cover error classification, refusal messages containing method_not_found, successful session adoption, legacy terminal fallback, and older runtimes.

Merge Risk: ⚪ Minimal · up to 9855d

Structured session creation now avoids opening a sibling terminal when the create result is uncertain, while preserving legacy fallback for confirmed refusals. The covered behavior has no remaining concrete merge-blocking risk.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 6 functions across 3 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the main change: preventing a sibling terminal when structured agent-session creation is not proven.
Description check ✅ Passed The description is detailed and covers the problem, rationale, implementation, tests, verification, and behavior before and after the change. It does not follow every template heading and does not pro…
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@brennanb2025
brennanb2025 merged commit c58d7a0 into main Sep 5, 2026
28 checks passed
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
…te (stablyai#18735)

An `agentSession.create` the host could not confirm — it committed the session but
could not publish its tab, and answered `agent_session_operation_unknown` — was
rejected with a bare `Error` carrying a `code`. Nothing in the type said "unknown",
so the verdict lived only in the code string, and the shared transport matcher was
still free to re-read that error's *message*: an unknown refusal whose text ends in
a definitive token (`Owner check failed: method_not_found`) classified as definitive,
which is exactly the answer that permits a legacy sibling terminal.

Make the class the verdict. `StructuredAgentSessionCreateUnknownOutcomeError` is a
sibling of `StructuredAgentSessionCreateRefusalError`, not a subclass, so the nine
existing `instanceof` consumers keep reading "refusal" as "you may fall back" with
zero edits, and an unknown outcome flows down the lost-reply path instead —
replaying the same envelope, re-publishing the tab the host failed to publish, and
parking as visibility-unknown rather than creating anything. Classification now
short-circuits on our own classes, so a message we wrote can never invert the
verdict we already reached.

Adds an end-to-end guard that drives the real classifier through
`startStructuredAgentLaunch`: an unknown outcome opens zero legacy terminals, a
definitive refusal opens exactly one. Ablating the branch turns that green suite red
with `['legacy-terminal']` — the duplicate session the guard exists to prevent.

Co-authored-by: Merge Sim <sim@local>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant