Skip to content

fix: close headless terminal tabs whose persisted session record is missing - #12922

Closed
tozes wants to merge 5 commits into
stablyai:mainfrom
tozes:fix/headless-close-missing-persisted-tab
Closed

tozes wants to merge 5 commits into
stablyai:mainfrom
tozes:fix/headless-close-missing-persisted-tab

Conversation

@tozes

@tozes tozes commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A terminal tab that exists in the live mobile-session snapshot but has no persisted workspace-session record can never be closed:

Every close path funnels into closeHeadlessMobileTerminalTab, which runs in this order:

  1. removePersistedHeadlessTerminalTab — throws tab_not_found when the persisted row is missing.
  2. PTY kill — never reached.
  3. Live snapshot prune + republish — never reached.

The drift has two observed origins: a background tab that failed reveal and was never persisted (#10747), and a prior close that removed the persisted row and then failed on a later step (daemon logs show session-kill-failed), leaving the live snapshot holding a tab whose persisted row is already gone (#12920).

Fix: by the time closeHeadlessMobileTerminalTab runs, closeMobileSessionTab has already resolved the tab from the live snapshot — the tab provably exists. A missing persisted row at that point can only mean drift, and refusing the close can never repair it. So treat a missing row as "already de-persisted" and continue with the PTY kill and snapshot prune. This is exactly what the existing allowMissingPersistedTab escape hatch already did for the relay-recovery caller; this PR makes that behavior unconditional and removes the option. It also makes the interrupted-close origin self-healing: the next close attempt completes the transaction instead of failing forever. The terminal_tab_pinned and workspace_session_unavailable guards are unchanged.

Screenshots

No visual change.

Testing

  • pnpm lint
  • pnpm typecheck
  • pnpm test — 46,990 passed, 11 failed. The 11 failures reproduce identically on clean main (f713739) in the same environment (Linux, Node 22, checkout on /tmp): they are host-environment-sensitive suites (shell prompt-hook builders, GPU flag config, git filesystem-boundary discovery), none of which import the changed module.
  • pnpm build
  • Added or updated high-quality tests that would catch regressions, or explained why tests were not needed

New regression test in orca-runtime.test.ts: warms the live snapshot via listMobileSessionTabs, drops the persisted row out-of-band, then closes. It fails with tab_not_found before the fix (verified by stashing the fix and re-running); with the fix it verifies both PTYs are killed and a re-list shows no resurrected tab.

AI Review Report

Reviewed with Claude Code (Claude Fable 5). Main risks checked:

  • Callers relying on the removed throw. removePersistedHeadlessTerminalTab has exactly one caller (closeHeadlessMobileTerminalTab), and all of its call sites are inside closeMobileSessionTab, which resolves the tab from the live snapshot before any of them run. A repo-wide search for tab_not_found consumers found one functional matcher (remote-runtime-pty-transport.ts isMissingHostSessionSurfaceError), which guards the session.tabs.activate path — activation still throws tab_not_found for tabs absent from the snapshot (closeMobileSessionTab line ~7749 is unchanged); only the close-side de-persist step stops throwing.
  • Close authorization unchanged. The refusal branches (live-host-pty echo protection, retirement-owner boundary, unattributed-close intent check) all run before the modified code; this change only affects post-authorization cleanup.
  • Kill scope. With a missing persisted row, projectedPtyIds is empty; PTYs to kill are collected from the live snapshot with the existing other-owner exclusion, so no PTY owned by a surviving tab can be killed.
  • Pinned tabs. terminal_tab_pinned still refuses when the persisted row exists and is pinned. A pinned tab whose persisted row is gone becomes closable — that state is drift, and the pre-fix alternative was an unclosable immortal tab.
  • Topology revision. No persisted mutation happens in the missing-row case, so no revision bump is needed; the live snapshot prune still bumps snapshotVersion and republishes, which is what paired clients key on.

Cross-platform: explicitly reviewed for macOS, Linux, and Windows impact. The change is pure control flow in the Electron main process — no paths, shell invocations, shortcuts, labels, or platform-conditional branches are touched. PTY kills go through the existing ptyController abstraction, which already encapsulates platform differences. No Electron API surface is added or changed.

Security Audit

  • Input handling: no new external input is parsed; the change consumes already-validated internal state (worktree id, tab id resolved upstream).
  • Command execution / path handling: none touched. No filesystem paths are constructed or traversed.
  • Auth / authorization: close-intent and echo-refusal checks are upstream and unchanged. A client can only close tabs the runtime already lists to it; this change does not widen who may close, only makes an authorized close complete instead of erroring.
  • Kill authority: the existing guard that treats dormant persisted local PTY ids as non-authoritative (parseAppSshPtyId filter) is preserved; the missing-row case contributes no persisted ids at all, so kill targets come only from the live snapshot with the other-owner exclusion.
  • Secrets / dependencies / IPC: no secrets handled, no dependency changes, no IPC schema changes (RPC method signatures are untouched; one internal private-method option removed).
  • Follow-up worth considering (out of scope here): reaping live-snapshot tab entries when their last PTY exits, so dead background tabs do not linger in terminal list until restart (noted in CLI terminal close can never close a background tab that failed reveal — headless close path throws tab_not_found for tabs that were never persisted #10747).

Notes

  • No platform-specific behavior. Runs entirely in the main-process runtime service.
  • Behavior change: closing a drifted tab now succeeds and reports closed: true instead of erroring with tab_not_found. Clients that surfaced that error to users will simply stop seeing it; no client relied on it programmatically (see AI Review Report).
  • The interrupted-close origin (de-persist succeeded, later step failed) becomes self-healing: the next close completes the cleanup.

Fixes #10747
Fixes #12920

🤖 Generated with Claude Code

…issing

A terminal tab present in the live mobile-session snapshot but absent
from the persisted workspace session could never be closed. Every close
path routes through closeHeadlessMobileTerminalTab, whose de-persist
step threw tab_not_found before the PTY kill and the snapshot prune, so
the runtime republished the unchanged snapshot and the tab resurrected
on every client. The drift arises from never-persisted background tabs
(failed reveal) and from a prior close that de-persisted the row and
then failed on a later step.

The caller resolves the tab from the live snapshot before this code
runs, so a missing persisted row always means drift, never a bad tab
id. Treat it as already de-persisted: continue with the PTY kill and
the snapshot prune. This also makes the interrupted-close case
self-healing — the next close completes the transaction.

Fixes stablyai#10747
Fixes stablyai#12920

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 6, 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: Pro Plus

Run ID: 5f9910dc-53a1-4180-83d6-03aebf3a9180

📥 Commits

Reviewing files that changed from the base of the PR and between 09ec516 and 12f5bd8.

📒 Files selected for processing (2)
  • src/main/runtime/orca-runtime.test.ts
  • src/main/runtime/orca-runtime.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/main/runtime/orca-runtime.test.ts
  • src/main/runtime/orca-runtime.ts

📝 Walkthrough

Walkthrough

The persisted headless terminal removal path now returns an empty result when the tab is missing. The headless terminal close flow no longer accepts or forwards allowMissingPersistedTab. The close flow retains killPtys. A regression test covers removed persisted parent state, PTY termination, and prevention of resurrected terminal tabs.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the primary fix for closing headless terminal tabs with missing persisted session records.
Description check ✅ Passed The description clearly explains the problem, fix, linked issues, testing, security, and compatibility considerations.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.

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.

@tozes

tozes commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Already reviewed.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@tozes

tozes commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

@OrcaWin A fresh Orca 1.4.184 reproduction today shows that this now also affects supervised worker cleanup: both PTYs were killed, but both close operations returned tab_not_found, the exact worktree then listed zero runtime terminals, and the user-facing tabs remained stuck. Full evidence is in issue #12920: #12920 (comment)

This PR is currently mergeable and CodeRabbit has no actionable findings. The exact-head PR Checks run is blocked at action_required: https://github.com/stablyai/orca/actions/runs/31590932003

Could you please approve the workflow run and review the current head? This is blocking reliable terminal and supervised-worker cleanup on Orca 1.4.184.

@hybridapp323

Copy link
Copy Markdown

Confirming this on v1.4.184 in headless orca serve / Remote Orca Server mode (Linux VPS host, desktop client over Tailscale). This PR's diagnosis matches our stack trace exactly, but our forensics show the throw is only the second half of the failure — there is an upstream partition-resolution defect that keeps the persisted row alive even after this fix. Data below in case it is useful for finishing the fix.

Symptom

Terminal tabs could not be closed at all. × did nothing durable, Ctrl+D killed the shell but left the tab, and every reconnect replayed the whole set. The host had accumulated 53 orphaned bash processes (all children of the daemon) and 81 persisted tab records. On one reconnect, ~50 sessions were re-created in a 2-second burst, carrying the same session ids that had been explicitly killed the previous day.

Evidence 1 — the throw, exactly as this PR describes

From the host's main.trace.ndjson: span runtime.session-tabs.close occurs 111 times and fails 111 times, all with closeReason:"user" and this stack:

Error: tab_not_found
    at OrcaRuntimeService.removePersistedHeadlessTerminalTab (out/main/index.js:190532)
    at OrcaRuntimeService.closeHeadlessMobileTerminalTab (out/main/index.js:190922)
    at OrcaRuntimeService.closeMobileSessionTab (out/main/index.js:190789)

Span terminal.close: 8 of 12 fail with the identical stack; the 4 that succeed are the direct-handle path that never consults the persisted session.

The ordering consequence this PR names is what produced the orphaned processes: the throw precedes the PTY kill, so a failed close loses both the record and the process. 53 live shells, zero of them reachable from the UI.

Evidence 2 — why the persisted row is missing: local ↔ runtime:<envId> partition drift

The row is not absent. It is in a different partition from the one the delete resolves to.

From the host's orca-data.json before cleanup:

Where Tabs
workspaceSession (the local partition) 81 (51 + 30 across two repos)
workspaceSessionsByHostId["runtime:<envId>"] 0
repos[].executionHostId (both repos) runtime:<envId>

folderWorkspaces was empty, and 103 of the 111 failing closes used the plain repo-scoped worktree key (identical to the stored key) — so this is not a worktree-key mismatch and not the folder-scope path. tryGetWorkspaceSessionHostIdForWorktree takes its non-folder branch, returns getRepoExecutionHostId(repo) = runtime:<envId>, and getWorkspaceSessionForWorktree reads that one partition, which is empty. Meanwhile getWorkspaceSessionHydrationTargets sweeps every partition, finds the 81 rows in local, and republishes them. Read scans everything; delete targets exactly one partition.

Evidence 3 — the drift regenerates (this is the part that matters for this PR)

We purged all persisted state (81 tab records and 81 terminal-history dirs removed, service restarted), then opened one fresh terminal and closed it:

13:28:23  session-created  serve-a3b556c3-...
13:28:33  runtime.session-tabs.close -> Failure: tab_not_found
13:28:52  bash pid 377643 still alive

Store afterwards: local partition = 2 tabs, runtime:<envId> partition = 0 tabs, executionHostId unchanged. So the tab-create path writes to local on a freshly purged store while the delete path resolves to runtime:<envId>. This is not legacy data — it reproduces on every new terminal.

Consequence for this PR

This change is necessary and we are shipping it locally: it guarantees a close can no longer leave an orphaned PTY, which is the worst symptom. But on its own it does not end the resurrection in serve mode — closeTerminalTabInWorkspaceSession still returns closed:false against the empty partition, so the row in local is never deleted and the hydrate sweep can re-create the tab on the next reconnect. The tab dies for the session and comes back on reattach.

The remaining half looks like it belongs in tryGetWorkspaceSessionHostIdForWorktree, which is the single resolver behind both getWorkspaceSessionForWorktree and setWorkspaceSessionForWorktree — so a fallback placed there keeps read and write on the same partition by construction. (Fixing only the getter would write the mutated local session into the runtime: partition, which is worse than the current bug.) The rule we are using locally, in the read-side-only spirit of #12722: populated candidate always wins; otherwise, if exactly one partition holds tabs for that worktree, resolve to it; under zero or 2+ matches, keep today's behaviour rather than guess.

Worth noting that in headless serve mode the runtime is the local machine, so the writer choosing local is arguably correct and the deleter consulting getRepoExecutionHostId is the actual defect — which is why we did not touch the writer.

Related: #9585, #11803, #12721, #14719 all appear to be facets of the same read/write partition asymmetry.

Happy to supply the raw trace spans or the anonymised orca-data.json if that would help.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

5 participants