From 53eec2ae5ff5e2b9230aae6722a4658cea0d16cc Mon Sep 17 00:00:00 2001 From: BingZ Date: Fri, 10 Jul 2026 16:16:14 +0800 Subject: [PATCH] fix(terminals): remap stale worktree ids on restore --- ...session-worktree-id-reconciliation.test.ts | 189 ++++++++++++++++++ ...pace-session-worktree-id-reconciliation.ts | 117 +++++++++++ src/renderer/src/store/slices/terminals.ts | 9 +- 3 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/lib/workspace-session-worktree-id-reconciliation.test.ts create mode 100644 src/renderer/src/lib/workspace-session-worktree-id-reconciliation.ts diff --git a/src/renderer/src/lib/workspace-session-worktree-id-reconciliation.test.ts b/src/renderer/src/lib/workspace-session-worktree-id-reconciliation.test.ts new file mode 100644 index 00000000000..6a10226279d --- /dev/null +++ b/src/renderer/src/lib/workspace-session-worktree-id-reconciliation.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from 'vitest' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { Repo, Worktree, WorkspaceSessionState } from '../../../shared/types' +import { reconcileWorkspaceSessionWorktreeIds } from './workspace-session-worktree-id-reconciliation' + +function repo(id: string, path: string, connectionId?: string): Repo { + return { id, path, displayName: id, badgeColor: '#000', addedAt: 0, connectionId } +} + +function worktree(id: string, repoId: string, path: string, hostId?: ExecutionHostId): Worktree { + return { + id, + repoId, + path, + hostId, + displayName: id, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + head: '', + branch: '', + isBare: false, + isMainWorktree: true + } +} + +function session(oldId: string, newId?: string): WorkspaceSessionState { + return { + activeRepoId: oldId.split('::')[0]!, + activeWorktreeId: oldId, + activeWorkspaceKey: `worktree:${oldId}`, + activeTabId: 'old-tab', + tabsByWorktree: { + [oldId]: [ + { + id: 'old-tab', + ptyId: null, + worktreeId: oldId, + title: 'Grok', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ], + ...(newId + ? { + [newId]: [ + { + id: 'new-tab', + ptyId: null, + worktreeId: newId, + title: 'Codex', + customTitle: null, + color: null, + sortOrder: 1, + createdAt: 2 + } + ] + } + : {}) + }, + activeTabIdByWorktree: { [oldId]: 'old-tab' }, + activeWorktreeIdsOnShutdown: [oldId], + terminalLayoutsByTabId: { + 'old-tab': { + root: { type: 'leaf', leafId: 'leaf-old' }, + activeLeafId: 'leaf-old', + expandedLeafId: null, + ptyIdsByLeafId: { 'leaf-old': `${oldId}@@daemon-session` } + } + }, + sleepingAgentSessionsByPaneKey: { + 'old-tab:leaf-old': { + paneKey: 'old-tab:leaf-old', + tabId: 'old-tab', + worktreeId: oldId, + agent: 'grok', + providerSession: null, + prompt: '', + state: 'working', + capturedAt: 1, + updatedAt: 1, + terminalTitle: 'Grok', + origin: 'quit' + } + } + } as unknown as WorkspaceSessionState +} + +describe('reconcileWorkspaceSessionWorktreeIds', () => { + it('moves a stale local worktree id to the unique current path match', () => { + const oldId = 'old-repo::D:/Code/orca' + const newId = 'new-repo::D:\\Code\\orca' + const result = reconcileWorkspaceSessionWorktreeIds({ + session: session(oldId, newId), + repos: [repo('new-repo', 'D:\\Code\\orca')], + worktreesByRepo: { 'new-repo': [worktree(newId, 'new-repo', 'D:\\Code\\orca')] }, + runtimeHostIdByWorkspaceSessionKey: {} + }) + + expect(result.activeWorktreeId).toBe(newId) + expect(result.activeRepoId).toBe('new-repo') + expect(result.tabsByWorktree[newId]?.map((tab) => tab.id)).toEqual(['old-tab', 'new-tab']) + expect(result.tabsByWorktree[oldId]).toBeUndefined() + expect(result.tabsByWorktree[newId]?.[0]?.worktreeId).toBe(newId) + expect(result.sleepingAgentSessionsByPaneKey?.['old-tab:leaf-old']?.worktreeId).toBe(newId) + expect(result.terminalLayoutsByTabId['old-tab']?.ptyIdsByLeafId?.['leaf-old']).toBe( + `${oldId}@@daemon-session` + ) + }) + + it('does not guess when multiple worktrees share a host and normalized path', () => { + const oldId = 'old::/repo' + const first = 'one::/repo' + const second = 'two::/repo' + const original = session(oldId) + const result = reconcileWorkspaceSessionWorktreeIds({ + session: original, + repos: [repo('one', '/repo'), repo('two', '/repo')], + worktreesByRepo: { + one: [worktree(first, 'one', '/repo')], + two: [worktree(second, 'two', '/repo')] + }, + runtimeHostIdByWorkspaceSessionKey: {} + }) + + expect(result).toBe(original) + }) + + it('only uses the missing-repo local fallback for a unique local path match', () => { + const oldId = 'missing::/repo' + const localId = 'local-repo::/repo' + const remoteId = 'remote-repo::/repo' + const result = reconcileWorkspaceSessionWorktreeIds({ + session: session(oldId), + repos: [repo('local-repo', '/repo'), repo('remote-repo', '/repo')], + worktreesByRepo: { + 'local-repo': [worktree(localId, 'local-repo', '/repo')], + 'remote-repo': [worktree(remoteId, 'remote-repo', '/repo', 'ssh:builder')] + }, + runtimeHostIdByWorkspaceSessionKey: {} + }) + + expect(result.activeWorktreeId).toBe(localId) + expect(result.activeRepoId).toBe('local-repo') + expect(result.tabsByWorktree[remoteId]).toBeUndefined() + }) + + it('does not cross execution hosts when paths are identical', () => { + const oldId = 'old::/repo' + const remoteId = 'remote::/repo' + const original = session(oldId) + const result = reconcileWorkspaceSessionWorktreeIds({ + session: original, + repos: [repo('remote', '/repo')], + worktreesByRepo: { + remote: [worktree(remoteId, 'remote', '/repo', 'runtime:remote-host')] + }, + runtimeHostIdByWorkspaceSessionKey: {} + }) + + expect(result).toBe(original) + }) + + it('matches an SSH repo connection id to its canonical worktree host id', () => { + const oldId = 'old::/repo' + const newId = 'new::/repo' + const result = reconcileWorkspaceSessionWorktreeIds({ + session: session(oldId), + repos: [repo('old', '/repo', 'builder'), repo('new', '/repo', 'builder')], + worktreesByRepo: { + new: [worktree(newId, 'new', '/repo', 'ssh:builder')] + }, + runtimeHostIdByWorkspaceSessionKey: {} + }) + + expect(result.activeWorktreeId).toBe(newId) + expect(result.tabsByWorktree[newId]?.[0]?.worktreeId).toBe(newId) + }) +}) diff --git a/src/renderer/src/lib/workspace-session-worktree-id-reconciliation.ts b/src/renderer/src/lib/workspace-session-worktree-id-reconciliation.ts new file mode 100644 index 00000000000..dadbbdec577 --- /dev/null +++ b/src/renderer/src/lib/workspace-session-worktree-id-reconciliation.ts @@ -0,0 +1,117 @@ +import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' +import { getRepoExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' +import type { Repo, Worktree, WorkspaceSessionState } from '../../../shared/types' +import { splitWorktreeId } from '../../../shared/worktree-id' +import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' + +type ReconciliationInput = { + session: WorkspaceSessionState + repos: readonly Repo[] + worktreesByRepo: Record + runtimeHostIdByWorkspaceSessionKey: Record +} + +function repoHostKey(repo: Repo | undefined): string { + return repo ? getRepoExecutionHostId(repo) : 'local' +} + +function worktreeHostKey(worktree: Worktree, reposById: Map): string { + return worktree.hostId ?? repoHostKey(reposById.get(worktree.repoId)) +} + +function mergeCollision(left: unknown, right: unknown): unknown { + if (Array.isArray(left) && Array.isArray(right)) { + return [...left, ...right] + } + if ( + left !== null && + right !== null && + typeof left === 'object' && + typeof right === 'object' && + !Array.isArray(left) && + !Array.isArray(right) + ) { + return { ...(left as Record), ...(right as Record) } + } + return right +} + +function remapSerializableValue(value: unknown, aliases: ReadonlyMap): unknown { + if (typeof value === 'string') { + const direct = aliases.get(value) + if (direct) { + return direct + } + for (const [oldId, newId] of aliases) { + if (value === worktreeWorkspaceKey(oldId)) { + return worktreeWorkspaceKey(newId) + } + } + return value + } + if (Array.isArray(value)) { + return value.map((entry) => remapSerializableValue(entry, aliases)) + } + if (value === null || typeof value !== 'object') { + return value + } + const remapped: Record = {} + for (const [key, entry] of Object.entries(value)) { + const remappedKey = aliases.get(key) ?? key + const remappedEntry = remapSerializableValue(entry, aliases) + remapped[remappedKey] = + remappedKey in remapped ? mergeCollision(remapped[remappedKey], remappedEntry) : remappedEntry + } + return remapped +} + +export function reconcileWorkspaceSessionWorktreeIds({ + session, + repos, + worktreesByRepo, + runtimeHostIdByWorkspaceSessionKey +}: ReconciliationInput): WorkspaceSessionState { + const currentWorktrees = Object.values(worktreesByRepo).flat() + const currentIds = new Set(currentWorktrees.map((worktree) => worktree.id)) + const reposById = new Map(repos.map((repo) => [repo.id, repo])) + const aliases = new Map() + + for (const oldId of Object.keys(session.tabsByWorktree)) { + if (currentIds.has(oldId)) { + continue + } + const parsed = splitWorktreeId(oldId) + if (!parsed?.worktreePath) { + continue + } + const explicitHost = + runtimeHostIdByWorkspaceSessionKey[worktreeWorkspaceKey(oldId)] ?? + runtimeHostIdByWorkspaceSessionKey[oldId] + const oldRepo = reposById.get(parsed.repoId) + const oldHost = explicitHost ?? (oldRepo ? repoHostKey(oldRepo) : 'local') + const oldPath = normalizeRuntimePathForComparison(parsed.worktreePath) + const matches = currentWorktrees.filter( + (worktree) => + worktreeHostKey(worktree, reposById) === oldHost && + normalizeRuntimePathForComparison(worktree.path) === oldPath + ) + if (matches.length === 1 && matches[0]?.id !== oldId) { + aliases.set(oldId, matches[0]!.id) + } + } + + if (aliases.size === 0) { + return session + } + // Why: daemon PTY ids merely contain the old worktree id as a prefix; only + // exact session keys/values are remapped so the live daemon can still attach. + const reconciled = remapSerializableValue(session, aliases) as WorkspaceSessionState + const activeWorktreeAlias = session.activeWorktreeId + ? aliases.get(session.activeWorktreeId) + : undefined + if (activeWorktreeAlias) { + reconciled.activeRepoId = + splitWorktreeId(activeWorktreeAlias)?.repoId ?? reconciled.activeRepoId + } + return reconciled +} diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 211dded1173..ad787a592fd 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -83,6 +83,7 @@ import { removeSleepingRecordsReplacedByManualWorktreeSleep, type AgentStatusWorktreeShutdownReason } from './agent-status' +import { reconcileWorkspaceSessionWorktreeIds } from '@/lib/workspace-session-worktree-id-reconciliation' function getNextTerminalOrdinal(tabs: TerminalTab[]): number { const usedOrdinals = new Set() @@ -2877,13 +2878,19 @@ export const createTerminalSlice: StateCreator return data }, - hydrateWorkspaceSession: (session, options) => { + hydrateWorkspaceSession: (persistedSession, options) => { set((s) => { const runtimeSessionPlaceholders = buildRuntimeSessionPlaceholders({ repos: s.repos, runtimeHostIdByWorkspaceSessionKey: options?.runtimeHostIdByWorkspaceSessionKey ?? {}, worktreesByRepo: s.worktreesByRepo }) + const session = reconcileWorkspaceSessionWorktreeIds({ + session: persistedSession, + repos: runtimeSessionPlaceholders.repos, + worktreesByRepo: runtimeSessionPlaceholders.worktreesByRepo, + runtimeHostIdByWorkspaceSessionKey: options?.runtimeHostIdByWorkspaceSessionKey ?? {} + }) const validWorktreeIds = new Set( Object.values(runtimeSessionPlaceholders.worktreesByRepo) .flat()