fix(persistence): sweep rows owned by deregistered repo ids at load

Deregistering a project stranded every row it owned. Each pruning path is
gated on the repo still being in `state.repos`, so once an id leaves the
catalogue its metadata, identity aliases, lineage and session rows became
unreachable forever -- and on a paired client they rendered as phantom
worktrees under an "Unknown" project.

Reconcile against the repo catalogue on load instead: any repo id that owns
rows but is absent from `state.repos` has its rows removed through the same
path `removeProject` uses. Host-independent and session-independent, because
an orphan has no owner that could object -- which is also why this reaches a
client's mirror of a remote host's session partition, something no local
removal can do.

Only a full `<repoId>::<path>` locator seeds the orphan set; bare keys can be
folder workspace ids or repo-keyed revisions, and guessing wrong there would
delete live state. `retiredWorktreeNamesByRepo` is deliberately untouched so a
re-added repo cannot reissue a name onto a cwd that still holds a prior
occupant's agent state.

Test fixtures that wrote worktree rows without registering their repo were
relying on orphans surviving a reload; they now register the repo they name.

Refs #17776
This commit is contained in:
Neil
2026-09-01 17:20:17 -07:00
parent 519af49a58
commit a2aea5d0b0
16 changed files with 389 additions and 21 deletions
@@ -66,15 +66,18 @@ export function rekeyOwnerKey(
return null
}
export function ownerKeyBelongsToRepo(ownerKey: string, repoId: string): boolean {
const rawOwnerKey = isWorktreeHostIdentity(ownerKey)
? getWorktreeIdFromHostIdentity(ownerKey)
: ownerKey
if (isRepoWorktreeId(repoId, rawOwnerKey)) {
return true
/** The worktree locator an owner key names, or null when the key is not worktree-scoped. */
export function ownerKeyWorktreeId(ownerKey: string): string | null {
const scope = parseWorkspaceKey(ownerKey)
if (scope) {
return scope.type === 'worktree' ? scope.worktreeId : null
}
const parsed = parseWorkspaceKey(ownerKey)
return parsed?.type === 'worktree' && isRepoWorktreeId(repoId, parsed.worktreeId)
return isWorktreeHostIdentity(ownerKey) ? getWorktreeIdFromHostIdentity(ownerKey) : ownerKey
}
export function ownerKeyBelongsToRepo(ownerKey: string, repoId: string): boolean {
const worktreeId = ownerKeyWorktreeId(ownerKey)
return worktreeId !== null && isRepoWorktreeId(repoId, worktreeId)
}
export function removeRepoWorktreeRecord<T>(
@@ -397,6 +397,8 @@ describe('Store.migrateWorktreeIdentity', () => {
it('moves persisted mobile selections across reloads', async () => {
const store = await createStore()
// Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load.
store.addRepo(makeRepo({ id: 'repo1', path: '/repo1' }))
store.setMobileClientTabSelections({
'device-a': {
[OLD]: { activeTabId: 'tab-1', activeGroupId: null, activeTabIdByGroupId: {} }
@@ -10,6 +10,7 @@ import {
createStore,
writeDataFile,
readDataFile,
makeRepo,
makeTerminalTab
} from './persistence-test-harness'
@@ -53,6 +54,11 @@ describe('cross-host pane identity migration', () => {
it('refuses hostless alias and acknowledgement rewrites for a tab id two partitions share', async () => {
writeDataFile({
schemaVersion: 1,
// Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load.
repos: [
makeRepo({ id: 'repo-local', path: '/repo-local' }),
makeRepo({ id: 'repo-a', path: '/repo-a' })
],
workspaceSession: makeLegacyPaneSession('repo-local', 'local-pty'),
workspaceSessionsByHostId: {
'ssh:host-a': makeLegacyPaneSession('repo-a', 'pty-a')
@@ -0,0 +1,176 @@
// 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).
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { rmSync, mkdtempSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { getDefaultWorkspaceSession } from '../shared/constants'
import { composeWorktreeHostIdentity } from '../shared/worktree/host-qualified-identity'
import { folderWorkspaceKey } from '../shared/workspace-scope'
import type { PersistedState } from '../shared/persisted-state-types'
import {
testState,
createStore,
writeDataFile,
readDataFile,
makeRepo,
makeTerminalTab
} 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 LIVE_REPO = 'live-repo'
const GONE_REPO = 'gone-repo'
const LIVE_WORKTREE = `${LIVE_REPO}::/workspace/live`
const GONE_WORKTREE = `${GONE_REPO}::/workspace/orphan`
const RUNTIME_HOST = 'runtime:env-a'
const sessionFor = (worktreeId: string, tabId = 'tab-1') => ({
...getDefaultWorkspaceSession(),
tabsByWorktree: {
[worktreeId]: [makeTerminalTab({ id: tabId, worktreeId })]
},
activeTabTypeByWorktree: { [worktreeId]: 'terminal' as const },
lastVisitedAtByWorktreeId: { [worktreeId]: 123 },
// The residue `profile-project-session-field-disposition` flags as leaking on repo removal.
sleepingAgentSessionsByPaneKey: {
[`${tabId}:leaf-1`]: {
paneKey: `${tabId}:leaf-1`,
tabId,
worktreeId,
agent: 'codex' as const,
providerSession: { key: 'session_id' as const, id: 'sess-1' },
prompt: 'sleeping',
state: 'waiting' as const,
capturedAt: 1,
updatedAt: 1,
origin: 'worktree-sleep' as const
}
}
})
describe('deregistered repo residue', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-orphan-sweep-'))
})
afterEach(() => {
rmSync(testState.dir, { recursive: true, force: true })
})
it('drops metadata, identity rows and sessions owned by an unregistered repo id', async () => {
const seed = await createStore()
seed.addRepo(makeRepo({ id: LIVE_REPO, path: '/workspace/live' }))
seed.addRepo(makeRepo({ id: GONE_REPO, path: '/workspace/orphan' }))
seed.setWorktreeMetaForHost(LIVE_WORKTREE, 'local', { displayName: 'Live' })
seed.setWorktreeMetaForHost(GONE_WORKTREE, 'local', { displayName: 'Orphan' })
seed.setWorkspaceSession(sessionFor(GONE_WORKTREE), 'local')
seed.flush()
// Deregister by hand: the point is that a row can outlive its repo however that happened.
const persisted = readDataFile() as PersistedState
persisted.repos = persisted.repos.filter((repo) => repo.id !== GONE_REPO)
writeDataFile(persisted)
const reloaded = await createStore()
reloaded.flush()
const swept = readDataFile() as PersistedState
expect(Object.keys(swept.worktreeMeta)).toEqual([LIVE_WORKTREE])
expect(swept.worktreeIdentityAliases).not.toHaveProperty(
composeWorktreeHostIdentity('local', GONE_WORKTREE)
)
expect(Object.keys(swept.worktreeMetaByIdentity ?? {})).toHaveLength(1)
const session = swept.workspaceSession
expect(session.tabsByWorktree).toEqual({})
expect(session.lastVisitedAtByWorktreeId).toEqual({})
expect(session.activeTabTypeByWorktree).toEqual({})
expect(session.sleepingAgentSessionsByPaneKey ?? {}).toEqual({})
})
it("sweeps a remote host's session partition the owning host's removal can never reach", async () => {
writeDataFile({
schemaVersion: 1,
repos: [makeRepo({ id: LIVE_REPO, path: '/workspace/live' })],
worktreeMeta: {},
workspaceSessionsByHostId: {
[RUNTIME_HOST]: sessionFor(GONE_WORKTREE)
}
})
const store = await createStore()
store.flush()
const partition = store.getWorkspaceSession(RUNTIME_HOST)
expect(partition.tabsByWorktree).toEqual({})
expect(partition.activeTabTypeByWorktree).toEqual({})
})
it('keeps rows for every registered repo, on any execution host', async () => {
const remoteWorktree = `${LIVE_REPO}::/home/user/remote`
writeDataFile({
schemaVersion: 1,
repos: [makeRepo({ id: LIVE_REPO, path: '/home/user/live', executionHostId: RUNTIME_HOST })],
worktreeMeta: { [remoteWorktree]: { hostId: RUNTIME_HOST, status: 'active' } },
workspaceSessionsByHostId: { [RUNTIME_HOST]: sessionFor(remoteWorktree) }
})
const store = await createStore()
expect(store.getWorktreeMeta(remoteWorktree)).toBeDefined()
const partition = store.getWorkspaceSession(RUNTIME_HOST)
expect(partition.tabsByWorktree[remoteWorktree]).toHaveLength(1)
// Also proves the sleeping-agent fixture is well-formed, so the sweep assertions above bite.
expect(Object.keys(partition.sleepingAgentSessionsByPaneKey ?? {})).toHaveLength(1)
})
it('leaves folder-workspace session rows alone: their keys name no repo', async () => {
const workspaceKey = folderWorkspaceKey('folder-1')
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
workspaceSession: {
...getDefaultWorkspaceSession(),
lastVisitedAtByWorktreeId: { [workspaceKey]: 7 }
}
})
const store = await createStore()
expect(store.getWorkspaceSession('local').lastVisitedAtByWorktreeId).toEqual({
[workspaceKey]: 7
})
})
// Why: a sweep that dirtied every launch would rewrite the profile forever and mask real changes.
it('leaves a profile with no orphans byte-identical across reloads', async () => {
const seed = await createStore()
seed.addRepo(makeRepo({ id: LIVE_REPO, path: '/workspace/live' }))
seed.setWorktreeMetaForHost(LIVE_WORKTREE, 'local', { displayName: 'Live' })
seed.setWorkspaceSession(sessionFor(LIVE_WORKTREE), 'local')
seed.flush()
const canonicalizing = await createStore()
canonicalizing.flush()
const canonical = JSON.stringify(readDataFile())
const reloaded = await createStore()
reloaded.flush()
expect(JSON.stringify(readDataFile())).toBe(canonical)
})
})
@@ -123,6 +123,9 @@ describe('Store host-partitioned workspace sessions', () => {
}
})
// Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load.
const makeRepos = (...repoIds: string[]) => repoIds.map((id) => makeRepo({ id, path: `/${id}` }))
it('migrates a legacy workspaceSession blob into the local partition', async () => {
writeDataFile({
schemaVersion: 1,
@@ -194,6 +197,7 @@ describe('Store host-partitioned workspace sessions', () => {
writeDataFile({
schemaVersion: 1,
workspaceSession: makeHostSession('local-repo'),
repos: makeRepos('repo-ssh'),
workspaceSessionsByHostId: {
'ssh:ssh-1': makeLegacyPaneHostSession('repo-ssh', 'remote-pty')
},
@@ -224,6 +228,7 @@ describe('Store host-partitioned workspace sessions', () => {
writeDataFile({
schemaVersion: 1,
workspaceSession: makeHostSession('local-repo'),
repos: makeRepos('repo-a', 'repo-b'),
workspaceSessionsByHostId: {
'ssh:host-a': makeLegacyPaneHostSession('repo-a', 'pty-a'),
'ssh:host-b': makeLegacyPaneHostSession('repo-b', 'pty-b')
@@ -488,6 +493,7 @@ describe('Store host-partitioned workspace sessions', () => {
it('removes one orphaned worktree with a host-scoped topology fence', async () => {
const store = await createStore()
store.addRepo(makeRepo({ id: 'repo-gone', path: '/repo-gone' }))
const worktreeId = 'repo-gone::/workspace/stale'
const session = {
...makeHostSession('repo-gone'),
@@ -728,6 +734,7 @@ describe('Store host-partitioned workspace sessions', () => {
const worktreeId = 'repo-1::/worktree'
writeDataFile({
schemaVersion: 1,
repos: makeRepos('repo-1'),
workspaceSessionsByHostId: {
'runtime:good': makeHostSession('good-repo'),
// activeRepoId must be string|null; a number fails the zod parse.
@@ -753,6 +760,7 @@ describe('Store host-partitioned workspace sessions', () => {
const worktreeId = 'repo-1::/worktree'
writeDataFile({
schemaVersion: 1,
repos: makeRepos('repo-1'),
workspaceSession: {
...makeHostSession('local-repo'),
// A projected/truncated write can leave a top-level field the wrong type;
@@ -813,6 +821,7 @@ describe('Store host-partitioned workspace sessions', () => {
const worktreeId = 'repo-1::/worktree'
const profile = await canonicalize({
schemaVersion: 1,
repos: makeRepos('repo-1'),
workspaceSession: {
...makeHostSession('local-repo'),
tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'tab-keep', worktreeId })] }
@@ -845,6 +854,7 @@ describe('Store host-partitioned workspace sessions', () => {
const worktreeId = 'repo-1::/worktree'
const profile = await canonicalize({
schemaVersion: 1,
repos: makeRepos('repo-1'),
workspaceSessionsByHostId: {
'runtime:env-a': {
...makeHostSession('runtime-repo'),
@@ -150,6 +150,8 @@ describe('Store', () => {
it('does not restore a terminal tab after its durable close flush returns', async () => {
const store = await createStore()
// Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load.
store.addRepo(makeRepo({ id: 'repo-1', path: '/repo-1' }))
const worktreeId = 'repo-1::/tmp/worktree-1'
const tabId = 'terminal-1'
const session: WorkspaceSessionState = {
@@ -58,7 +58,7 @@ describe('Store native-chat tab viewMode persistence', () => {
const WORKTREE = 'repo1::/worktree'
writeDataFile({
schemaVersion: 1,
repos: [makeRepo()],
repos: [makeRepo({ id: 'repo1', path: '/repo1' })],
worktreeMeta: {},
settings: {},
ui: {},
+5 -1
View File
@@ -737,7 +737,10 @@ describe('Store', () => {
it('reassignSshTargetId persists a worktree-meta-only re-point (no matching repo)', async () => {
const store = await createStore()
// A meta on the old SSH host with no repo row — the re-point must still be persisted, not memory-only.
// A meta on the old SSH host with no repo row for that host — the re-point must still be
// persisted, not memory-only. The repo id stays registered so the load-time orphan sweep,
// which only reads repo ids, leaves the row alone.
store.addRepo(makeRepo({ id: 'r1', path: '/r1' }))
store.setWorktreeMeta('r1::/remote/wt', { displayName: 'wt', hostId: 'ssh:ssh-old' })
const repoIds = store.reassignSshTargetId('ssh-old', 'ssh-new')
@@ -787,6 +790,7 @@ describe('Store', () => {
it('reassignSshTargetId re-keys a session partition stored under the old ssh host id', async () => {
const store = await createStore()
store.addRepo(makeRepo({ id: 'r1', path: '/r1' }))
store.setWorkspaceSession(
{
activeRepoId: null,
+1 -1
View File
@@ -708,7 +708,7 @@ describe('Store', () => {
}
writeDataFile({
schemaVersion: 1,
repos: [makeRepo()],
repos: [makeRepo({ id: 'repo1', path: '/repo1' })],
worktreeMeta: {
'repo1::/worktree-a': { status: 'active' },
'repo1::/worktree-b': { status: 'active' }
@@ -346,7 +346,7 @@ describe('Store', () => {
const acknowledgedAt = 1_700_000_000_000
writeDataFile({
schemaVersion: 1,
repos: [makeRepo()],
repos: [makeRepo({ id: 'repo1', path: '/repo1' })],
worktreeMeta: {},
settings: {},
ui: {
@@ -408,7 +408,7 @@ describe('Store', () => {
writeDataFile({
schemaVersion: 1,
repos: [makeRepo()],
repos: [makeRepo({ id: 'repo1', path: '/repo1' })],
worktreeMeta: {},
settings: {},
ui: {
@@ -166,6 +166,8 @@ describe('Store', () => {
describe('mobileClientTabSelectionsByDeviceId', () => {
it('persists device tab selections across reloads and drops malformed payloads', async () => {
const store = await createStore()
// Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load.
store.addRepo(makeRepo({ id: 'repo-1', path: '/repo-1' }))
store.setMobileClientTabSelections({
'device-a': {
'repo-1::/tmp/wt': { activeTabId: 'tab-1', activeGroupId: 'g1', activeTabIdByGroupId: {} }
@@ -188,6 +190,7 @@ describe('Store', () => {
it('prunes selections for a removed repo worktree', async () => {
const store = await createStore()
store.addRepo(makeRepo())
store.addRepo(makeRepo({ id: 'other-repo', path: '/other-repo' }))
store.setMobileClientTabSelections({
'device-a': {
'r1::/tmp/wt': {
@@ -12,10 +12,13 @@ import {
import { mergeProjectHostSetupCompatibilityState } from '../tracking-repos/project-host-compatibility'
import { RepoOrderPersistenceOperations } from '../tracking-repos/repo-order-operations'
import { pruneWorktreeStateForRepo as pruneWorktreeStateForRepoOperation } from '../tracking-repos/repo-worktree-pruning'
import { collectDeregisteredRepoIds } from '../tracking-repos/deregistered-repo-residue'
import { hydrateRepo as hydrateRepoOperation } from '../tracking-repos/repo-hydration'
import { RepoUpdatePersistenceOperations } from '../tracking-repos/repo-update-operations'
import { ProjectHostSetupPersistenceOperations } from '../tracking-repos/project-host-setup-update'
import { bumpLocalWorktreeScanGeneration } from '../../local-worktree-scan-generation'
import type { PersistedState } from '../../../shared/persisted-state-types'
import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id'
import type { StoreRuntimeState } from './store-runtime-state'
import type { WriteSchedulingOperations } from './write-scheduling'
@@ -129,6 +132,33 @@ export class RepoLifecycleOperations {
scheduleSave(this[repoLifecycleOperationsContext].scheduling)
}
/**
* 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.
*/
sweepDeregisteredRepoResidue(): string[] {
const state = this[repoLifecycleOperationsContext].runtime.state
const orphanRepoIds = collectDeregisteredRepoIds(state)
if (orphanRepoIds.size === 0) {
return []
}
for (const repoId of orphanRepoIds) {
pruneWorktreeStateForRepo(this, repoId, null)
state.workspaceSession = removeRepoFromWorkspaceSession(state.workspaceSession, repoId)
state.workspaceSessionsByHostId = removeRepoFromHostWorkspaceSessions(
state.workspaceSessionsByHostId,
repoId
)
}
pruneDeregisteredRepoUiResidue(state.ui, orphanRepoIds)
return [...orphanRepoIds]
}
updateRepo(
id: string,
updates: Partial<
@@ -212,6 +242,26 @@ export function pruneMobileClientTabSelections(
}
}
function pruneDeregisteredRepoUiResidue(
ui: PersistedState['ui'],
orphanRepoIds: ReadonlySet<string>
): void {
const isOrphanWorktree = (worktreeId: string): boolean =>
orphanRepoIds.has(getRepoIdFromWorktreeId(worktreeId))
if (ui.lastActiveRepoId && orphanRepoIds.has(ui.lastActiveRepoId)) {
ui.lastActiveRepoId = null
}
if (ui.lastActiveWorktreeId && isOrphanWorktree(ui.lastActiveWorktreeId)) {
ui.lastActiveWorktreeId = null
}
ui.filterRepoIds = ui.filterRepoIds?.filter((repoId) => !orphanRepoIds.has(repoId)) ?? []
for (const worktreeId of Object.keys(ui.showDotfilesByWorktree ?? {})) {
if (isOrphanWorktree(worktreeId)) {
delete ui.showDotfilesByWorktree?.[worktreeId]
}
}
}
export function getRepoUpdateOperations(
owner: RepoLifecycleOperations
): RepoUpdatePersistenceOperations {
+9 -1
View File
@@ -64,6 +64,9 @@ export class Store {
)
const adaptedProjectGroups = this.domains.adaptation.adaptFlatFolderScanProjectGroups()
this.domains.adaptation.hydrateFolderWorkspaceDiffComments()
// Load is the only place an orphaned repo id can be swept: every removal path needs the repo to
// still be registered, so rows outlive their owner without one (#17776).
const sweptRepoIds = this.domains.repos.sweepDeregisteredRepoResidue()
for (const entry of normalized.migrationUnsupportedEntries) {
setMigrationUnsupportedPty(entry)
}
@@ -78,7 +81,12 @@ export class Store {
this.state.legacyPaneKeyAliasEntries = entries
scheduleSave(this.domains.scheduling)
})
if (normalized.changed || this.runtime.loadNeedsSave || adaptedProjectGroups) {
if (
normalized.changed ||
this.runtime.loadNeedsSave ||
adaptedProjectGroups ||
sweptRepoIds.length > 0
) {
scheduleSave(this.domains.scheduling)
}
}
@@ -0,0 +1,82 @@
import type { PersistedState } from '../../../shared/persisted-state-types'
import { getWorktreeIdFromHostIdentity } from '../../../shared/worktree/host-qualified-identity'
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 { ownerKeyWorktreeId } from '../../orca-profiles/profile-project-worktree-identity'
/**
* 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).
*/
export function collectDeregisteredRepoIds(state: PersistedState): Set<string> {
const liveRepoIds = new Set(state.repos.map((repo) => repo.id))
const orphanRepoIds = new Set<string>()
// Only a full `<repoId>::<path>` 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
// guessing wrong here deletes live session state.
const addWorktreeId = (worktreeId: string | null | undefined): void => {
const repoId = worktreeId ? splitWorktreeId(worktreeId)?.repoId : undefined
if (repoId && !liveRepoIds.has(repoId)) {
orphanRepoIds.add(repoId)
}
}
const addOwnerKey = (ownerKey: string): void => {
addWorktreeId(ownerKeyWorktreeId(ownerKey))
}
// Deliberately not seeded from `sparsePresetsByRepo` or `retiredWorktreeNamesByRepo`: both are
// bounded, and dropping a retired-name row would let a re-added repo reissue a name onto a cwd
// that still holds a prior occupant's agent state.
for (const worktreeId of Object.keys(state.worktreeMeta)) {
addWorktreeId(worktreeId)
}
for (const alias of Object.keys(state.worktreeIdentityAliases ?? {})) {
addWorktreeId(getWorktreeIdFromHostIdentity(alias))
}
for (const [childId, lineage] of Object.entries(state.worktreeLineageById)) {
addWorktreeId(childId)
addWorktreeId(lineage.parentWorktreeId)
}
for (const [childKey, lineage] of Object.entries(state.workspaceLineageByChildKey)) {
addOwnerKey(childKey)
addOwnerKey(lineage.parentWorkspaceKey)
}
for (const selections of Object.values(state.mobileClientTabSelectionsByDeviceId ?? {})) {
for (const worktreeId of Object.keys(selections)) {
addWorktreeId(worktreeId)
}
}
const sessions: (WorkspaceSessionState | undefined)[] = [
state.workspaceSession,
...Object.values(state.workspaceSessionsByHostId ?? {})
]
for (const session of sessions) {
if (!session) {
continue
}
for (const field of SESSION_FIELDS_PRUNED_BY_OWNER_KEY) {
for (const ownerKey of Object.keys(
(session[field] as Record<string, unknown> | undefined) ?? {}
)) {
addOwnerKey(ownerKey)
}
}
for (const ownerKey of Object.keys(session.tabsByWorktree ?? {})) {
addOwnerKey(ownerKey)
}
for (const ownerKey of Object.keys(session.browserTabsByWorktree ?? {})) {
addOwnerKey(ownerKey)
}
for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) {
addWorktreeId(record.worktreeId)
}
for (const tombstone of Object.values(session.terminalSurfaceTombstonesByPaneKey ?? {})) {
addWorktreeId(tombstone.worktreeId)
}
}
return orphanRepoIds
}
@@ -2,7 +2,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { rmSync, mkdtempSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { testState, createStore, makeTerminalTab, writeDataFile } from './persistence-test-harness'
import {
testState,
createStore,
makeRepo,
makeTerminalTab,
writeDataFile
} from './persistence-test-harness'
import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures'
import { getDefaultPersistedState } from '../shared/constants'
@@ -196,6 +202,8 @@ describe('STA-3077: an SSH reattach binds panes without grafting them back', ()
it('does not clear and rebind a retired surface loaded from an older profile', async () => {
const paneKey = `${TAB}:${TEST_LEAF_1}`
const persisted = getDefaultPersistedState(testState.dir)
// Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load.
persisted.repos = [makeRepo({ id: 'repo1', path: '/repo1' })]
persisted.workspaceSession = {
...persisted.workspaceSession,
...sessionWithPane({ tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-1' }),
+20 -6
View File
@@ -5,12 +5,26 @@ import { tmpdir } from 'node:os'
import type { PersistedState } from '../shared/persisted-state-types'
import { canonicalWorktreeIdentity } from '../shared/worktree/identity'
import { composeWorktreeHostIdentity } from '../shared/worktree/host-qualified-identity'
import { createStore, readDataFile, testState, writeDataFile } from './persistence-test-harness'
import type { Store } from './persistence/loading-store/store'
import {
createStore,
makeRepo,
readDataFile,
testState,
writeDataFile
} from './persistence-test-harness'
describe('host-qualified worktree metadata', () => {
const worktreeId = 'repo-1::/workspace/feature'
const ROTATED_INSTANCE_ID = '44444444-4444-4444-8444-444444444444'
// Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load.
const createStoreWithRepo = (): Store => {
const store = createStore()
store.addRepo(makeRepo({ id: 'repo-1', path: '/workspace' }))
return store
}
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-worktree-identity-'))
})
@@ -52,7 +66,7 @@ describe('host-qualified worktree metadata', () => {
})
})
it('reloads host-specific metadata without collapsing it to the legacy locator', () => {
const store = createStore()
const store = createStoreWithRepo()
store.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'Local feature' })
store.setWorktreeMetaForHost(worktreeId, 'ssh:build-box', { displayName: 'Remote feature' })
store.flush()
@@ -72,7 +86,7 @@ describe('host-qualified worktree metadata', () => {
expect(store.getWorktreeMetaForHost(worktreeId, 'local')?.comment).toBe('after')
})
it('backfills one stable instance for legacy metadata that omitted it', () => {
const seed = createStore()
const seed = createStoreWithRepo()
seed.setWorktreeMeta(worktreeId, { displayName: 'Legacy feature' })
seed.flush()
const legacy = readDataFile() as PersistedState
@@ -97,7 +111,7 @@ describe('host-qualified worktree metadata', () => {
// Fails open on purpose: an ambiguous alias used to brick reads and throw out of the worktree
// listing loop, taking every workspace in the repo down with it and never self-healing.
it('collapses an ambiguous locator onto its most recently active instance', () => {
const seed = createStore()
const seed = createStoreWithRepo()
const first = seed.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'First' })
seed.flush()
const persisted = readDataFile() as PersistedState
@@ -262,7 +276,7 @@ describe('host-qualified worktree metadata', () => {
it('repairs a missing canonical instance id while re-adopting an SSH target', () => {
const oldHostId = 'ssh:old-target' as const
const newHostId = 'ssh:new-target' as const
const seed = createStore()
const seed = createStoreWithRepo()
seed.setWorktreeMetaForHost(worktreeId, oldHostId, { displayName: 'Remote feature' })
seed.flush()
const persisted = readDataFile() as PersistedState
@@ -318,7 +332,7 @@ describe('host-qualified worktree metadata', () => {
it('deduplicates an equivalent destination during SSH target re-adoption', () => {
const oldHostId = 'ssh:old-target' as const
const newHostId = 'ssh:new-target' as const
const seed = createStore()
const seed = createStoreWithRepo()
seed.setWorktreeMetaForHost(worktreeId, oldHostId, { displayName: 'Remote feature' })
seed.flush()
const persisted = readDataFile() as PersistedState