fix(persistence): address review feedback on extracted modules (#14862)

Follow-ups to the module extraction (#14252):

- Guard the lineage companion maps in worktree meta GC, and clear them
  alongside a corrupt worktreeMeta so stale rows can't re-attach.
- Repair a renamed worktree's stale lineage.worktreeId and flag the save.
- Key the git-username cache by execution host + path so the same checkout
  path on local/SSH/runtime hosts can't cross-hydrate usernames.
- Drop undefined keys before the automation update spread; a Partial with
  an explicit undefined blanked stored values.
- Normalize pane identities in every workspaceSessionsByHostId partition,
  not just the legacy blob, and use the merged leaf maps for lease and
  acknowledgement remapping.
- Reject duplicate preferred leaf ids so two panes can't collapse onto one
  pty/buffer/scrollback key.
- Let an explicit rightSidebarExplorerView outrank the legacy search-tab
  fallback; the legacy migration now runs on the raw payload at load.
- Record why the mobile pairing migration leaves its sources in place.
- Trim persisted folderPath, normalize synthesized worktree visibility
  preferences, validate notification settings field-by-field, strip
  undefined optional keys before the SSH lease merge, reuse `now` for an
  automation's updatedAt, and rename the pane alias registrar.
This commit is contained in:
Jinjing
2026-08-16 00:36:21 -07:00
committed by GitHub
parent 470ef65bd7
commit c066a557ea
18 changed files with 224 additions and 60 deletions
@@ -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()
@@ -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
}
}
@@ -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
}
@@ -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
}
@@ -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:
@@ -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()
@@ -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)
@@ -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<string, string>
}): 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
}
@@ -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<string, string>()
const claimedLeafIds = new Set<string>()
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)
@@ -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<string, Map<string, string>>()
const leafIdByPtyIdByTabId = new Map<string, Map<string, string>>()
// 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<string, TerminalLayoutSnapshot> = {}
@@ -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<string, string>()
@@ -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<string, Map<string, string>>,
source: Map<string, Map<string, string>>
): Map<string, Map<string, string>> {
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,
@@ -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)
@@ -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
@@ -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
}
@@ -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<Repo, 'path' | 'connectionId' | 'executionHostId'>
): string {
return `${getRepoExecutionHostId(repo)}\u0000${repo.path}`
}
export function hydrateRepo(repo: Repo, gitUsernameCache: ReadonlyMap<string, string>): Repo {
const {
repoIcon: rawRepoIcon,
@@ -41,7 +53,7 @@ export function hydrateRepo(repo: Repo, gitUsernameCache: ReadonlyMap<string, st
// Why: never spawn git/gh username resolution in hydration — a stuck probe froze Windows startup for minutes (issue #7225); read only cache/persisted value.
const gitUsername = isFolderRepo(repo)
? ''
: (gitUsernameCache.get(repo.path) ?? repo.gitUsername ?? '')
: (gitUsernameCache.get(repoGitUsernameCacheKey(repo)) ?? repo.gitUsername ?? '')
return {
...repoWithoutIcon,
@@ -4,6 +4,7 @@ import type { ExecutionHostId } from '../../../shared/execution-host'
import { getRepoExecutionHostId } from '../../../shared/execution-host'
import { isLegacyRepoForExternalWorktreeVisibility } from '../../../shared/external-worktree-visibility'
import { normalizeRepoSourceControlAiOverrides } from '../../../shared/source-control-ai'
import { normalizeWorktreeVisibilitySourcePreferences } from '../../../shared/worktree/visibility-sources'
import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state'
import { sanitizeRepoUpdatesForPersistence } from './repo-sanitization'
@@ -82,12 +83,17 @@ export class RepoUpdatePersistenceOperations {
(sanitizedUpdates.agentWorktreeVisibility === 'hide' ||
sanitizedUpdates.agentWorktreeVisibility === 'show')
) {
sanitizedUpdates.worktreeVisibilitySourcePreferences = {
// Why normalize: the stored value is spread in as-is, so a legacy/corrupt custom map would be
// written straight back without passing the same validation as a renderer-supplied patch.
const preferences = normalizeWorktreeVisibilitySourcePreferences({
...repo.worktreeVisibilitySourcePreferences,
builtIn: {
claude: sanitizedUpdates.agentWorktreeVisibility,
gsd: sanitizedUpdates.agentWorktreeVisibility
}
})
if (preferences) {
sanitizedUpdates.worktreeVisibilitySourcePreferences = preferences
}
}
if ('projectGroupId' in sanitizedUpdates) {
@@ -30,18 +30,9 @@ export function pruneWorktreeStateForRepo(
hostMembership.set(key, result)
return result
}
// Why: session state (legacy blob + per-host partitions) references worktrees
// by the same `${repoId}::${path}` owner key; if it is not pruned here, a
// deleted project's worktrees stay in lastVisitedAtByWorktreeId /
// sleepingAgentSessionsByPaneKey and get re-materialized into worktreeMeta on
// the next launch, surfacing as an orphaned "unknown" workspace.
// worktreeMeta is host-classified via belongsToHost, but session partitions
// are keyed by host directly. A session owner key carries no host, and the
// same key can exist in multiple partitions (shared repo id/path across
// hosts). So for session cleanup we collect every prefix-matching owner key
// regardless of belongsToHost, and let the per-partition host gating below
// decide which partition to touch. (belongsToHost still governs
// worktreeMeta/lineage deletion. Collect before deleting worktreeMeta.)
// Why: leftover session owner keys re-materialize into worktreeMeta next launch as orphaned
// "unknown" workspaces. Owner keys carry no host and can repeat across partitions, so collect every
// prefix match (before the worktreeMeta deletes below) and let the per-partition gating decide.
const ownerKeysToPrune = new Set<string>()
const collectPrefixedKeys = (keys: Iterable<string>): 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) {
@@ -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)) {
@@ -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