Skip to content

[Bug]: Editor tabs for deleted/duplicated files resurrect across restarts (duplicate OpenFile records + UI-focus owner leak) #21122

Closed as duplicate

Description

@BearMett

Summary

Editor tabs for files that were closed (or that no longer exist on disk) keep coming back, and one file can accumulate several tab/record identities. Consolidates the mechanisms behind #17185, #17778 and the duplicate-tab half of #16205, and explains why the restore-time dedupe from #17370 was not enough.

Environment

Reproduction

  1. Pair a remote runtime environment and focus it in the UI (active workspace execution host or settings.activeRuntimeEnvironmentId).
  2. Open a file that belongs to a local worktree (for example via a path printed in a terminal).
  3. Close the tab. Press Ctrl+Tab (default tab.previousRecent), or restart.
  4. The tab returns. Over a session the same file accumulates more copies.

Evidence (real persisted store, paths scrubbed)

workspaceSession.openFilesByWorktree for one local worktree key:

filePath runtimeEnvironmentId count
…/kahi-2026-07-qty-by-listing.csv 107a064f-… (a paired remote runtime) 2
…/brand-requests.txt null 1
…/brand-requests.txt 107a064f-… 3

unifiedTabs holds a composite editor:<worktreeId>:107a064f…:<path> tab and a plain-uuid tab for the same path in the same group, all stamped executionHostId: "runtime:107a064f-…" although the paths are local. tabGroups[].tabOrder / recentTabIds reference both, and activeFileIdByWorktree names one of them.

The five rolling orca-data.json.bak.N files show duplicate counts growing within a session and collapsing at each restart (csv/txt: 2/0 → 1/0 → 1/1 → 1/1 → 2/1 → 2/3). This is a running leak, not a one-off corruption.

Root cause — four independent defects

  1. Persisted records carry no id. buildEditorSessionData (src/renderer/src/lib/workspace-session.ts) serializes OpenFiles without their id, so N live records sharing (worktreeId, filePath, runtimeEnvironmentId) persist as N identical rows. Restore collapses them to one; the next session mints them again.
  2. UI focus leaks into ownership. open-file-apply.ts only captures operation provenance for writable edit opens; if captureEditorFileOperationProvenance throws, the catch falls through to file.runtimeEnvironmentId ?? settings.activeRuntimeEnvironmentId. tabs-create-actions.ts stamps Tab.executionHostId from activeWorkspaceExecutionHostId. Both are focus signals, not ownership, so a local worktree's file gets a remote owner and a second identity.
  3. Orphan OpenFiles are never pruned. Hydration dedupes tabs per (groupId, entityId) but keeps every OpenFile; tabs-reconciliation.ts prunes tabs without a file but never files without a tab. tabs-surface.ts's last-resort fallback (state.openFiles.find(worktreeId match)) then selects the orphan — the visible resurrection.
  4. closeFile closes one identity only. It removes the passed fileId, then finds the first unified tab with that entityId and breaks. Sibling records stay in openFiles, tabOrder, recentTabIds; Ctrl+Tab (handleSwitchRecentTab → activateCyclableTab → setActiveFile) reactivates them with no membership check. setActiveFile also accepts an id with no backing record.

Why #17370 was not enough

It dedupes at restore time by the exact (filePath, worktreeId, runtimeEnvironmentId) tuple. It cannot see owner-mismatched siblings (null vs 107a064f…), does nothing about live minting during a session, and leaves orphaned OpenFiles in place.

Proposed fix (stacked)

  • PR 1 — stop the leak, heal existing stores, make close atomic: dedupe indistinguishable records on the write path; at hydration normalize owners to the worktree's stamped route (only when it resolves, never for read-only/live-tail/external-SSH records, and keeping divergent unsaved drafts apart), redirect superseded ids onto the survivor, correct contradicting tab executionHostIds, prune clean orphan OpenFiles; closeFile sweeps every same-document sibling and all of its tabs with one recently-closed snapshot; setActiveFile ignores ids with no record.
  • PR 2 — one owner per editor file: derive the owner from the worktree's operation route instead of UI focus in open-file-apply.ts and tabs-create-actions.ts; align the runtime mirror's editor ids with the owner-qualified id scheme.
  • PR 3 — missing-file UX: show a "file missing" state with Retry / Close for local documents instead of an empty editor; never prune remote records on loss of contact (unverifiable per docs/reference/ssh-execution-boundary.md).

Reproducing against your own store

The heal PR adds tests/tools/editor-ghost-tab-heal-repro.mjs. It copies an orca-data.json into an isolated user-data directory (the original is only read), auto-detects (worktree, path) pairs with more than one OpenFile record or mixed runtimeEnvironmentId owners, launches Orca in the background, and prints before/after snapshots around close + Ctrl+Tab, the flushed session and a restart pass:

npx electron-vite build --mode e2e
ORCA_BACKGROUND_LAUNCH=1 node tests/tools/editor-ghost-tab-heal-repro.mjs --data <copy-of-orca-data.json>

Out of scope / related

Metadata

Metadata

Assignees

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