Skip to content

fix(session): let a populated ssh partition own its workspace over leftover local tabs - #23394

Closed
tlgnkl wants to merge 4 commits into
stablyai:mainfrom
tlgnkl:fix/ssh-partition-authoritative
Closed

tlgnkl wants to merge 4 commits into
stablyai:mainfrom
tlgnkl:fix/ssh-partition-authoritative

Conversation

@tlgnkl

@tlgnkl tlgnkl commented Sep 27, 2026 •

Copy link
Copy Markdown

ELI5

Orca keeps two copies of the tab list for a workspace on an SSH host: an old one in the general local store and the real one in the host's own store. At startup Orca picked the old copy whenever it had any tabs at all. So tabs you closed weeks ago came back as empty shells, and live agent tabs could disappear. Now, when the host's own copy has tabs, Orca uses that copy.

What Changed

Before. adoptStrandedHostPartitionSession never touched a workspace if the base (local) held at least one terminal tab for it. For long-lived SSH profiles, local is the stale side: it is never pruned for SSH worktrees. The same rule runs in two places:

After. When the ssh:<targetId> partition holds a populated tab row for the workspace (and the id is not contested), the workspace is adopted from the host partition:

  • The host's rows replace the base's, including unifiedTabs, tabGroups and the close tombstones.
  • A base tab is kept only if the host neither lists it nor has tombstoned it, and either:
    • it still names a PTY (a live terminal the owning partition never heard about), or
    • it was created after the host's newest evidence for that workspace: the newest host tab's createdAt or the newest host close tombstone's closedAt. This covers a tab created while the host was unresolved: it is parked with ptyId: null until the host resolves, and nothing about the parking is persisted. Legacy leftovers are older than the host's rows, so they are still dropped.
  • A tombstone always wins, even over a newer tab.
  • Carried tabs get a unified entry (the base's own, or one built the way legacy hydration builds it), so they keep a surface.
  • Once adopted, the workspace routes back to the SSH partition on the next write. The stale local row stops being rewritten.

Unchanged:

The change is in the shared function, so the boot read and the publish fallback agree by construction.

Why

Linked Issue

Fixes #23390
Fixes #22503

Visual Proof

N/A. This changes which persisted copy session hydration and the remote publish read. The visible result (no resurrected shells, no vanished agent tabs after restart) depends on a long-lived profile plus a live SSH host and cannot be reproduced in a screenshot from a fresh profile. The behaviour is covered by the unit and round-trip tests below.

Testing

  • New table tests for adoptStrandedHostPartitionSession (src/shared/workspace-session-stranded-partition-adoption.test.ts):

  • Updated the two boot-hydration tests that encoded the old rule. Added cases for leftover PTY-less rows and a live local tab.

  • A new publish-path test against the real Store: remoteWorkspace:setForConnectedTargets with no session publishes the SSH partition's tabs, not a leftover local row.

  • All the new tests fail on main and pass with the change.

  • Ran vitest over src/shared, src/main/ipc, src/renderer/src/lib, src/renderer/src/hooks and src/renderer/src/store/slices: 24,774 passed. The 5 failures also fail on a clean main in my environment (node-pty spawn unavailable, one message assertion). After the review fixes I re-ran the session / remote-workspace / tab-group / hydration suites: 489 passed. pnpm tc passes, and so do oxlint, oxfmt and pnpm run check:code-quality:changed.

  • Why the lint/test/build box below is unchecked: I ran pnpm tc, oxlint, the changed-code quality gate and targeted plus broad vitest, but not the full pnpm lint, the full pnpm test or pnpm build. Native modules (node-pty) don't build on this machine (Xcode license not accepted), so deps were installed with --ignore-scripts. CI should cover the rest.

  • Not verified: a manual run against a live SSH host with a profile that has leftover local rows. That's the next thing I'll check on my own install.

  • I manually tested these changes locally

  • Automated tests added/updated, or explained why not below

AI Disclosure

Claude Opus 5.5 (Claude Code) wrote the code and tests; reviewed by me.

Review

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

  • Cross-platform: pure session-state logic, no paths or platform APIs.
  • SSH/remote: this is the SSH partition read and publish path. Covered above.
  • Backwards compatibility: reads existing persisted data only. No schema change. Profiles without leftover local rows behave exactly as before.
  • Overlap: fix(GAP-03): stop offline-client reconnect from resurrecting server-closed tabs #22334 edits the same file for the opposite shape (base empty, stale host rows after an offline reconnect). The two changes touch different branches of the rule, but a rebase conflict is likely whichever lands second.
  • Known trade-off, same tab id in both partitions: the host row wins wholesale, including its ptyId. If local recorded a newer respawn that the host partition hasn't written yet, that binding is lost and the pane goes through the normal reattach/recovery path. I didn't take the local ptyId over a null host one: on the affected profiles the local bindings are the stale side (dead relay instances).
  • Known limit of the time bound: a tab parked on an unresolved host, followed by another tab that did reach the host partition before a restart, looks older than the host's evidence and is dropped. That needs both events inside one offline window before a crash.
  • Known limit of the draft tab id: editor hydration tracks used paths across all worktrees at once, while the carryover predicts the id from its own row. They can disagree only when the same absolute file path is open in two different worktrees; that global path index predates this PR.
  • Follow-ups (not in this PR):

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

Update after review

  • For tabs the host row lists, the host's tab- and pane-keyed records (terminal layouts, reattach ids, PTY incarnations) now replace the base's. Carried base tabs keep their own records.
  • The unified row of a host-won workspace drops the entries of discarded base tabs even when the host has no unified row of its own. Every surviving terminal gets an entry stamped with its own worktreeId, and new entries are appended to the chosen group's tabOrder.
  • Base-only entries in a replaced openFilesByWorktree / browserTabsByWorktree row are no longer dropped wholesale: an unsaved editor draft is always kept, and a browser workspace is kept under the same time bound as terminal tabs. The host copy still wins every entry it lists. This also covers the pre-existing "base has no terminal tabs" path, whose round-trip test encoded dropping a draft.
  • A carried draft's unified entry is keyed by the id editor hydration gives the file, so a same-path file under another runtime cannot leave it without a tab. stranded-partition-carried-draft-hydration.test.ts runs adoption through the real tab and editor hydration to pin this.
  • Carried entries are filed under a tab group that exists; when the workspace has none, one is created (never for a legacy-format session without tabGroups).
  • Not changed: matching differently shaped keys (bare vs worktree:) for the same workspace. That behavior predates this PR and is left for a follow-up.

🤖 Generated with Claude Code

…ftover local tabs

adoptStrandedHostPartitionSession kept the base (`local`) copy of any workspace it held terminal
tabs for. `local` is never pruned for an SSH workspace, so on long-lived profiles it is the stale
copy: boot restored tabs closed weeks ago as fresh shells and dropped live agent tabs whose
unified rows live in `ssh:<targetId>` (stablyai#23390), and the main-side publish fallback uploaded the
same stale rows to the host snapshot on every sync (stablyai#22503).

When the host partition holds a populated tab row for the workspace, it now owns it. A base tab
the host neither lists nor tombstoned survives if it still names a PTY, or if it was created after
the host's newest tab or close for that workspace (a tab parked on an unresolved host has no PTY
and no persisted marker). Carried tabs get a unified entry so they keep a surface. An empty host
row or a contested id keeps the old behaviour.

Fixes stablyai#23390
Fixes stablyai#22503

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 31156559-ea70-47ca-a136-814969462f7a

📥 Commits

Reviewing files that changed from the base of the PR and between 6e16be7 and 4e8c030.

📒 Files selected for processing (8)
  • src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts
  • src/renderer/src/store/slices/editor/file-ids/editor-file-ids.ts
  • src/renderer/src/store/slices/stranded-partition-carried-draft-hydration.test.ts
  • src/shared/editor-file-id.ts
  • src/shared/workspace-session-base-row-carryover.test.ts
  • src/shared/workspace-session-base-row-carryover.ts
  • src/shared/workspace-session-base-tab-carryover.ts
  • src/shared/workspace-session-stranded-partition-adoption.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 8 remain after this review.


📝 Walkthrough

Walkthrough

SSH partition adoption now uses populated host tab rows to determine workspace ownership. The adoption flow carries eligible base tabs, dirty editor files, and newer browser workspaces into adopted host rows. It also reconciles host records, unified entries, and group ordering. Tests cover tab selection, tombstones, record replacement, carried drafts, hydration, SSH round trips, and exported tab IDs.

Priority: ➖ Normal

Severity of issue fixed: Medium

Merge Risk: 🟡 Moderate · up to 4e8c0

Adopting an SSH session can retain stale local tabs alongside the host tabs when their workspace row keys differ. Reconcile those rows before merging unless this behavior is explicitly accepted.

Security Architecture Review

Security architecture risk: 🟡 Moderate · up to 4e8c0

The host-first rule addresses stale local tabs, but equivalent workspace keys can leave both the old and host tab rows in the restored session. That may undermine the intended recovery of closed and live tabs. No new attacker-controlled access path has been established.

Retained concerns

  • Medium · reliability · inferred: Host ownership is decided by normalized workspace identity, but replacement and tombstone reconciliation use exact row keys. Differently spelled keys for the same workspace can leave a stale base tab row beside the adopted host row, weakening the intended close-and-reconnect recovery.
Security review details

Security Blast Radius

  • inferred — The affected state is the session for an SSH workspace across its local and host partitions. The inspected change does not establish cross-target access or a new privileged entrypoint.

Trust Boundaries and Controls

  • observed — Foreign and contested workspace filtering precedes host-row adoption. Tombstones control eligible base-tab carryover, but that control is not applied to a base row left under a different key.

Resilience and Maintainability Implications

  • inferred — Duplicate rows can frustrate close-state and agent-tab recovery within one workspace. The inspected evidence does not establish which row downstream display code selects or a resulting security compromise.

Hardening Proposals

  • proposed — Reconcile all row keys representing an adopted workspace before applying host tombstones, and cover differently spelled keys through hydration, publishing, and repeated reconnects.
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR satisfies the partition-selection requirements in [#23390] and [#22503]. A populated host row replaces conflicting local rows. Close tombstones and stale local-only rows are excluded. Eligible … Change boot lease handling so a live detached PTY is not expired only because its tab has no surface. Resurface the tab or report it as an orphan. Add automated coverage for this boot condition.
Docstring Coverage ⚠️ Warning Docstring coverage is 36.73% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 49 functions across 11 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Out of Scope Changes check ✅ Passed The adoption helpers, editor ID adjustment, draft and browser carryover, unified-row reconciliation, and adoption, hydration, and publish tests directly support [#23390] and [#22503]. No unrelated cha…
Title check ✅ Passed The title clearly and concisely describes the main change: allowing a populated SSH partition to take ownership over leftover local tabs.
Description check ✅ Passed The description is detailed and follows the required template. It explains the user impact, implementation, rationale, linked issues, testing, compatibility considerations, and known limitations. Some…
Full details: Linked Issues check

Explanation

The PR satisfies the partition-selection requirements in [#23390] and [#22503]. A populated host row replaces conflicting local rows. Close tombstones and stale local-only rows are excluded. Eligible local PTY rows, newer entries, drafts, and browser entries are carried with unified-row reconciliation. The publish fallback also selects the populated host partition. The remaining [#23390] requirement is unmet: the changes do not update lease-expiry handling or add a test that preserves a live detached PTY when its tab has no surface at boot.

  • Fix all pre-merge checks with AI

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.

@coderabbitai coderabbitai 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.

Actionable comments posted: 5


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 7a08fd16-9f32-4eb5-a6f3-346c4c227d83

📥 Commits

Reviewing files that changed from the base of the PR and between 27b823f and 92a306d.

📒 Files selected for processing (5)
  • src/main/ipc/ssh-host-partition-session-export.test.ts
  • src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts
  • src/shared/workspace-session-base-tab-carryover.ts
  • src/shared/workspace-session-stranded-partition-adoption.test.ts
  • src/shared/workspace-session-stranded-partition-adoption.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 9 remain after this review.

Comment thread src/shared/workspace-session-base-tab-carryover.ts Outdated
Comment thread src/shared/workspace-session-base-tab-carryover.ts Outdated
Comment thread src/shared/workspace-session-stranded-partition-adoption.ts
Comment thread src/shared/workspace-session-stranded-partition-adoption.ts Outdated
Comment thread src/shared/workspace-session-stranded-partition-adoption.ts Outdated

@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.

Important

One behavior change needs confirmation before merge: when both partitions hold tabs for a non-contested workspace, the host's non-tab rows now replace the base's wholesale, which can drop a local-only unsaved editor draft. Details inline.

Reviewed changes

This PR makes a non-contested ssh:<targetId> partition authoritative over the leftover local row once the host holds any terminal tabs for the workspace. It fixes #23390 / #22503.

  • Ownership rule flipped — workspacesTheBaseOwns now excludes workspaces the host also populates, so a "both populated" workspace becomes adoptable instead of being left alone.
  • Tab carryover added — baseTabsTheHostNeverListed keeps base tabs the host never listed or tombstoned, bounded by a PTY binding or createdAt > host's newest evidence; attachCarriedTabsToUnifiedRows re-homes those into the host group so they keep a surface.
  • Read and publish agree — the main-process persistedSessionForTarget fallback calls the same shared function, so the boot read and the upload no longer disagree.
  • Tests — table tests for the adoption rule, updated round-trip tests, and a new publish-path test.

I read the complete diff, traced adoptRecord / the ownership table, and verified the new suite passes locally (15/15). One concern is inline; a latent key-spelling fragility is below.

ℹ️ Nitpicks

  • baseTabsTheHostNeverListed is invoked with next.tabsByWorktree[key] where key is the host's raw key, while ownership in the same function normalizes keys (workspacesWithTerminalTabs, adopts). If a base row were ever stored under a different spelling that normalizes to the same workspace id (the worktree:-prefixed form the session type permits), the carryover would read undefined and a duplicate base row would survive. No shipping writer produces that spelling today, so this is latent — a normalized lookup would make it consistent with the rest of the function.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Comment thread src/shared/workspace-session-stranded-partition-adoption.ts
…stent

When the host tab row replaces the base's, its tab- and pane-keyed records
(terminal layouts, reattach ids, PTY incarnations) now replace the base's for
the tabs the host listed, so a live tab is not paired with a dead relay's PTY.
The unified row drops the entries of discarded base tabs even when the host
supplied no unified row, every surviving terminal gets an entry stamped with
its own worktree id, and new entries are appended to the chosen group's tab
order. The pane-key parse is shared through tabIdForPaneKey.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai 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.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: c51ab00f-1de9-4ebc-b0d7-fa3fc03e988e

📥 Commits

Reviewing files that changed from the base of the PR and between 92a306d and 6e16be7.

📒 Files selected for processing (4)
  • src/shared/workspace-session-base-tab-carryover.ts
  • src/shared/workspace-session-host-records.ts
  • src/shared/workspace-session-stranded-partition-adoption.test.ts
  • src/shared/workspace-session-stranded-partition-adoption.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/shared/workspace-session-stranded-partition-adoption.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 6 remain after this review.

Comment thread src/shared/workspace-session-base-tab-carryover.ts Outdated

@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 in the incremental changes — nothing actionable inline. One concern from the previous review is unchanged and still tracked in its open thread.

Reviewed changes

Reviewed the new commit since the previous pullfrog review (6e16be7014), which tightens what a host-won workspace keeps after the ownership rule change.

  • Host-won tab records now replace the base's — for tab ids the host's populated row lists, tabKeyed (terminalLayoutsByTabId, remoteSessionIdsByTabId, localOnlyScrollbackByTabId) and paneKeyed (terminalPtyIncarnationsByPaneKey) host records replace the base's instead of only filling gaps, so a live host tab is not paired with a dead relay's layout or reattach id. Carried base tabs keep their own records.
  • Unified surface reconciled against the winning row — reconcileHostWonUnifiedRows drops the unified entries of discarded base terminals even when the host supplied no unified row of its own, and gives every surviving terminal an entry (a carried tab reuses the base's own entry, re-homed into the chosen group; a host tab gets a synthesized one).
  • Group order kept consistent — added unified entries are appended to the chosen group's tabOrder, and discarded base tab ids are removed from every group, so the session is consistent before hydration's own repair runs.
  • Synthesized entries carry the terminal's own worktreeId — unifiedEntryFor now stamps terminal.worktreeId rather than the session record key, which can be a canonical worktree:<id> key.
  • Shared pane-key parse — tabIdForPaneKey is extracted next to worktreeIdForPaneKey, so the split and adoption parse a pane key identically.
  • Tests — added coverage for host-won tab/pane records, group tabOrder consistency, synthesized worktreeId, and dropping discarded unified entries.

I read the incremental range-diff and the full diff, traced the new replacement predicate and reconcileHostWonUnifiedRows against hydration's group repair (adoptGrouplessTabs / resolveTabGroupOwners), and ran the affected suites (workspace-session-stranded-partition-adoption.test.ts 19/19, round-trip + ssh export 33/33) locally — all pass.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

tlgnkl and others added 2 commits September 27, 2026 21:33
…t-won row

When a populated host row replaces the base's, the base-only entries of
`openFilesByWorktree` and `browserTabsByWorktree` were dropped wholesale,
including an unsaved `dirtyDraftContent` that nothing else can restore.
The host copy still wins every entry it lists; a base-only editor file
survives when it holds an unsaved draft, and a base-only browser
workspace survives under the terminal tabs' time bound. Carried entries
get a unified surface like carried terminals do.

The unified reconcile now files additions under a group that exists,
and creates that group when the workspace has none, instead of pointing
entries at a `carried:<key>` group missing from `tabGroups`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… file

A carried editor entry was keyed by the bare file path, but editor
hydration keys a file by owner: a runtime-owned file always gets its
owned id, and when a same-path file for another runtime hydrates first,
a bare-path tab is migrated to that file. So a draft whose path the host
also held under another runtime came back in `openFiles` with no tab.

The carried entry now takes the id hydration will match to the file,
the base's own entry is re-keyed to it, and dedupe uses the same id.
`buildOwnedEditorFileId` and `runtimeOwnerKey` move to /shared so the
session read path and the editor slice share one definition.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@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 critical issues — one minor suggestion inline.

Reviewed changes

Reviewed the incremental delta since the previous pullfrog review (6e16be70): commits 41b42354 and 4e8c030d, which close the "populated host row drops base-only editor/browser entries" concern.

  • Carried base-only drafts and newer browsers into a host-won row — new carryBaseEntriesIntoHostRows folds surviving base entries back into the replaced openFilesByWorktree / browserTabsByWorktree rows; the host copy still wins every entry it lists, a base-only file survives only when it holds an unsaved draft, and a base-only browser survives under the terminal tabs' time bound.
  • Keyed a carried draft's unified entry by the id hydration gives the file — extracted editor-file-id.ts to /shared; hydratedEditorTabId predicts hydration's id (owned id vs bare path, including the local-after-runtime legacy-migration case) and surfaceAdditions re-keys the base's own entry to it.
  • Hardened the unified-group reconcile — a carried entry's group is created (carried:<key>) only when the session has tabGroups at all, and the target group is chosen only among groups that exist, so a stale active-group pointer cannot file entries under a missing group.
  • Shared pane-key parse — tabIdForPaneKey extracted and reused by the pane-keyed replace predicate.
  • Tests — new carryover table tests, a real tab/editor hydration suite that pins the carried-draft tab id, and expanded round-trip and ssh-export coverage.

I read the incremental range-diff and the full diff, traced carryBaseEntriesIntoHostRows / replacedRowKeys / hydratedEditorTabId against the real hydration path, and ran the affected suites locally: adoption (19), base-row-carryover (13), round-trip (31), ssh-export (3) and real-hydration (4) all pass; tc:web is clean and oxlint reports nothing.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Comment on lines +138 to +145
const listed = new Set(hostFiles.map(openFileIdentity))
// Why read-only files are excluded: hydration discards their drafts, so there is nothing to keep.
return (baseFiles ?? []).filter(
(file) =>
file.dirtyDraftContent !== undefined &&
file.readOnly !== true &&
!listed.has(openFileIdentity(file))
)

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.

unsavedBaseFiles filters base records against the host row but does not de-duplicate among the base's own records, so two records with the same openFileIdentity both get carried. Hydration keeps only the first (the persistence schema permits a repeated (runtime, path) tuple — store-session-editor-hydration.test.ts:415), so the second carried entry yields a second unified tab with the same id while its own draft is never restored. De-duplicating by openFileIdentity while filtering keeps the carryover one-entry-per-owner, matching hydration.

Technical details
# Carried editor entries are not de-duplicated by identity

## Affected sites
- `src/shared/workspace-session-base-row-carryover.ts:134-146` — `unsavedBaseFiles` returns every base record the host does not list; repeated `(runtimeOwnerKey, filePath)` records are emitted twice.
- `src/shared/workspace-session-base-row-carryover.ts:49-61` — `carried.forEach` emits one `CarriedSurface` per record.
- `src/shared/workspace-session-base-tab-carryover.ts:189-223` — `surfaceAdditions` tests `row` only, not the additions it has already built, so both surfaces become entries with the same id.
- `src/renderer/src/store/slices/editor/actions/hydrate-editor-session.ts:52-67` — `hasOwner` drops the second record; one `OpenFile` is restored and its `editorDrafts` entry is keyed by the shared id.

## Required outcome
- At most one carried entry per `openFileIdentity`, matching hydration's one-open-file-per-owner rule.

## Suggested approach (optional)
- Track seen identities inside `unsavedBaseFiles` (see the suggestion below), or de-duplicate `carried` by identity before building surfaces.

## Evidence
- Reproduced via `adoptStrandedHostPartitionSession` with two base files `{ same path, dirtyDraftContent: 'a' }` and `{ same path, dirtyDraftContent: 'b' }` next to a non-empty host row: `openFilesByWorktree` gains both, and `unifiedTabs` gains two editor entries with the same id.
Suggested change
const listed = new Set(hostFiles.map(openFileIdentity))
// Why read-only files are excluded: hydration discards their drafts, so there is nothing to keep.
return (baseFiles ?? []).filter(
(file) =>
file.dirtyDraftContent !== undefined &&
file.readOnly !== true &&
!listed.has(openFileIdentity(file))
)
const listed = new Set(hostFiles.map(openFileIdentity))
const carried = new Set<string>()
// Why read-only files are excluded: hydration discards their drafts, so there is nothing to keep.
return (baseFiles ?? []).filter((file) => {
const identity = openFileIdentity(file)
// Why one per identity: hydration keeps only the first of a repeated (runtime, path) record, so a
// second carried entry adds nothing but a duplicate tab.
if (
file.dirtyDraftContent === undefined ||
file.readOnly === true ||
listed.has(identity) ||
carried.has(identity)
) {
return false
}
carried.add(identity)
return true
})

@Jinwoo-H

Jinwoo-H commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

Thank you for the detailed diagnosis here and in #23390. It pinpointed the problem: stale local rows were overriding the SSH partition at startup.

We fixed it in two smaller PRs:

Because those cover the same behavior, I'm closing this one. Thanks again.

@Jinwoo-H Jinwoo-H closed this Oct 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants