From 225a47533dbfd7a76d17611d5c2000ee66f387bb Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 6 Sep 2026 18:29:19 -0700 Subject: [PATCH] fix: preserve paired host sessions during startup residue cleanup (#18922) * fix: preserve paired host sessions during startup residue cleanup * refactor(persistence): tighten the paired-host retention pass Dedupe the owner-key -> repo-id extraction the retention and seeding passes both needed, and name the `runtime:*` check instead of repeating the parse three times. Reach the session walker directly by exporting `addWorkspaceSessionWorktreeOwners` rather than fabricating a `{ workspaceSession }` state slice to get at it. Correct the docstrings: `runtime:*` also covers a serving host's own partition, and the "authoritative removal" they promised has no product caller on a paired client today, so say what the exemption actually costs. Add a survived-load assertion to the explicit-removal test, which otherwise passed against the pre-fix sweep -- the partition was already empty before the removal ran. No behavior change beyond the docs and the test assertion. --- ...sistence-deregistered-repo-residue.test.ts | 18 ++- ...persistence-remote-session-startup.test.ts | 109 ++++++++++++++++++ .../repo-lifecycle-operations.ts | 8 +- .../session-worktree-ownership.ts | 2 +- .../deregistered-repo-residue.ts | 55 +++++++-- 5 files changed, 168 insertions(+), 24 deletions(-) create mode 100644 src/main/persistence-remote-session-startup.test.ts diff --git a/src/main/persistence-deregistered-repo-residue.test.ts b/src/main/persistence-deregistered-repo-residue.test.ts index 3a7a3372b3c..a7fb4d7353f 100644 --- a/src/main/persistence-deregistered-repo-residue.test.ts +++ b/src/main/persistence-deregistered-repo-residue.test.ts @@ -1,7 +1,5 @@ // Why this file exists: deregistering a project used to strand every row it owned. No sweeper could -// reach them -- the missing-directory prune is gated on the repo still being registered, and a -// paired client's mirror of a remote host's rows is keyed by ids that client never registers, so the -// owning host's removal never reached it (#17776). +// reach them because the missing-directory prune is gated on the repo still being registered. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' @@ -103,7 +101,7 @@ describe('deregistered repo residue', () => { expect(session.sleepingAgentSessionsByPaneKey ?? {}).toEqual({}) }) - it("sweeps a remote host's session partition the owning host's removal can never reach", async () => { + it('keeps a remote session whose repo is not registered on the desktop', async () => { writeDataFile({ schemaVersion: 1, repos: [makeRepo({ id: LIVE_REPO, path: '/workspace/live' })], @@ -117,8 +115,10 @@ describe('deregistered repo residue', () => { store.flush() const partition = store.getWorkspaceSession(RUNTIME_HOST) - expect(partition.tabsByWorktree).toEqual({}) - expect(partition.activeTabTypeByWorktree).toEqual({}) + expect(partition.tabsByWorktree[GONE_WORKTREE]).toHaveLength(1) + expect(partition.activeTabTypeByWorktree).toEqual( + sessionFor(GONE_WORKTREE).activeTabTypeByWorktree + ) }) it('keeps rows for every registered repo, on any execution host', async () => { @@ -204,15 +204,13 @@ describe('deregistered repo residue', () => { schemaVersion: 1, repos: [makeRepo({ id: LIVE_REPO, path: '/workspace/live' })], worktreeMeta: {}, - workspaceSessionsByHostId: { - [RUNTIME_HOST]: { ...getDefaultWorkspaceSession(), ...session } - } + workspaceSession: { ...getDefaultWorkspaceSession(), ...session } }) const store = await createStore() store.flush() - const partition = store.getWorkspaceSession(RUNTIME_HOST) + const partition = store.getWorkspaceSession() expect(partition.activeWorktreeId ?? null).toBeNull() expect(partition.activeWorkspaceKey ?? null).toBeNull() expect(partition.activeWorktreeIdsOnShutdown ?? []).toEqual([]) diff --git a/src/main/persistence-remote-session-startup.test.ts b/src/main/persistence-remote-session-startup.test.ts new file mode 100644 index 00000000000..ddb3f57827b --- /dev/null +++ b/src/main/persistence-remote-session-startup.test.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { getDefaultWorkspaceSession } from '../shared/constants' +import type { BrowserPage, BrowserWorkspace } from '../shared/browser-workspace-types' +import { createStore, makeRepo, testState } from './persistence-test-harness' + +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(), + sshConfigHostsToTargets: vi.fn() +})) +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn().mockReturnValue({}) })) + +const HOST = 'runtime:paired-host' +const REPO = 'remote-repo' +const WORKTREE = `${REPO}::/remote/project` +const PAGE: BrowserPage = { + id: 'page-1', + workspaceId: 'browser-1', + worktreeId: WORKTREE, + url: 'https://example.test/moved', + title: 'Moved page', + loading: false, + canGoBack: true, + canGoForward: false, + faviconUrl: null, + loadError: null, + createdAt: 1, + browserRuntimeEnvironmentId: 'paired-host', + remoteBrowserPageId: 'remote-page-1', + remoteBrowserPageClientHosted: true +} +const BROWSER: BrowserWorkspace = { + id: PAGE.workspaceId, + worktreeId: WORKTREE, + sessionProfileId: null, + activePageId: PAGE.id, + pageIds: [PAGE.id], + url: PAGE.url, + title: PAGE.title, + loading: false, + faviconUrl: null, + canGoBack: true, + canGoForward: false, + loadError: null, + createdAt: 1 +} + +function browserSession() { + return { + ...getDefaultWorkspaceSession(), + browserTabsByWorktree: { [WORKTREE]: [BROWSER] }, + browserPagesByWorkspace: { [BROWSER.id]: [PAGE] }, + activeBrowserTabIdByWorktree: { [WORKTREE]: BROWSER.id } + } +} + +describe('remote session startup ownership', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-remote-session-')) + }) + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('keeps a paired browser row and its hosting identity across two Store reloads', () => { + const seed = createStore() + seed.addRepo(makeRepo({ id: 'local-repo', path: join(testState.dir, 'local') })) + seed.setWorkspaceSession(browserSession(), HOST) + seed.flush() + + for (let i = 0; i < 2; i += 1) { + const reloaded = createStore() + expect(reloaded.getWorkspaceSession(HOST).browserPagesByWorkspace).toEqual({ + [BROWSER.id]: [PAGE] + }) + expect(reloaded.sweepDeregisteredRepoResidue()).toEqual([]) + reloaded.flush() + } + }) + + it('retains remote metadata when no session or local catalog row names its repo', () => { + const seed = createStore() + seed.setWorktreeMetaForHost(WORKTREE, HOST, { displayName: 'Remote work' }) + seed.flush() + const reloaded = createStore() + expect(reloaded.getWorktreeMeta(WORKTREE)).toMatchObject({ displayName: 'Remote work' }) + expect(reloaded.sweepDeregisteredRepoResidue()).toEqual([]) + reloaded.flush() + }) + + it('still applies an explicit remote project removal', () => { + const seed = createStore() + seed.setWorkspaceSession(browserSession(), HOST) + seed.flush() + const reloaded = createStore() + // Assert the row survived load first, or an empty partition below would prove nothing. + expect(reloaded.getWorkspaceSession(HOST).browserPagesByWorkspace).not.toEqual({}) + reloaded.removeProjectForHost(REPO, HOST) + reloaded.flush() + expect(createStore().getWorkspaceSession(HOST).browserPagesByWorkspace).toEqual({}) + }) +}) diff --git a/src/main/persistence/loading-store/repo-lifecycle-operations.ts b/src/main/persistence/loading-store/repo-lifecycle-operations.ts index 535108845e1..e5c75c1ff01 100644 --- a/src/main/persistence/loading-store/repo-lifecycle-operations.ts +++ b/src/main/persistence/loading-store/repo-lifecycle-operations.ts @@ -136,11 +136,9 @@ export class RepoLifecycleOperations { /** * Drop every persisted row owned by a repo id that is no longer registered. * - * Runs at load because no removal path can: `removeProject` only fires while the repo is still in - * `state.repos`, and a paired client's mirror of a remote host's rows is keyed by ids that client - * never registers, so the owning host's removal never reaches it (#17776). An orphan has no owner - * that could object, so this ignores the session-ownership and local-execution-host gates the - * missing-directory sweeper needs. + * Runs at load to reach leftover local rows after deregistration. Rows owned by a `runtime:*` + * host are exempt: this runs before pairing, so their absence from the local catalog cannot + * establish deletion. Only an explicit `removeProjectForHost` retires them. */ sweepDeregisteredRepoResidue(): string[] { const state = this[repoLifecycleOperationsContext].runtime.state diff --git a/src/main/persistence/restoring-sessions/session-worktree-ownership.ts b/src/main/persistence/restoring-sessions/session-worktree-ownership.ts index e06e39dc5c5..5c54af92975 100644 --- a/src/main/persistence/restoring-sessions/session-worktree-ownership.ts +++ b/src/main/persistence/restoring-sessions/session-worktree-ownership.ts @@ -209,7 +209,7 @@ export function collectWorkspaceSessionWorktreeOwners( return owners } -function addWorkspaceSessionWorktreeOwners( +export function addWorkspaceSessionWorktreeOwners( session: WorkspaceSessionState, collector: WorktreeOwnerCandidateCollector ): void { diff --git a/src/main/persistence/tracking-repos/deregistered-repo-residue.ts b/src/main/persistence/tracking-repos/deregistered-repo-residue.ts index c52bddbb712..cf97adc11ae 100644 --- a/src/main/persistence/tracking-repos/deregistered-repo-residue.ts +++ b/src/main/persistence/tracking-repos/deregistered-repo-residue.ts @@ -1,19 +1,61 @@ import type { PersistedState } from '../../../shared/persisted-state-types' -import { getWorktreeIdFromHostIdentity } from '../../../shared/worktree/host-qualified-identity' +import { + getExecutionHostIdFromWorktreeHostIdentity, + getWorktreeIdFromHostIdentity +} from '../../../shared/worktree/host-qualified-identity' +import { parseExecutionHostId } from '../../../shared/execution-host' +import { addWorkspaceSessionWorktreeOwners } from '../restoring-sessions/session-worktree-ownership' import { splitWorktreeId } from '../../../shared/worktree/id' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { SESSION_FIELDS_PRUNED_BY_OWNER_KEY } from '../../orca-profiles/profile-project-session-field-disposition' import { ownerKeyWorktreeIds } from '../../orca-profiles/profile-project-worktree-identity' +/** A `runtime:*` host addresses a paired Orca desktop's rows, whose catalog lives on that host. */ +const isPairedHost = (hostId: string | null | undefined): boolean => + parseExecutionHostId(hostId)?.kind === 'runtime' + +/** Repo ids an owner key can name, across both readings (see `ownerKeyWorktreeIds`). */ +function ownerKeyRepoIds(ownerKey: string | null | undefined): string[] { + return ownerKey + ? ownerKeyWorktreeIds(ownerKey).flatMap((worktreeId) => { + const repoId = splitWorktreeId(worktreeId)?.repoId + return repoId ? [repoId] : [] + }) + : [] +} + /** * Repo ids that still own persisted rows but no longer appear in `state.repos`. * - * Why nothing else finds them: every other sweeper is gated on the repo still being registered, so - * deregistering a project stranded the rows it owned permanently — including a paired client's - * mirror of a remote host's session partition, which no local repo removal can reach (#17776). + * Rows owned by a `runtime:*` host are held live instead of swept: a paired client mirrors that + * host's sessions without ever registering its repos, and this runs in the Store constructor, + * before pairing, so catalog absence there proves nothing (#17776 read it as proof and deleted + * live sessions). The cost is that residue outliving a removal is no longer swept for those hosts. */ export function collectDeregisteredRepoIds(state: PersistedState): Set { const liveRepoIds = new Set(state.repos.map((repo) => repo.id)) + const retainOwner = (ownerKey: string | null | undefined): void => { + for (const repoId of ownerKeyRepoIds(ownerKey)) { + liveRepoIds.add(repoId) + } + } + // `owners` goes unread: the walker only ever calls `addOwner`. + const retainCollector = { owners: new Set(), addOwner: retainOwner } + for (const [hostId, session] of Object.entries(state.workspaceSessionsByHostId ?? {})) { + if (session && isPairedHost(hostId)) { + addWorkspaceSessionWorktreeOwners(session, retainCollector) + } + } + for (const [worktreeId, meta] of Object.entries(state.worktreeMeta)) { + if (isPairedHost(meta.hostId)) { + retainOwner(worktreeId) + } + } + for (const alias of Object.keys(state.worktreeIdentityAliases ?? {})) { + if (isPairedHost(getExecutionHostIdFromWorktreeHostIdentity(alias))) { + retainOwner(getWorktreeIdFromHostIdentity(alias)) + } + } const orphanRepoIds = new Set() // Only a full `::` locator seeds the set. A bare key -- a folder workspace id, a // repo-keyed topology revision, a test-shaped locator -- cannot be told apart from a repo id, and @@ -30,10 +72,7 @@ export function collectDeregisteredRepoIds(state: PersistedState): Set { * other reading would hand the removal pass -- which accepts either -- a live row to delete. */ const addOwnerKey = (ownerKey: string): void => { - const repoIds = ownerKeyWorktreeIds(ownerKey).flatMap((worktreeId) => { - const repoId = splitWorktreeId(worktreeId)?.repoId - return repoId ? [repoId] : [] - }) + const repoIds = ownerKeyRepoIds(ownerKey) if (repoIds.length > 0 && repoIds.every((repoId) => !liveRepoIds.has(repoId))) { for (const repoId of repoIds) { orphanRepoIds.add(repoId)