mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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: {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user