diff --git a/src/main/persistence-host-partitioned-sessions.test.ts b/src/main/persistence-host-partitioned-sessions.test.ts index c7af1023719..c9721b68d83 100644 --- a/src/main/persistence-host-partitioned-sessions.test.ts +++ b/src/main/persistence-host-partitioned-sessions.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' import { getDefaultWorkspaceSession } from '../shared/constants' +import { isTerminalLeafId } from '../shared/stable-pane-id' import { testState, createStore, @@ -165,6 +166,63 @@ describe('Store host-partitioned workspace sessions', () => { expect(store.getWorkspaceSession('local').activeRepoId).toBe('canonical-local') }) + it('rewrites legacy pane ids inside a host partition and remaps its leases', async () => { + writeDataFile({ + schemaVersion: 1, + workspaceSession: makeHostSession('local-repo'), + workspaceSessionsByHostId: { + 'ssh:host-b': { + ...getDefaultWorkspaceSession(), + activeRepoId: 'repo-ssh', + activeWorktreeId: 'repo-ssh::/worktree', + activeTabId: 'tab-ssh', + tabsByWorktree: { + 'repo-ssh::/worktree': [ + { + id: 'tab-ssh', + worktreeId: 'repo-ssh::/worktree', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: 'remote-pty' + } + ] + }, + terminalLayoutsByTabId: { + 'tab-ssh': { + root: { type: 'leaf', leafId: 'pane:1' }, + activeLeafId: 'pane:1', + expandedLeafId: null, + ptyIdsByLeafId: { 'pane:1': 'remote-pty' } + } + } + } + }, + sshRemotePtyLeases: [ + { + targetId: 'ssh-1', + ptyId: 'remote-pty', + worktreeId: 'repo-ssh::/worktree', + tabId: 'tab-ssh', + leafId: 'pane:1', + state: 'detached', + createdAt: 1, + updatedAt: 1 + } + ] + }) + + const store = await createStore() + + const root = store.getWorkspaceSession('ssh:host-b').terminalLayoutsByTabId['tab-ssh']?.root + const leafId = root?.type === 'leaf' ? root.leafId : null + expect(leafId && isTerminalLeafId(leafId)).toBe(true) + // The lease follows the partition's rewritten leaf, not the legacy `pane:1`. + expect(store.getSshRemotePtyLeases('ssh-1')[0]?.leafId).toBe(leafId) + }) + it('isolates writes: setting host A does not mutate host B or local', async () => { const store = await createStore() diff --git a/src/main/persistence/applying-settings/onboarding-normalization.ts b/src/main/persistence/applying-settings/onboarding-normalization.ts index 6e9813d71d4..b6299886a7b 100644 --- a/src/main/persistence/applying-settings/onboarding-normalization.ts +++ b/src/main/persistence/applying-settings/onboarding-normalization.ts @@ -42,10 +42,20 @@ export function normalizeNotificationSettings(value: unknown): NotificationSetti typeof rawVolume === 'number' && Number.isFinite(rawVolume) ? Math.min(100, Math.max(0, rawVolume)) : defaults.customSoundVolume + // Why field-by-field: a blanket spread let a type-flipped value on disk through, so `enabled: "false"` + // stayed truthy and `customSoundPath: 42` reached the sound loader. + const booleanOr = (raw: unknown, fallback: boolean): boolean => + typeof raw === 'boolean' ? raw : fallback return { - ...defaults, - ...candidate, + enabled: booleanOr(candidate.enabled, defaults.enabled), + agentTaskComplete: booleanOr(candidate.agentTaskComplete, defaults.agentTaskComplete), + terminalBell: booleanOr(candidate.terminalBell, defaults.terminalBell), + suppressWhenFocused: booleanOr(candidate.suppressWhenFocused, defaults.suppressWhenFocused), customSoundId, + customSoundPath: + typeof candidate.customSoundPath === 'string' + ? candidate.customSoundPath + : defaults.customSoundPath, customSoundVolume } } diff --git a/src/main/persistence/applying-settings/ui-selection-normalization.ts b/src/main/persistence/applying-settings/ui-selection-normalization.ts index 644d0d4a8e7..fd702f6560b 100644 --- a/src/main/persistence/applying-settings/ui-selection-normalization.ts +++ b/src/main/persistence/applying-settings/ui-selection-normalization.ts @@ -84,12 +84,13 @@ export function normalizeRightSidebarExplorerView( view: unknown, tab?: unknown ): PersistedState['ui']['rightSidebarExplorerView'] { - // Why: older builds persisted Search as a standalone activity tab. - if (tab === 'search') { - return 'search' - } if (view === 'files' || view === 'search') { return view } + // Why: older builds persisted Search as a standalone activity tab with no explorer view; 'search' + // is still a live tab, so this fallback must not outrank an explicit view. + if (tab === 'search') { + return 'search' + } return getDefaultUIState().rightSidebarExplorerView } diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts index 0acc77f7fcc..ef8fd642f24 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts @@ -38,9 +38,14 @@ export function upsertSshRemotePtyLease( ) const existing = existingIndex !== -1 ? operations.state.sshRemotePtyLeases[existingIndex] : undefined + // Why: callers pass optional fields as explicit `undefined`, which would blank the stored tabId/leafId + // (and friends) when re-upserting an existing lease. + const definedLease = Object.fromEntries( + Object.entries(normalizedLease).filter(([, value]) => value !== undefined) + ) as typeof normalizedLease const next: SshRemotePtyLease = { ...existing, - ...normalizedLease, + ...definedLease, createdAt: existing?.createdAt ?? normalizedLease.createdAt ?? now, updatedAt: normalizedLease.updatedAt ?? now } diff --git a/src/main/persistence/loading-store/store.ts b/src/main/persistence/loading-store/store.ts index d602be2b7aa..4fd97e3214d 100644 --- a/src/main/persistence/loading-store/store.ts +++ b/src/main/persistence/loading-store/store.ts @@ -210,6 +210,7 @@ import { stripRetiredGlobalSettings } from '../applying-settings/terminal-settings-migrations' import { + normalizeRightSidebarExplorerView, normalizeRightSidebarTab, normalizeShowDotfilesByWorktree, normalizeSortBy @@ -1410,6 +1411,12 @@ export class Store { // Why: migrate once from the retired Appearance setting only when no explicit chrome preference exists yet. rightSidebarOpen, rightSidebarTab: normalizeRightSidebarTab(parsed.ui?.rightSidebarTab), + // Why here and not in getPersistedUI: only the raw payload still shows the legacy + // "Search tab, no explorer view" shape — the defaults spread above fills in 'files'. + rightSidebarExplorerView: normalizeRightSidebarExplorerView( + parsed.ui?.rightSidebarExplorerView, + parsed.ui?.rightSidebarTab + ), setupGuideSidebarDismissed, usagePercentageDisplayChangeNoticeDismissed, setupGuideBrowserMilestoneMigrated: diff --git a/src/main/persistence/loading-store/user-data-path.ts b/src/main/persistence/loading-store/user-data-path.ts index e6b11b85cfa..db62f706b7b 100644 --- a/src/main/persistence/loading-store/user-data-path.ts +++ b/src/main/persistence/loading-store/user-data-path.ts @@ -67,6 +67,10 @@ export function getCanonicalUserDataPath(): string { * Copy legacy mobile pairing credentials into the canonical userData directory. * * Copies the registry and E2EE keypair forward as a pair so an update doesn't force a re-pair or mix devices with the wrong key. + * + * Sources are deliberately left in place: a copy-then-delete has no atomic form across the userData + * dirs, and losing the originals to a crash mid-migration would strand every paired device. They stay + * readable by an older build the user rolls back to; removing them is a separate cleanup decision. */ export function migrateMobilePairingDataToCanonicalUserDataPath(sourceUserDataDir: string): void { const targetUserDataDir = getCanonicalUserDataPath() diff --git a/src/main/persistence/restoring-sessions/folder-workspace-operations.ts b/src/main/persistence/restoring-sessions/folder-workspace-operations.ts index 05783b7b5c2..feef3ebf3e0 100644 --- a/src/main/persistence/restoring-sessions/folder-workspace-operations.ts +++ b/src/main/persistence/restoring-sessions/folder-workspace-operations.ts @@ -66,9 +66,10 @@ export class FolderWorkspacePersistenceOperations { const group = (this.state.projectGroups ?? []).find( (entry) => entry.id === input.projectGroupId ) + // Why trim: the guard below accepts a padded path, so persist the same value it validated. const folderPath = typeof input.folderPath === 'string' && input.folderPath.trim().length > 0 - ? input.folderPath + ? input.folderPath.trim() : group?.parentPath if (!group || !folderPath) { throw new Error('Folder-backed project group not found.') @@ -137,7 +138,7 @@ export class FolderWorkspacePersistenceOperations { workspace.name = normalizeFolderWorkspaceName(updates.name, workspace.name) } if (typeof updates.folderPath === 'string' && updates.folderPath.trim().length > 0) { - workspace.folderPath = updates.folderPath + workspace.folderPath = updates.folderPath.trim() } if (updates.linkedTask !== undefined) { workspace.linkedTask = normalizeWorkspaceLinkedItem(updates.linkedTask) diff --git a/src/main/persistence/restoring-sessions/pane-identity-migration.ts b/src/main/persistence/restoring-sessions/pane-identity-migration.ts index e5a6fd1f1ea..e07e9995286 100644 --- a/src/main/persistence/restoring-sessions/pane-identity-migration.ts +++ b/src/main/persistence/restoring-sessions/pane-identity-migration.ts @@ -1,7 +1,6 @@ import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types' import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import type { MigrationUnsupportedPtyEntry } from '../../../shared/agent-status-types' import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id' import { agentHookServer } from '../../agent-hooks/server' import { collectLayoutLeafIdsInOrder, firstLayoutLeafId } from './terminal-layout-normalization' @@ -18,18 +17,14 @@ export function findWorktreeIdForTab( return undefined } -export type PaneIdentityMigrationEntries = { - migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] - legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] -} - -export function collectMigrationUnsupportedPtyEntries(args: { +/** Bridges a tab's legacy numeric pane keys to stable ones; returns the alias rows worth persisting. */ +export function registerLegacyPaneKeyAliasesForTab(args: { session: WorkspaceSessionState tabId: string inputLayout: TerminalLayoutSnapshot normalizedLayout: TerminalLayoutSnapshot leafIdByInputLeafId: Map -}): PaneIdentityMigrationEntries { +}): LegacyPaneKeyAliasEntry[] { const worktreeId = findWorktreeIdForTab(args.session, args.tabId) const tab = worktreeId ? args.session.tabsByWorktree?.[worktreeId]?.find((entry) => entry.id === args.tabId) @@ -111,6 +106,5 @@ export function collectMigrationUnsupportedPtyEntries(args: { } } } - // Why: legacy numeric pane keys are now bridged by aliases, not persisted as restart-required rows. - return { migrationUnsupportedEntries: [], legacyPaneKeyAliasEntries } + return legacyPaneKeyAliasEntries } diff --git a/src/main/persistence/restoring-sessions/terminal-layout-normalization.ts b/src/main/persistence/restoring-sessions/terminal-layout-normalization.ts index 9ee36737da1..672a52847eb 100644 --- a/src/main/persistence/restoring-sessions/terminal-layout-normalization.ts +++ b/src/main/persistence/restoring-sessions/terminal-layout-normalization.ts @@ -174,8 +174,13 @@ export function normalizeTerminalLayoutSnapshotForPersistence( ) const inputLeafIdsInOrder = collectLayoutLeafIdsInOrder(inputRoot) const preferredLeafIdsInOrder = collectLayoutLeafIdsInOrder(preferredLayout?.root) - const usePreferredLeafIds = preferredLeafIdsInOrder.length === inputLeafIdsInOrder.length + // Why the uniqueness check: a preferred layout that repeats a UUID would hand two input leaves the + // same id, collapsing their pty/buffer/scrollback/title records onto one key. + const usePreferredLeafIds = + preferredLeafIdsInOrder.length === inputLeafIdsInOrder.length && + new Set(preferredLeafIdsInOrder).size === preferredLeafIdsInOrder.length const leafIdByInputLeafId = new Map() + const claimedLeafIds = new Set() for (const [index, leafId] of inputLeafIdsInOrder.entries()) { const count = counts.get(leafId) ?? 0 if (count !== 1 || leafIdByInputLeafId.has(leafId)) { @@ -184,14 +189,17 @@ export function normalizeTerminalLayoutSnapshotForPersistence( } if (isTerminalLeafId(leafId)) { leafIdByInputLeafId.set(leafId, leafId) + claimedLeafIds.add(leafId) continue } changed = true const preferredLeafId = usePreferredLeafIds ? preferredLeafIdsInOrder[index] : undefined - leafIdByInputLeafId.set( - leafId, - preferredLeafId && isTerminalLeafId(preferredLeafId) ? preferredLeafId : randomUUID() - ) + const nextLeafId = + preferredLeafId && isTerminalLeafId(preferredLeafId) && !claimedLeafIds.has(preferredLeafId) + ? preferredLeafId + : randomUUID() + claimedLeafIds.add(nextLeafId) + leafIdByInputLeafId.set(leafId, nextLeafId) } const root = changed ? cloneLayoutWithLeafIds(inputRoot, leafIdByInputLeafId, duplicatedInputLeafIds) diff --git a/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts b/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts index 87e226086a1..df1bf25f036 100644 --- a/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts +++ b/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts @@ -4,7 +4,7 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-st import type { MigrationUnsupportedPtyEntry } from '../../../shared/agent-status-types' import type { SshRemotePtyLease } from '../../../shared/ssh-types' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id' -import { collectMigrationUnsupportedPtyEntries } from './pane-identity-migration' +import { registerLegacyPaneKeyAliasesForTab } from './pane-identity-migration' import { normalizeTerminalLayoutSnapshotForPersistence } from './terminal-layout-normalization' import { legacyMigrationUnsupportedRowsToAliasEntries, @@ -28,6 +28,8 @@ export function normalizeWorkspaceSessionPaneIdentities( let changed = false const leafIdByInputLeafIdByTabId = new Map>() const leafIdByPtyIdByTabId = new Map>() + // Why always empty: legacy numeric pane keys are bridged by aliases now, not persisted as + // restart-required rows; the field stays so callers keep clearing stale rows written by old builds. const migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] = [] const legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] = [] const terminalLayoutsByTabId: Record = {} @@ -38,7 +40,7 @@ export function normalizeWorkspaceSessionPaneIdentities( ) terminalLayoutsByTabId[tabId] = normalized.snapshot leafIdByInputLeafIdByTabId.set(tabId, normalized.leafIdByInputLeafId) - const migrationEntries = collectMigrationUnsupportedPtyEntries({ + const tabAliasEntries = registerLegacyPaneKeyAliasesForTab({ session, tabId, inputLayout: layout, @@ -46,10 +48,7 @@ export function normalizeWorkspaceSessionPaneIdentities( leafIdByInputLeafId: normalized.leafIdByInputLeafId }) // Why: old split layouts can generate enough alias rows to exceed V8's argument limit if spread into push(). - for (const entry of migrationEntries.migrationUnsupportedEntries) { - migrationUnsupportedEntries.push(entry) - } - for (const entry of migrationEntries.legacyPaneKeyAliasEntries) { + for (const entry of tabAliasEntries) { legacyPaneKeyAliasEntries.push(entry) } const leafIdByPtyId = new Map() @@ -107,6 +106,19 @@ export function remapSshRemotePtyLeaseLeafIds( return { leases: nextLeases, changed } } +/** Combines per-tab leaf maps from separate host partitions; an already-mapped tab keeps its mapping. */ +function mergeLeafIdMapsByTabId( + target: Map>, + source: Map> +): Map> { + const merged = new Map(target) + for (const [tabId, leafIds] of source) { + const existing = merged.get(tabId) + merged.set(tabId, existing ? new Map([...leafIds, ...existing]) : new Map(leafIds)) + } + return merged +} + export function normalizePersistedPaneIdentityState(state: PersistedState): { state: PersistedState changed: boolean @@ -114,20 +126,54 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): { legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] } { const normalizedSession = normalizeWorkspaceSessionPaneIdentities(state.workspaceSession, {}) + let leafIdByInputLeafIdByTabId = normalizedSession.leafIdByInputLeafIdByTabId + let leafIdByPtyIdByTabId = normalizedSession.leafIdByPtyIdByTabId + const hostSessionLegacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] = [] + // Why: SSH/runtime hosts keep their own session blob, and their legacy leaves need the same UUID + // rewrite — otherwise their leases and read markers still point at `pane:1` after migration. + const normalizedHostSessions = state.workspaceSessionsByHostId + ? { ...state.workspaceSessionsByHostId } + : undefined + let hostSessionsChanged = false + if (normalizedHostSessions) { + for (const hostId of Object.keys( + normalizedHostSessions + ) as (keyof typeof normalizedHostSessions)[]) { + const hostSession = normalizedHostSessions[hostId] + if (!hostSession) { + continue + } + const normalizedHostSession = normalizeWorkspaceSessionPaneIdentities(hostSession, {}) + normalizedHostSessions[hostId] = normalizedHostSession.session + leafIdByInputLeafIdByTabId = mergeLeafIdMapsByTabId( + leafIdByInputLeafIdByTabId, + normalizedHostSession.leafIdByInputLeafIdByTabId + ) + leafIdByPtyIdByTabId = mergeLeafIdMapsByTabId( + leafIdByPtyIdByTabId, + normalizedHostSession.leafIdByPtyIdByTabId + ) + for (const entry of normalizedHostSession.legacyPaneKeyAliasEntries) { + hostSessionLegacyPaneKeyAliasEntries.push(entry) + } + hostSessionsChanged ||= normalizedHostSession.changed + } + } const remappedLeases = remapSshRemotePtyLeaseLeafIds( state.sshRemotePtyLeases ?? [], - normalizedSession.leafIdByInputLeafIdByTabId, - normalizedSession.leafIdByPtyIdByTabId + leafIdByInputLeafIdByTabId, + leafIdByPtyIdByTabId ) const mergedMigrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] = [] const mergedLegacyPaneKeyAliasEntries = mergeLegacyPaneKeyAliasEntries([ ...normalizeLegacyPaneKeyAliasEntries(state.legacyPaneKeyAliasEntries), ...legacyMigrationUnsupportedRowsToAliasEntries(state.migrationUnsupportedPtyEntries ?? []), - ...normalizedSession.legacyPaneKeyAliasEntries + ...normalizedSession.legacyPaneKeyAliasEntries, + ...hostSessionLegacyPaneKeyAliasEntries ]) const remappedAcknowledgements = remapAcknowledgedAgentPaneKeys( state.ui?.acknowledgedAgentsByPaneKey, - normalizedSession.leafIdByInputLeafIdByTabId + leafIdByInputLeafIdByTabId ) const migrationUnsupportedChanged = !migrationUnsupportedEntriesEqual( state.migrationUnsupportedPtyEntries ?? [], @@ -139,6 +185,7 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): { ) if ( !normalizedSession.changed && + !hostSessionsChanged && !remappedLeases.changed && !migrationUnsupportedChanged && !legacyAliasesChanged && @@ -155,6 +202,7 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): { state: { ...state, workspaceSession: normalizedSession.session, + ...(normalizedHostSessions ? { workspaceSessionsByHostId: normalizedHostSessions } : {}), sshRemotePtyLeases: remappedLeases.leases, migrationUnsupportedPtyEntries: mergedMigrationUnsupportedEntries, legacyPaneKeyAliasEntries: mergedLegacyPaneKeyAliasEntries, diff --git a/src/main/persistence/scheduling-automations/automation-definition-operations.ts b/src/main/persistence/scheduling-automations/automation-definition-operations.ts index f8ac0abe0a5..fd01e31b66e 100644 --- a/src/main/persistence/scheduling-automations/automation-definition-operations.ts +++ b/src/main/persistence/scheduling-automations/automation-definition-operations.ts @@ -82,6 +82,11 @@ export function updateAutomation( throw new Error('Automation not found.') } const current = operations.state.automations[index] + // Why: the renderer forwards a Partial verbatim, so `{ enabled: undefined }` survives structuredClone + // and would blank the stored value in the spread below. Explicit clears go through the `null` branches. + const definedUpdates = Object.fromEntries( + Object.entries(updates).filter(([, value]) => value !== undefined) + ) as AutomationUpdateInput const repoId = updates.projectId ?? current.projectId const repo = operations.state.repos.find((entry) => entry.id === repoId) const executionTargetType = repo?.connectionId ? 'ssh' : 'local' @@ -93,7 +98,7 @@ export function updateAutomation( const workspaceMode = updates.workspaceMode ?? current.workspaceMode const updated: Automation = { ...current, - ...updates, + ...definedUpdates, name: updates.name !== undefined ? updates.name.trim() || 'Untitled automation' : current.name, precheck: Object.hasOwn(updates, 'precheck') ? normalizeAutomationPrecheck(updates.precheck) diff --git a/src/main/persistence/scheduling-automations/automation-schedule-operations.ts b/src/main/persistence/scheduling-automations/automation-schedule-operations.ts index 0dbf1f89845..b52de27baa0 100644 --- a/src/main/persistence/scheduling-automations/automation-schedule-operations.ts +++ b/src/main/persistence/scheduling-automations/automation-schedule-operations.ts @@ -17,7 +17,7 @@ export function advanceAutomationNextRun( } const current = state.automations[index] const nextRunAt = nextAutomationOccurrenceAfter(current.rrule, current.dtstart, now) - const updated = { ...current, nextRunAt, updatedAt: Date.now() } + const updated = { ...current, nextRunAt, updatedAt: now } state.automations[index] = updated flush() return updated diff --git a/src/main/persistence/tracking-repos/project-host-operations.ts b/src/main/persistence/tracking-repos/project-host-operations.ts index ff992a32aa6..25153c37d86 100644 --- a/src/main/persistence/tracking-repos/project-host-operations.ts +++ b/src/main/persistence/tracking-repos/project-host-operations.ts @@ -15,6 +15,7 @@ import { getRepoExecutionHostId, normalizeExecutionHostId } from '../../../share import { normalizeProjectRuntimePreference } from '../../../shared/project-execution-runtime' import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { makeProjectHostSetupId } from './project-host-compatibility' +import { repoGitUsernameCacheKey } from './repo-hydration' export type ProjectHostMutationOperations = { state: StoreOwnedPersistedState @@ -214,8 +215,9 @@ export class ProjectHostPersistenceOperations { if (!repo) { return false } - const previous = this.gitUsernameCache.get(repo.path) ?? repo.gitUsername ?? '' - this.gitUsernameCache.set(repo.path, username) + const cacheKey = repoGitUsernameCacheKey(repo) + const previous = this.gitUsernameCache.get(cacheKey) ?? repo.gitUsername ?? '' + this.gitUsernameCache.set(cacheKey, username) if (previous === username) { return false } diff --git a/src/main/persistence/tracking-repos/repo-hydration.ts b/src/main/persistence/tracking-repos/repo-hydration.ts index 00f5b8fa1e9..10728f55b67 100644 --- a/src/main/persistence/tracking-repos/repo-hydration.ts +++ b/src/main/persistence/tracking-repos/repo-hydration.ts @@ -1,4 +1,5 @@ import type { Repo } from '../../../shared/repo-types' +import { getRepoExecutionHostId } from '../../../shared/execution-host' import { getDefaultRepoHookSettings } from '../../../shared/constants' import { isFolderRepo } from '../../../shared/repo-kind' import { sanitizeRepoIcon } from '../../../shared/repo-icon' @@ -14,6 +15,17 @@ import { normalizeWorktreeVisibilitySourcePreferences } from '../../../shared/worktree/visibility-sources' +/** + * Cache key for a repo's resolved git username. Host-scoped because the same checkout path can exist + * on local, SSH, and runtime hosts with different `user.name`, and a path-only key hydrates one + * host's username onto another (wrong `git-username` branch prefix). + */ +export function repoGitUsernameCacheKey( + repo: Pick +): string { + return `${getRepoExecutionHostId(repo)}\u0000${repo.path}` +} + export function hydrateRepo(repo: Repo, gitUsernameCache: ReadonlyMap): Repo { const { repoIcon: rawRepoIcon, @@ -41,7 +53,7 @@ export function hydrateRepo(repo: Repo, gitUsernameCache: ReadonlyMap() const collectPrefixedKeys = (keys: Iterable): void => { for (const key of keys) { @@ -61,13 +52,9 @@ export function pruneWorktreeStateForRepo( delete state.worktreeMeta[key] } } - // Why: owner keys are `${repoId}::${path}` and do not carry a host, so a - // host-scoped prune (hostId != null) must only touch that host's session: - // the legacy blob is the local host's session, and each - // workspaceSessionsByHostId partition is one non-local host. Pruning every - // partition here would wipe a surviving host's tabs, sleeping-agent state, - // and active-worktree pointer for a shared repo id/path. A full removal - // (hostId === null) still clears every host. + // Why: a host-scoped prune must touch only that host's session (legacy blob = local, one partition + // per remote host); pruning every partition would wipe a surviving host's tabs and sleeping agents + // for a shared repo id/path. A full removal (hostId === null) still clears every host. const pruneLegacyLocalSession = hostId === null || hostId === LOCAL_EXECUTION_HOST_ID const pruneAllHostPartitions = hostId === null if (pruneLegacyLocalSession) { diff --git a/src/main/persistence/tracking-repos/worktree-identity-migration.ts b/src/main/persistence/tracking-repos/worktree-identity-migration.ts index e743d70c9a3..f8256c0c4fe 100644 --- a/src/main/persistence/tracking-repos/worktree-identity-migration.ts +++ b/src/main/persistence/tracking-repos/worktree-identity-migration.ts @@ -159,6 +159,9 @@ export function migrateWorktreeIdentity( const movedLineage = state.worktreeLineageById[newWorktreeId] if (movedLineage && movedLineage.worktreeId === oldWorktreeId) { movedLineage.worktreeId = newWorktreeId + // Why: moveKey reports nothing when the record already sat under the new key, so flag the repair + // ourselves or the caller skips the save and the stale id comes back on reload. + changed = true } // Why: children carry this as parentWorktreeId; keep the denormalized path-derived id consistent (parentWorktreeInstanceId is stable). for (const lineage of Object.values(state.worktreeLineageById)) { diff --git a/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts b/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts index 052b2d5cf4f..d0d29030803 100644 --- a/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts +++ b/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts @@ -23,7 +23,10 @@ export const STALE_DURABLE_WRITE_TEMP_AGE_MS = 24 * 60 * 60 * 1000 export function gcStaleWorktreeMeta(state: StoreOwnedPersistedState): number { // Why: a hand-corrupted "worktreeMeta": null overrides the defaults merge; normalize here instead of throwing. + // Companion lineage maps get the same guard because the deletes below index them directly. state.worktreeMeta ??= {} + state.worktreeLineageById ??= {} + state.workspaceLineageByChildKey ??= {} const repoById = new Map(state.repos.map((repo) => [repo.id, repo])) const projectIds = new Set((state.projects ?? []).map((project) => project.id)) const now = Date.now() @@ -79,14 +82,24 @@ export function gcStaleWorktreeMeta(state: StoreOwnedPersistedState): number { export function normalizeWorktreeLinkedItemMetadata(state: StoreOwnedPersistedState): boolean { let changed = false + // Why: a hand-corrupted null lineage map would throw on the companion deletes below. + state.worktreeLineageById ??= {} + state.workspaceLineageByChildKey ??= {} const rawWorktreeMeta = state.worktreeMeta as unknown if ( typeof rawWorktreeMeta !== 'object' || rawWorktreeMeta === null || Array.isArray(rawWorktreeMeta) ) { + const hadLineage = + Object.keys(state.worktreeLineageById).length > 0 || + Object.keys(state.workspaceLineageByChildKey).length > 0 state.worktreeMeta = {} - changed = rawWorktreeMeta !== undefined + // Companions go with the discarded map; a stranded lineage row would otherwise re-attach to a + // worktree recreated at the same repoId::path. + state.worktreeLineageById = {} + state.workspaceLineageByChildKey = {} + changed = rawWorktreeMeta !== undefined || hadLineage } for (const [key, meta] of Object.entries(state.worktreeMeta)) { // Why: hand-corrupted non-object entries are a real input class; drop them here because gcStaleWorktreeMeta