Skip to content

fix(agent-status): lift the closed-tab blackhole when a retracted tab comes back (STA-5679) - #16985

Closed
brennanb2025 wants to merge 2 commits into
mainfrom
brennanb2025/sta5679-suppression-gate
Closed

brennanb2025 wants to merge 2 commits into
mainfrom
brennanb2025/sta5679-suppression-gate

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 3 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​146 0 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​146
Prod 6 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​49 $\color{#cf222e}{\Huge{\mathbf{−}}}$​3 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​46

ELI5

When you close a terminal tab, Orca stops accepting agent-status updates for it. Sensible — except
Orca could believe a tab was closed when it wasn't. A host restart, or a refresh that only carries
part of the picture, can briefly retract a tab that is still sitting there with an agent working in
it. Once that happened, the tab was on the "closed" list for the rest of the session with no way
off it. Every status update for it was accepted and thrown away, silently, so the tab showed no agent
status at all and nothing anywhere reported a problem. Restarting Orca was the only cure.

Even starting a brand-new turn in that tab — the strongest possible "I am alive" signal there is —
did not bring it back.

User-facing before / after

Before After
A host restart or a partial refresh briefly retracts a tab that is still open, with an agent working in it That tab's agent status went dark for the rest of the session — no working indicator, no completion, nothing. A new turn didn't revive it The status comes back once Orca has confirmed the tab really is still there
A tab you genuinely closed Its status stays suppressed Unchanged
Noticing it was happening Nothing surfaced it — the updates were accepted and quietly discarded, so it looked like the agent had simply stopped reporting The condition clears itself, so there is nothing left to notice

When it bites

Anyone whose Orca host restarts, or replays a partial snapshot, while agents are working. The symptom
is a tab whose agent status stops updating and never recovers until you restart the app.

The bug

getAgentStatusDisposition suppresses every hook event whose tab id is in closedAgentStatusTabIds (server.ts:1185), and that check runs first — ahead of the pane-key gate and ahead of any new-turn revive.

Unlike closedAgentStatusPaneKeys, which a UserPromptSubmit/SessionStart lifts a few lines below, the tab set is add-only. Only a 1024-entry LRU eviction or a full server reset ever clears it.

So a tab retracted by a transient or subset snapshot frame — a host restart, a cross-host collision replay — is blackholed for the rest of the session even though it is still open and its agent is still working. The POSTs keep returning 204, so nothing reports a problem anywhere.

Reproduced on a live pane

Real hook events over the real HTTP path, against a tab confirmed still present in tabsByWorktree:

step result
hook on a live, never-closed tab row appears — claude / working
retract that still-open tab row cleared
UserPromptSubmit 204, no row
SessionStart 204, no row
PreToolUse 204, no row

UserPromptSubmit and SessionStart are the strongest revive signals in the system. The tab-level set ignores all of them.

The fix

The renderer already recognized this hazard and fixed its half — buildRemirroredClosedTabMarkerLiftPatch, commented:

"a lingering closed-tab marker would blackhole its byte-derived status for the whole session (host-restart subset frames, cross-host collision replays)"

But main's set is checked upstream of the renderer, so lifting only the renderer marker could never reach it. This adds the other half: liftClosedAgentStatusTabs() behind an agentStatus:liftClosedTabs IPC, called with exactly the ids the renderer just proved alive.

This does not weaken the existing re-attach does not lift a closed-tab tombstone rule — a re-attach is an inference about a pane, whereas a republished mirrored id is the host reporting that the tab exists.

Tests

Two in server-closed-tab-suppression.test.ts, verified non-vacuous: stubbing the lift to a no-op fails with expected "vi.fn()" to be called at least once. The first test also asserts the suppressed state before lifting, so it cannot pass by accident. Second test pins that only the named tabs lift. 22/22 pass; tc:node, tc:web, oxlint and oxfmt clean.

Scope

Deliberately narrow. Not touched: the 1024 LRU cap, the launch-token fence, the pane-key revive rules, or the retraction logic that decides a tab is gone in the first place. Whether web-session-tabs-sync should retract a live tab is a separate question — this makes the consequence recoverable rather than permanent.

@coderabbitai

coderabbitai Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 7 minutes.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 14936b82-2315-4709-95d0-dbbbdde58f3d

📥 Commits

Reviewing files that changed from the base of the PR and between 4bb3377 and 573eea774fefbb665685e000375dc2c1ec3f19ef.

📒 Files selected for processing (7)
  • src/main/agent-hooks/server-closed-tab-suppression.test.ts
  • src/main/agent-hooks/server.ts
  • src/main/ipc/agent-status-row-teardown-ipc.ts
  • src/preload/api/agent-status-api.ts
  • src/preload/index.ts
  • src/renderer/src/runtime/web-session-tabs-sync.ts
  • src/renderer/src/web/preload-api/web-agent-status-api.ts

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 marked this pull request as draft August 28, 2026 07:57
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Retracting this — the fix is a no-op, and making it work would regress STA-4114

A history audit found this PR is wrong in a way my own repro hid. Posting the reasoning rather than silently force-pushing.

1. The lift can never fire. Main's closedAgentStatusTabIds has exactly one writer chain: markTabClosedForAgentStatus ← dropStatusEntriesByTabPrefix ← the agentStatus:dropByTabPrefix IPC ← sweepRetiredTerminalTabState ← only closeTab. The host-retraction path (STA-4593) uses the pure patch builder and sends no IPC — so the scenario my commit message described ("a tab retracted by a transient or subset snapshot frame") never marks main at all. That is precisely why STA-4593 lifted only the renderer half: there was no main half standing.

2. The ids I pass can't match anything main holds. liftedTabIds come from mirroredTerminalIds, all of form web-terminal-*. That prefix appears in src/main/ only in tests, and main's own suite asserts it never reaches a pane: expect(spawnedEnv.ORCA_TAB_ID).not.toMatch(/^web-terminal-/) (orca-runtime.test.ts:15580).

3. If it were "fixed" to actually fire, it would regress STA-4114. #14624 lifted the pane fence and explicitly refused the tab one: "Closed-tab tombstones are a separate, stronger claim and are deliberately left standing… a live process is never routed back into a closed tab." Main's set also gates restorePaneAuthority (server.ts:2038). #6095 additionally records that a time-boxed variant was tried and rejected — "manual validation proved a 15-second suppression window was too short."

4. My tests were vacuous against production. Both used 'tab-1', a plain local id the production call site cannot produce. The control arm made them honest as unit tests, but they cannot fail for any production-reachable input.

On my repro: the blackhole I demonstrated was real, but I induced it by calling dropAgentStatusByTabPrefix directly on a live tab — which no production path does. I proved a consequence, not a reachable bug. That distinction is on me.

What survives as a genuine gap, for a future PR: nothing pins STA-4114's guarantee against a lift — adding liftClosedAgentStatusTabs and calling it makes restorePaneAuthority return true for a closed tab and no test catches it. A regression pin there is worth having independent of this change.

The Claude/Cursor status outage this was chasing is explained and fixed by #16980 (a green test run was deleting the developer's real ~/.claude and ~/.cursor hooks). Converting this to draft.

… comes back (STA-5679)

`getAgentStatusDisposition` suppresses every hook event whose tab id sits in
`closedAgentStatusTabIds`, and that check runs FIRST — before the pane-key gate
and before any new-turn revive. Unlike `closedAgentStatusPaneKeys`, which a
`UserPromptSubmit`/`SessionStart` lifts, the tab set was add-only: nothing but a
1024-entry LRU eviction or a full server reset ever cleared it.

A tab retracted by a transient or subset snapshot frame — a host restart, a
cross-host collision replay — is therefore blackholed for the rest of the
session even though it is still open and its agent is still working. The POSTs
keep returning 204, so nothing anywhere reports a problem.

The renderer already knew: `buildRemirroredClosedTabMarkerLiftPatch` lifts its
own marker when a mirrored id is republished, commented "a lingering closed-tab
marker would blackhole its byte-derived status for the whole session". But main's
set is checked upstream of the renderer, so lifting only the renderer half could
never reach it.

Give main the other half of that lift: `liftClosedAgentStatusTabs()` behind an
`agentStatus:liftClosedTabs` IPC, called with exactly the ids the renderer just
proved alive. This does not weaken the `re-attach does not lift a closed-tab
tombstone` rule — a re-attach is an inference, whereas a republished mirrored id
is the host reporting the tab exists.

Reproduced before the fix on a live pane: UserPromptSubmit, SessionStart and
PreToolUse all returned 204 with no row, while the tab stayed open.
…igger

The lift's only production trigger — the renderer re-mirror call — and the IPC
handler that carries it had no test: reverting either left every suite green.
The server-side method was the sole covered hunk.

Also adds liftClosedAgentStatusTabs to the agentHookServer double; the handler
swallows its own errors, so the omitted field would have passed vacuously.
@brennanb2025
brennanb2025 force-pushed the brennanb2025/sta5679-suppression-gate branch from 573eea7 to 22685e4 Compare August 28, 2026 08:27
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review r1 — rebased, validated, and given the coverage its wiring was missing

Head is now 22685e4f31 (rebased onto current main, plus one review commit). The fix itself is unchanged — I did not supersede it. It is the right fix at the right layer, and the shape matches how this file already revives the sibling pane-key gate.

What the bug actually is

getAgentStatusDisposition checks closedAgentStatusTabIds first and returns suppress before ever reaching the pane-key gate below it — and that pane-key gate is the one with a revive path. So a tab retracted by a transient/subset mirror frame stayed suppressed for the whole session, upstream of the renderer, and every later hook 204'd into nothing.

Rendered proof (real hook events through the live main process)

Posted genuine /hook/cursor events at a real pane in a dev instance, driving the actual gate:

phase sidebar
1 — healthy row shows QA: phase 1 healthy - Cursor
2 — after the tab is marked closed, agent keeps reporting row is frozen on the stale phase-1 text — the new event was swallowed
3 — after the lift row updates to QA: phase 3 after lift - Cursor

healthy
blackholed
restored

Phase 2 is the important frame: the row does not vanish, it goes stale, which is exactly the "no loading status" symptom users report.

The gap I found and fixed

Ablating each production hunk individually: only the server method was covered. Reverting the IPC handler, or the renderer re-mirror call that is the fix's only production trigger, left every suite green. The two new server tests exercise a method called directly on the object — they would still pass if nothing ever called it.

Added in 22685e4f31:

  • agentStatus:liftClosedTabs IPC tests in src/main/ipc/agent-hooks.test.ts, mirroring the existing dropByTabPrefix block — all 5 go red when the handler hunk is reverted.
  • An assertion in the existing re-mirror test that the main-process half of the lift actually fires; it goes red when the renderer call site is reverted.
  • liftClosedAgentStatusTabs added to the agentHookServer double — the handler swallows its own errors, so the omitted field would have passed vacuously.

Mutation-tested the original two tests: making the method a no-op kills one, making it clear the whole set kills the other. They are non-redundant. Pre-fix both fail with liftClosedAgentStatusTabs is not a function.

Scope — worth stating plainly

The lift fires only for worktrees bound to an explicit runtime environment: every snapshot path in useWebSessionTabsSync is gated on getExplicitRuntimeEnvironmentIdForWorktree, so a purely local workspace never reaches it. That is correct — sweepRetiredTerminalTabState has exactly two callers, a genuine local tab close (where the id really never recurs) and the mirror retraction (where it can) — but it means this PR fixes the paired/remote variant, not the plain-local repro in the ticket. #16980 is the one that addresses the local cause (hooks being deleted). The two are complementary, share no files, and can land in either order.

Follow-up worth filing (not blocking)

That same retraction sweep tears down five things under a documented "retired tab ids never recur" assumption. On the mirror path that assumption is false, and this PR restores only one of the five — the parked-terminal watcher, pane-foreground-agent map, hibernation output epoch, and agent-startup delivery guards stay torn down for a tab that comes back.

Gates

pnpm tc clean, oxlint clean on all affected dirs, target suites green. The 10 pty-* failures in a full local run are pre-existing — they reproduce identically on the untouched PR head in a clean worktree and CI's shards are green.

Not verified: dev-mode CDP is blind to packaged-build differences, so the rendered evidence above does not cover a packaged build. I also did not exercise a real paired remote host end to end — the phase-2/3 transition was driven through the real IPC and hook path, but the natural trigger is the snapshot re-mirror.

Not approving or merging — that is Brennan's call.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Rendered QA (adversarial) — #16985 @ 22685e4f311e8ddc6cf9a7bd44756f67697e1a4c

Ran on an isolated dev Electron built from this head: own ORCA_DEV_USER_DATA_PATH, own CDP port (9412), own renderer port (5175), ownership proven on every attach with the /@fs 200-vs-403 pair. I exercised the paired/remote variant, which is the one this PR actually fixes.

16985-evidence.png

How it was driven

Nothing here is mocked on the main side. The observable is window.api.agentStatus.getSnapshot() — the real AgentHookServer state in the live main process — and the stimulus is a real HTTP POST to the running hook server (raw-json transport, real token from endpoint.env), i.e. exactly what an agent's hook does.

step subject tab control tab
A baseline, neither closed working / "baseline-subject" working / "baseline-control"
B both retracted (dropByTabPrefix) NULL — blackholed NULL — blackholed
C real applyFreshWebSessionTabsSnapshot re-mirrors only the subject renderer marker cleared, liftClosedTabs fired (not re-mirrored)
D POST a hook to both again working / "AFTER-REMIRROR-subject" ✅ NULL — still suppressed ✅

Step A matters: without a green baseline the whole differential would be vacuous. It's green, so B is a genuine reproduction of STA-5679 and D is a genuine behavioural change.

The chain exercised end to end is the entire PR: web-session-tabs-sync.ts → preload liftClosedTabs → agentStatus:liftClosedTabs ipcMain handler → agentHookServer.liftClosedAgentStatusTabs → getAgentStatusDisposition gate → hook ingest. Step C called the real production entry point the paired path uses (applyFreshWebSessionTabsSnapshot), not the IPC directly, so the renderer-side trigger is covered too.

The control arm is the important half: the tab that was never re-mirrored stays suppressed. The lift is scoped to the named tab and is not a global reset of the closed-tab set.

I also confirmed the preload surface is live in the running app — liftClosedTabs is present on window.api.agentStatus.

Scope, stated plainly

The only production caller of applyWebSessionTabsSnapshot is web-runtime-session.ts:1145, keyed by a runtime environmentId. So this fixes the paired/remote variant and nothing else — consistent with what the earlier review concluded. My step B→D control arm is effectively the plain-local shape: a tab retracted with no snapshot re-mirror stays blackholed for the whole session. A green on the local path would have proved nothing about this PR, and I did not claim one. That variant remains #16980's job.

What I could not cover

  • A genuine remote host. There was no real paired Orca server in this rig; the snapshot in step C was synthesized to the shape the host publishes and fed to the real production function. The renderer→main→hook-server half is fully real; the host→renderer wire hop is not exercised here.
  • Packaged builds — dev-mode CDP is structurally blind to them. No packaged coverage claimed.
  • Retraction via a real transient/subset host frame. I retracted with dropByTabPrefix, which is the same main-side state the retraction path produces, but not the frame-driven route that creates it in the wild.

No production code was changed and nothing was merged, approved, or closed.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Closing

Retracted above with the full reasoning: this lift can never fire (main's closedAgentStatusTabIds is written only by closeTab, never by the host-retraction path, and the web-terminal-* ids it passes never reach main), and if it were made to fire it would regress STA-4114, which deliberately left closed-tab tombstones standing.

The real fix for the status problems this was chasing is #17619. Closing so nobody picks this up as merge-ready.

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