From 30329e2fe1702da812c15cd371f092c183db418d Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 8 Sep 2026 03:25:31 -0700 Subject: [PATCH] fix(session): adopt every workspace the host partition names, not only tabbed ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught that gating adoption on `host.tabsByWorktree[key].length > 0` traded the #12721 deletion for a narrower one. The write path routes EVERY worktree-scoped field to the owning partition, so an SSH workspace with open editor files or browser tabs and no terminals had all of it dropped on every restart — and unlike terminal state it cannot be recovered from the host snapshot, which carries terminal fields only, so an unsaved `dirtyDraftContent` was destroyed outright. The defect was not a missing field. It was a hand-maintained field list deciding what the read recovers while the write used the ownership table, so the two could disagree. Adoption now walks `WORKSPACE_SESSION_FIELD_OWNERSHIP` with an exhaustive switch, and a new ownership kind is a compile-time decision rather than a silent omission. Session keys are normalized through the shared `normalizeWorkspaceSessionKeyToWorkspaceId` so host-qualified visit recency (`ssh:target|worktreeId`) reaches its workspace, and the regression is pinned by feeding the shipping split's own output back through the real boot read rather than a hand-built fixture. Co-authored-by: Robert Nisipeanu Co-authored-by: Jinwoo-H --- config/reliability-gates.jsonc | 15 +- .../lib/workspace-session-host-contention.ts | 17 +- ...e-session-ssh-partition-round-trip.test.ts | 160 +++++++++ src/shared/workspace-scope.ts | 14 + ...ace-session-stranded-partition-adoption.ts | 320 +++++++++++------- 5 files changed, 392 insertions(+), 134 deletions(-) diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index edf547b2825..33879e74d1a 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -258,7 +258,7 @@ { "id": "workspace-session.ssh-host-partition-round-trip", - "title": "An SSH workspace's tabs are never round-tripped to the host as an empty list", + "title": "An SSH workspace round-trips through its own partition without losing tabs, editor files or browser state", "maturity": "experimental", "protection": "partial", "owner": "workspace-session-persistence", @@ -278,8 +278,8 @@ "https://github.com/stablyai/orca/issues/18173", "https://github.com/stablyai/orca/blob/main/src/shared/workspace-session-partition-owner.ts" ], - "invariant": "A workspace whose tab list exists only in its `ssh:` partition is hydrated at boot and published to the host with those tabs. An empty tab row is read as a gap, never as evidence that the tabs were closed, so a replace-session upload can never delete a populated host list. A workspace the local partition already holds tabs for is left untouched, and every workspace routes back to the one partition that owns it.", - "oracle": "Seed a real Store the way shipping builds leave it: the local blob holds the worktree key with an empty list while `ssh:` holds the real one, with a second populated SSH partition present. Publish through the IPC handler with no session argument (the path the debounced writer takes) and assert the host snapshot carries the runtime-authored tabs rather than []. Separately hydrate through the real boot read, export and re-import through the real projection, and merge through mergeDirectSshRemoteWorkspaceSession, asserting the tabs survive the publish and the next pull. Assert the reunited workspace routes to `ssh:`, that a populated local row is not modified, and that a contested id claimed by an SSH and a runtime host does not send the SSH rows into the rotating runtime partition.", + "invariant": "Every workspace the `ssh:` partition names is hydrated at boot and published to the host, whatever kind of state it holds - terminal tabs, open editor files with unsaved hot-exit drafts, browser workspaces, tab groups, or host-qualified visit recency. An empty tab row is read as a gap, never as evidence the tabs were closed, so a replace-session upload can never delete a populated host list. A workspace the local partition already holds terminal tabs for is left untouched, and every workspace routes back to the partition that owns it.", + "oracle": "Seed a real Store the way shipping builds leave it: the local blob holds the worktree key with an empty list while `ssh:` holds the real one, with a second populated SSH partition present. Publish through the IPC handler with no session argument (the path the debounced writer takes) and assert the host snapshot carries the runtime-authored tabs rather than []. Separately drive the shipping split (buildWorkspaceSessionHostSnapshots) and feed its own output back through the real boot read, pinning the write and read halves to each other rather than to a hand-built fixture: an SSH workspace with open editor files, an unsaved dirtyDraftContent and no terminal tabs must come back intact, as must one with no tabsByWorktree key at all. Export and re-import through the real projection and merge through mergeDirectSshRemoteWorkspaceSession, asserting tabs survive a publish, the next pull, and an older client publishing an empty list for them. Assert the reunited workspace routes to `ssh:`, that a workspace the base holds tabs for is not modified, and that a contested id claimed by an SSH and a runtime host does not send the SSH rows into the rotating runtime partition.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh-host-partition-session-export.test.ts src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts src/renderer/src/lib/workspace-session-host-contention.test.ts src/shared/workspace-session-partition-owner.test.ts" ], @@ -330,8 +330,8 @@ "platform": "macos", "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh-host-partition-session-export.test.ts src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts src/renderer/src/lib/workspace-session-host-contention.test.ts src/shared/workspace-session-partition-owner.test.ts", "result": "passed", - "durationSeconds": 2.94, - "summary": "35 tests passed across 4 files, including the real Store publish and the real export/import/merge round trip." + "durationSeconds": 2.45, + "summary": "43 tests passed across 4 files, including the real Store publish, the shipping write/read split round trip, and the older-client empty-publish skew direction." } ], "runtimeBudget": { @@ -344,7 +344,7 @@ }, "redGreenEvidence": { "status": "complete", - "evidence": "Against pristine main ea102a9eb89 the boot read returned no tabs for the worktree, the export published tabsByWorktreePath[path] = [], the round trip ended with the tabs deleted, and routing answered 'local' instead of ssh:target-1 - 8 of 9 new assertions failed. All pass after the fix. Each assertion was additionally mutation-checked against the specific regression it claims to catch; the load-bearing one, treating an empty tab row as an answer rather than a gap, fails 6." + "evidence": "Against pristine main 12f53da542d, 9 of the 11 assertions that target the original defect fail: the boot read returns no tabs, the export publishes an empty tabsByWorktreePath row, the round trip ends with the tabs deleted, routing answers local instead of ssh:target-1, and an older client's empty publish deletes the tabs on pull. The remaining assertions guard the fix itself rather than main's bug: review found that gating adoption on terminal tabs stranded editor-only and browser-only SSH workspaces, destroying unsaved hot-exit drafts no other channel can recover. The gate now discovers a workspace through an exhaustive switch over the field-ownership table, and reintroducing the tabs-only gate fails 6 assertions including the write/read round trip." }, "performanceBudget": { "required": false, @@ -353,7 +353,8 @@ "knownGaps": [ "No live SSH host or relay is exercised; the multiplexer is faked at the request boundary.", "Folder workspaces still persist to the local partition, so their half of the writer divergence in #12723 is not covered here.", - "Tabs stranded beside an already-populated local row are deliberately not recovered, and no assertion claims they are." + "Rows stranded beside a workspace the local partition already holds terminal tabs for are deliberately not recovered, and no assertion claims they are.", + "One-shot resurrection in the legacy-transition shape: where an older build left an empty local row while the runtime partition still holds that workspace's tabs, boot adopts them back once. Non-destructive and does not recur, because a workspace this build empties deliberately leaves an empty row in the owning partition, which is not adoptable." ], "promotionCriteria": [ "Complete the CI soak requirement with no unexplained flakes.", diff --git a/src/renderer/src/lib/workspace-session-host-contention.ts b/src/renderer/src/lib/workspace-session-host-contention.ts index 4e473b61f7d..74b26052b6a 100644 --- a/src/renderer/src/lib/workspace-session-host-contention.ts +++ b/src/renderer/src/lib/workspace-session-host-contention.ts @@ -5,11 +5,7 @@ import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' -import { parseWorkspaceKey } from '../../../shared/workspace-scope' -import { - getWorktreeIdFromHostIdentity, - isWorktreeHostIdentity -} from '../../../shared/worktree/host-qualified-identity' +import { normalizeWorkspaceSessionKeyToWorkspaceId } from '../../../shared/workspace-scope' import { WORKSPACE_SESSION_FIELD_OWNERSHIP } from '../../../shared/workspace-session-host-field-ownership' import { workspaceSessionPartitionHostId } from '../../../shared/workspace-session-partition-owner' import { @@ -51,14 +47,9 @@ const WORKTREE_KEYED_FIELDS = ( Object.keys(WORKSPACE_SESSION_FIELD_OWNERSHIP) as (keyof WorkspaceSessionState)[] ).filter((field) => WORKSPACE_SESSION_FIELD_OWNERSHIP[field] === 'worktreeKeyed') -/** Bare worktree id behind a session key, which may be a WorkspaceKey or a host-qualified identity. */ -export function normalizeWorkspaceSessionKeyToWorktreeId(value: string): string { - if (isWorktreeHostIdentity(value)) { - return getWorktreeIdFromHostIdentity(value) - } - const scope = parseWorkspaceKey(value) - return scope?.type === 'worktree' ? scope.worktreeId : value -} +/** Bare worktree id behind a session key. Lives in shared because the partition adoption read needs + * the same normalization, and two implementations of it would drift. */ +export const normalizeWorkspaceSessionKeyToWorktreeId = normalizeWorkspaceSessionKeyToWorkspaceId function resolveClaimedHostId( worktree: WorkspaceRuntimeOwnerProjection, diff --git a/src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts b/src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts index 554176933aa..ee87fe908f7 100644 --- a/src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts +++ b/src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts @@ -154,6 +154,166 @@ describe('ssh host partition hydration', () => { }) }) +describe('ssh host partition workspaces with no terminal tabs', () => { + /** An SSH workspace the user left with an editor open and every terminal closed. Orca does not + * auto-create a terminal while other tabs exist, so this is an ordinary state — and the whole + * workspace now persists to `ssh:`, tabs or no tabs. */ + function editorOnlyPartitions() { + return { + local: session({ tabsByWorktree: {} }), + [SSH_HOST_ID]: session({ + tabsByWorktree: { [WORKTREE_ID]: [] }, + openFilesByWorktree: { + [WORKTREE_ID]: [ + { + filePath: '/remote/checkout/feature/src/main.ts', + relativePath: 'src/main.ts', + worktreeId: WORKTREE_ID, + language: 'typescript', + dirtyDraftContent: 'unsaved work' + } + ] + }, + activeFileIdByWorktree: { [WORKTREE_ID]: '/remote/checkout/feature/src/main.ts' }, + activeTabTypeByWorktree: { [WORKTREE_ID]: 'editor' }, + browserTabsByWorktree: { + [WORKTREE_ID]: [{ id: 'browser-1', name: 'Docs', tabs: [], activeTabId: null }] + }, + lastVisitedAtByWorktreeId: { [`${SSH_HOST_ID}|${WORKTREE_ID}`]: 4242 } + } as unknown as WorkspaceSessionState) + } + } + + it('restores the open editor files', async () => { + const read = await fetchWorkspaceSessionWithRuntimeHostOwners( + partitionedApi(editorOnlyPartitions()), + repos + ) + + expect( + read.session.openFilesByWorktree?.[WORKTREE_ID]?.map((file) => file.relativePath) + ).toEqual(['src/main.ts']) + }) + + it('restores an unsaved hot-exit draft, which no other channel can recover', async () => { + // RemoteWorkspaceSession carries terminal fields only, so the SSH host snapshot cannot + // round-trip editor state. Losing it here loses user-authored content outright. + const read = await fetchWorkspaceSessionWithRuntimeHostOwners( + partitionedApi(editorOnlyPartitions()), + repos + ) + + expect(read.session.openFilesByWorktree?.[WORKTREE_ID]?.[0]?.dirtyDraftContent).toBe( + 'unsaved work' + ) + }) + + it('restores browser workspaces and the active tab type', async () => { + const read = await fetchWorkspaceSessionWithRuntimeHostOwners( + partitionedApi(editorOnlyPartitions()), + repos + ) + + expect(read.session.browserTabsByWorktree?.[WORKTREE_ID]?.map((entry) => entry.id)).toEqual([ + 'browser-1' + ]) + expect(read.session.activeTabTypeByWorktree?.[WORKTREE_ID]).toBe('editor') + }) + + it('restores a workspace the host partition names with no tabs row at all', async () => { + // Stricter than the fixtures above, which carry an empty `tabsByWorktree` key. A workspace that + // never had a terminal has no such key, so tab presence cannot be what discovers it. + const partitions = { + local: session({ tabsByWorktree: {} }), + [SSH_HOST_ID]: session({ + tabsByWorktree: {}, + openFilesByWorktree: { + [WORKTREE_ID]: [ + { + filePath: '/remote/checkout/feature/README.md', + relativePath: 'README.md', + worktreeId: WORKTREE_ID, + language: 'markdown', + dirtyDraftContent: 'never saved' + } + ] + } + } as unknown as WorkspaceSessionState) + } + + const read = await fetchWorkspaceSessionWithRuntimeHostOwners(partitionedApi(partitions), repos) + + expect(read.session.openFilesByWorktree?.[WORKTREE_ID]?.[0]?.dirtyDraftContent).toBe( + 'never saved' + ) + }) + + it('restores host-qualified visit recency, which is keyed by host and not by bare id', async () => { + const read = await fetchWorkspaceSessionWithRuntimeHostOwners( + partitionedApi(editorOnlyPartitions()), + repos + ) + + expect(read.session.lastVisitedAtByWorktreeId?.[`${SSH_HOST_ID}|${WORKTREE_ID}`]).toBe(4242) + }) +}) + +describe('ssh host partition write/read round trip', () => { + /** The two halves pinned together through the shipping write path. Testing the read against a + * hand-built partition is what let an editor-only workspace fall out: the fixture asserted the + * shape the read expected instead of the shape the write actually produces. */ + async function roundTrip(payload: WorkspaceSessionState): Promise { + const { buildWorkspaceSessionHostSnapshots } = + await import('./workspace-session-host-persistence') + const snapshots = buildWorkspaceSessionHostSnapshots(payload, { + repos: [{ id: REPO_ID, connectionId: TARGET_ID, executionHostId: null }], + worktreesByRepo: {} + }) + const partitions: Record = {} + for (const snapshot of snapshots) { + partitions[snapshot.hostId ?? 'local'] = snapshot.state + } + const read = await fetchWorkspaceSessionWithRuntimeHostOwners( + partitionedApi(partitions as never), + repos + ) + return read.session + } + + it('sends an editor-only SSH workspace to its partition and reads it back', async () => { + const restored = await roundTrip( + session({ + tabsByWorktree: {}, + openFilesByWorktree: { + [WORKTREE_ID]: [ + { + filePath: '/remote/checkout/feature/src/app.ts', + relativePath: 'src/app.ts', + worktreeId: WORKTREE_ID, + language: 'typescript', + dirtyDraftContent: 'work in progress' + } + ] + }, + activeTabTypeByWorktree: { [WORKTREE_ID]: 'editor' } + } as unknown as WorkspaceSessionState) + ) + + expect(restored.openFilesByWorktree?.[WORKTREE_ID]?.[0]?.dirtyDraftContent).toBe( + 'work in progress' + ) + expect(restored.activeTabTypeByWorktree?.[WORKTREE_ID]).toBe('editor') + }) + + it('sends a terminal SSH workspace to its partition and reads it back', async () => { + const restored = await roundTrip( + session({ tabsByWorktree: { [WORKTREE_ID]: [tab('tab-live')] } }) + ) + + expect(restored.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id)).toEqual(['tab-live']) + }) +}) + describe('ssh host partition remote-workspace round trip', () => { it('does not delete the worktree tabs across a publish and the next pull', async () => { const partitions = strandedPartitions([tab('tab-runtime')]) diff --git a/src/shared/workspace-scope.ts b/src/shared/workspace-scope.ts index 262f6a82afe..6e562fa84de 100644 --- a/src/shared/workspace-scope.ts +++ b/src/shared/workspace-scope.ts @@ -1,4 +1,8 @@ import type { WorkspaceKey, WorkspaceScope } from './folder-workspace-types' +import { + getWorktreeIdFromHostIdentity, + isWorktreeHostIdentity +} from './worktree/host-qualified-identity' export function worktreeWorkspaceKey(worktreeId: string): WorkspaceKey { return `worktree:${worktreeId}` @@ -20,6 +24,16 @@ export function parseWorkspaceKey(value: string): WorkspaceScope | null { return null } +/** Bare workspace id behind a session key, which may be a WorkspaceKey, a host-qualified identity + * (`ssh:target|repo::path`, used by visit recency), or already a bare id. */ +export function normalizeWorkspaceSessionKeyToWorkspaceId(value: string): string { + if (isWorktreeHostIdentity(value)) { + return getWorktreeIdFromHostIdentity(value) + } + const scope = parseWorkspaceKey(value) + return scope?.type === 'worktree' ? scope.worktreeId : value +} + export function isWorkspaceKey(value: string): value is WorkspaceKey { return parseWorkspaceKey(value) !== null } diff --git a/src/shared/workspace-session-stranded-partition-adoption.ts b/src/shared/workspace-session-stranded-partition-adoption.ts index af237c4e681..1c7d38763a7 100644 --- a/src/shared/workspace-session-stranded-partition-adoption.ts +++ b/src/shared/workspace-session-stranded-partition-adoption.ts @@ -1,6 +1,9 @@ import type { WorkspaceSessionState } from './workspace-session-state-types' -import type { TerminalTab } from './terminal-tab-types' -import { WORKSPACE_SESSION_FIELD_OWNERSHIP } from './workspace-session-host-field-ownership' +import { + WORKSPACE_SESSION_FIELD_OWNERSHIP, + type WorkspaceSessionFieldOwnership +} from './workspace-session-host-field-ownership' +import { normalizeWorkspaceSessionKeyToWorkspaceId } from './workspace-scope' /** * Fold rows a host partition holds alone back into the session the readers assemble. @@ -10,66 +13,133 @@ import { WORKSPACE_SESSION_FIELD_OWNERSHIP } from './workspace-session-host-fiel * now names a single owner, but both stores still hold real data, so every reader has to reunite * them once before the write path returns the result to that owner. * - * Strictly gap-filling. A workspace the base holds no tabs for takes the host partition's rows; a - * workspace the base does hold tabs for keeps them untouched, and the host partition adds nothing. + * **A workspace is adopted whenever the host partition names it at all — not only when it has + * terminal tabs.** The write path routes EVERY worktree-scoped field to the owning partition, so a + * workspace with open editor files, browser tabs or tab groups and no terminals lives there just as + * completely as one with terminals. Gating on tabs would strand exactly those, and unlike terminal + * state they cannot be recovered from the SSH host snapshot, which carries terminal fields only — + * an unsaved `dirtyDraftContent` would be destroyed outright. * - * Why an EMPTY tab row counts as a gap and not as an answer: an empty list is not evidence that - * anything was closed. `mergeDirectSshRemoteWorkspaceSession` already argues this at length, and + * That is why the walk below switches exhaustively over `WORKSPACE_SESSION_FIELD_OWNERSHIP` instead + * of listing the fields it knows about: a hand-maintained list is what let editor and browser state + * fall out, and a new ownership kind must not be able to fall out the same way. + * + * The one thing the base keeps unconditionally is a workspace it holds **terminal tabs** for. That + * is the live copy the user is looking at, and merging a stale partition into it would re-add tabs + * they had closed on every launch. Leaving it alone keeps this a one-shot repair, at the cost of + * not recovering rows stranded beside a populated workspace — which are stranded on main today too, + * so it is never a new loss. An EMPTY tab row is not such a copy: an empty list is not evidence + * that anything was closed (`mergeDirectSshRemoteWorkspaceSession` argues this at length, and * docs/reference/ssh-execution-boundary.md makes it general — "we could not see it" is - * `unverifiable`, never proof of absence. Treating that empty row as the truth is exactly what - * published an empty tab list and let `replace-session` delete the host's copy (#12721). - * - * Why nothing is merged INTO a populated workspace: the two lists would have to be unioned by tab - * id, and a stale row in the unread partition would then re-add tabs the user had closed, on every - * launch. Leaving a populated workspace alone keeps this a one-shot repair — afterwards the - * workspace lives in one partition — at the cost of not recovering tabs stranded beside a - * populated row. Those are stranded on main today too, so that is never a new loss. + * `unverifiable`, never proof of absence). Treating it as the truth is what published an empty tab + * list and let `replace-session` delete the host's copy (#12721). */ type KeyedRecord = Record -const WORKSPACE_KEYED_FIELDS = ( - Object.keys(WORKSPACE_SESSION_FIELD_OWNERSHIP) as (keyof WorkspaceSessionState)[] -).filter((field) => WORKSPACE_SESSION_FIELD_OWNERSHIP[field] === 'worktreeKeyed') - -/** Keyed by a tab id, or by a pane key that starts with one, so an adopted workspace's rows can be - * recognised by the tabs it brought. */ -const TAB_SCOPED_FIELDS = ( - Object.keys(WORKSPACE_SESSION_FIELD_OWNERSHIP) as (keyof WorkspaceSessionState)[] -).filter((field) => { - const ownership = WORKSPACE_SESSION_FIELD_OWNERSHIP[field] - return ownership === 'tabKeyed' || ownership === 'paneKeyed' -}) - -/** Keyed opaquely, but each record names the workspace it belongs to — the only routing left once - * the tab or pane it describes is gone, and the same one `splitWorkspaceSessionByHost` uses. */ -const SELF_DESCRIBING_FIELDS = ( - Object.keys(WORKSPACE_SESSION_FIELD_OWNERSHIP) as (keyof WorkspaceSessionState)[] -).filter((field) => { - const ownership = WORKSPACE_SESSION_FIELD_OWNERSHIP[field] - return ownership === 'sleepingAgentKeyed' || ownership === 'surfaceTombstoneKeyed' -}) - -const WORKSPACE_ARRAY_FIELDS = ( - Object.keys(WORKSPACE_SESSION_FIELD_OWNERSHIP) as (keyof WorkspaceSessionState)[] -).filter((field) => WORKSPACE_SESSION_FIELD_OWNERSHIP[field] === 'worktreeArray') +const SESSION_FIELDS = Object.keys( + WORKSPACE_SESSION_FIELD_OWNERSHIP +) as (keyof WorkspaceSessionState)[] function asRecord(value: unknown): KeyedRecord | null { return value && typeof value === 'object' && !Array.isArray(value) ? (value as KeyedRecord) : null } -/** Workspaces the host partition is the only side holding tabs for. */ -function strandedWorkspaceKeys( +function recordWorkspaceId(entry: unknown): string | null { + const worktreeId = asRecord(entry)?.worktreeId + return typeof worktreeId === 'string' ? worktreeId : null +} + +/** A browser-workspace row is keyed by browser workspace id; its pages name the workspace. */ +function browserPagesWorkspaceId(entry: unknown): string | null { + const first = Array.isArray(entry) ? (entry[0] as unknown) : null + return recordWorkspaceId(first) +} + +/** Workspaces the base holds terminal tabs for: its live copies, which adoption never touches. */ +function workspacesTheBaseOwns(base: WorkspaceSessionState): Set { + const owned = new Set() + for (const [key, tabs] of Object.entries(base.tabsByWorktree ?? {})) { + if (Array.isArray(tabs) && tabs.length > 0) { + owned.add(normalizeWorkspaceSessionKeyToWorkspaceId(key)) + } + } + return owned +} + +/** Every workspace the host partition names in any scoped field, minus the base's live copies. */ +function adoptableWorkspaceIds( base: WorkspaceSessionState, host: WorkspaceSessionState ): Set { - const stranded = new Set() - for (const [key, tabs] of Object.entries(host.tabsByWorktree ?? {})) { - if (Array.isArray(tabs) && tabs.length > 0 && (base.tabsByWorktree?.[key]?.length ?? 0) === 0) { - stranded.add(key) + const owned = workspacesTheBaseOwns(base) + const adoptable = new Set() + const consider = (value: string | null | undefined): void => { + if (!value) { + return + } + const workspaceId = normalizeWorkspaceSessionKeyToWorkspaceId(value) + if (!owned.has(workspaceId)) { + adoptable.add(workspaceId) } } - return stranded + for (const field of SESSION_FIELDS) { + const ownership: WorkspaceSessionFieldOwnership = WORKSPACE_SESSION_FIELD_OWNERSHIP[field] + const value = host[field] + switch (ownership) { + case 'global': + case 'hostPrivate': + case 'tabKeyed': + case 'paneKeyed': + case 'fileKeyed': + // Keyed by something the workspaces below already account for. + break + case 'worktreeKeyed': + for (const key of Object.keys(asRecord(value) ?? {})) { + consider(key) + } + break + case 'worktreeArray': + for (const id of Array.isArray(value) ? (value as string[]) : []) { + consider(id) + } + break + case 'sleepingAgentKeyed': + case 'surfaceTombstoneKeyed': + for (const entry of Object.values(asRecord(value) ?? {})) { + consider(recordWorkspaceId(entry)) + } + break + case 'browserWorkspaceKeyed': + for (const entry of Object.values(asRecord(value) ?? {})) { + consider(browserPagesWorkspaceId(entry)) + } + break + } + } + return adoptable +} + +function adoptRecord( + next: WorkspaceSessionState, + host: WorkspaceSessionState, + field: keyof WorkspaceSessionState, + shouldAdopt: (key: string, entry: unknown) => boolean, + /** Adoptable workspaces are host-owned, so their rows replace the base's leftovers; everything + * else only fills a gap, so nothing the base already answered is overwritten. */ + replace: boolean +): void { + const hostRecord = asRecord(host[field]) + if (!hostRecord) { + return + } + const merged = { ...asRecord(next[field]) } + for (const [key, entry] of Object.entries(hostRecord)) { + if (shouldAdopt(key, entry) && (replace || !Object.hasOwn(merged, key))) { + merged[key] = entry + } + } + ;(next as KeyedRecord)[field] = merged } export function adoptStrandedHostPartitionSession( @@ -79,84 +149,106 @@ export function adoptStrandedHostPartitionSession( if (!host) { return base } - const stranded = strandedWorkspaceKeys(base, host) - if (stranded.size === 0) { + const adoptable = adoptableWorkspaceIds(base, host) + if (adoptable.size === 0) { return base } - const tabsByWorktree: Record = { ...base.tabsByWorktree } - for (const key of stranded) { - tabsByWorktree[key] = host.tabsByWorktree[key] ?? [] + const adopts = (key: string): boolean => + adoptable.has(normalizeWorkspaceSessionKeyToWorkspaceId(key)) + + const next: WorkspaceSessionState = { ...base, tabsByWorktree: { ...base.tabsByWorktree } } + const adoptedTabIds = new Set() + for (const [key, tabs] of Object.entries(host.tabsByWorktree ?? {})) { + if (!adopts(key) || !Array.isArray(tabs)) { + continue + } + next.tabsByWorktree[key] = tabs + for (const tab of tabs) { + adoptedTabIds.add(tab.id) + } } - const next: WorkspaceSessionState = { ...base, tabsByWorktree } - for (const field of WORKSPACE_KEYED_FIELDS) { - if (field === 'tabsByWorktree') { + // Computed up front rather than as the walk passes `openFilesByWorktree`, so the file-keyed + // fields do not depend on the ownership table's declaration order. + const adoptedFileIds = new Set() + for (const [key, files] of Object.entries(asRecord(host.openFilesByWorktree) ?? {})) { + if (!adopts(key)) { continue } - const hostRecord = asRecord(host[field]) - if (!hostRecord) { - continue - } - // A stranded workspace's other rows describe the tabs just adopted, so they replace the base's - // leftovers rather than filling around them. - const merged = { ...asRecord(next[field]) } - for (const key of stranded) { - if (Object.hasOwn(hostRecord, key)) { - merged[key] = hostRecord[key] + for (const file of Array.isArray(files) ? files : []) { + const filePath = asRecord(file)?.filePath + if (typeof filePath === 'string') { + adoptedFileIds.add(filePath) } } - ;(next as KeyedRecord)[field] = merged } - const adoptedTabIds = new Set( - [...stranded].flatMap((key) => (host.tabsByWorktree[key] ?? []).map((tab) => tab.id)) - ) - for (const field of TAB_SCOPED_FIELDS) { - const hostRecord = asRecord(host[field]) - if (!hostRecord) { - continue - } - const merged = { ...asRecord(next[field]) } - for (const [key, entry] of Object.entries(hostRecord)) { - // Scoped to the adopted tabs so a tab the base already answered for keeps its own rows. - if (!Object.hasOwn(merged, key) && adoptedTabIds.has(key.split(':', 1)[0] ?? '')) { - merged[key] = entry + + for (const field of SESSION_FIELDS) { + const ownership: WorkspaceSessionFieldOwnership = WORKSPACE_SESSION_FIELD_OWNERSHIP[field] + switch (ownership) { + case 'global': + case 'hostPrivate': + // 'local' owns the globals; hostPrivate is main's own per-partition fence. + break + case 'worktreeKeyed': + if (field !== 'tabsByWorktree') { + adoptRecord(next, host, field, (key) => adopts(key), true) + } + break + case 'worktreeArray': { + const hostIds = host[field] + const adopted = (Array.isArray(hostIds) ? (hostIds as string[]) : []).filter(adopts) + if (adopted.length > 0) { + const baseIds = next[field] + ;(next as KeyedRecord)[field] = [ + ...new Set([...(Array.isArray(baseIds) ? (baseIds as string[]) : []), ...adopted]) + ] + } + break } + case 'tabKeyed': + case 'paneKeyed': + // Keyed by a tab id, or by a pane key that starts with one. Tab ids are colon-free, so the + // first segment identifies the owning tab in both shapes. + adoptRecord( + next, + host, + field, + (key) => adoptedTabIds.has(key.split(':', 1)[0] ?? ''), + false + ) + break + case 'sleepingAgentKeyed': + case 'surfaceTombstoneKeyed': + // Keyed opaquely, but each record names its own workspace — the only routing left once the + // tab or pane it describes is gone, and the same one `splitWorkspaceSessionByHost` uses. + adoptRecord( + next, + host, + field, + (_key, entry) => { + const workspaceId = recordWorkspaceId(entry) + return workspaceId !== null && adopts(workspaceId) + }, + false + ) + break + case 'browserWorkspaceKeyed': + adoptRecord( + next, + host, + field, + (_key, entry) => { + const workspaceId = browserPagesWorkspaceId(entry) + return workspaceId !== null && adopts(workspaceId) + }, + false + ) + break + case 'fileKeyed': + // Routed by the open file's workspace, so it follows the files adopted just above. + adoptRecord(next, host, field, (key) => adoptedFileIds.has(key), false) + break } - ;(next as KeyedRecord)[field] = merged - } - for (const field of SELF_DESCRIBING_FIELDS) { - const hostRecord = asRecord(host[field]) - if (!hostRecord) { - continue - } - const merged = { ...asRecord(next[field]) } - for (const [key, entry] of Object.entries(hostRecord)) { - // Why these travel at all: a hibernated agent or a surface tombstone for a stranded workspace - // is only in the host partition, and the renderer's next full write replaces that partition — - // so a record the reunited session never carried would be dropped by the repair itself. - const worktreeId = asRecord(entry)?.worktreeId - if ( - !Object.hasOwn(merged, key) && - typeof worktreeId === 'string' && - stranded.has(worktreeId) - ) { - merged[key] = entry - } - } - ;(next as KeyedRecord)[field] = merged - } - for (const field of WORKSPACE_ARRAY_FIELDS) { - const hostIds = host[field] - if (!Array.isArray(hostIds)) { - continue - } - const adopted = (hostIds as string[]).filter((id) => stranded.has(id)) - if (adopted.length === 0) { - continue - } - const baseIds = next[field] - ;(next as KeyedRecord)[field] = [ - ...new Set([...(Array.isArray(baseIds) ? (baseIds as string[]) : []), ...adopted]) - ] } return next }