diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-scope.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-scope.ts index f37c52942d1..95a3a7be8c9 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-scope.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-scope.ts @@ -171,6 +171,22 @@ export function orcadMigrationOwnerMatchesScope( ) } +/** + * An unqualified key naming a moved repo that another host also registers: it may be this move's + * row or the other host's, so a WSL move can neither carry nor ignore what it owns. + */ +export function orcadMigrationOwnerKeyIsAmbiguous( + value: string | null | undefined, + scope: OrcadMigrationSourceScope +): boolean { + if (!value || isWorktreeHostIdentity(value)) { + return false + } + return [...scope.sharedRepoIds].some( + (repoId) => scope.repoIds.has(repoId) && ownerKeyBelongsToRepo(value, repoId) + ) +} + export function unqualifyOrcadMigrationOwnerKey(value: string): string { return isWorktreeHostIdentity(value) ? getWorktreeIdFromHostIdentity(value) : value } diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts index c839cf45aab..fef2f6b84ca 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts @@ -6,6 +6,7 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { + orcadMigrationOwnerKeyIsAmbiguous, orcadMigrationOwnerMatchesScope, orcadMigrationOwnsRepoId, orcadMigrationScopeOwnsWholeHost, @@ -33,7 +34,10 @@ export function collectUnsupportedSessionItems( // A WSL move takes some of host `local`'s projects; the others, and rows a past move retained, // stay on this desktop untouched, so only the selected projects' rows can refuse it. const wholeHost = sourceHostPartition && orcadMigrationScopeOwnsWholeHost(scope) - const matches = (ownerKey: string): boolean => Boolean(ownerKey) && (wholeHost || owns(ownerKey)) + // An unattributable key that may be the selected project's refuses rather than staying behind. + const matches = (ownerKey: string): boolean => + Boolean(ownerKey) && + (wholeHost || owns(ownerKey) || orcadMigrationOwnerKeyIsAmbiguous(ownerKey, scope)) const items: string[] = [] // Another project's rows in this host's copy stay behind; only an unsaved draft there is work. if (wholeHost) { diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-manifest-export.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-manifest-export.test.ts index 61484876530..761a917e27c 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-manifest-export.test.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-manifest-export.test.ts @@ -313,6 +313,84 @@ describe('exportWslMoveManifest', () => { ).not.toThrow() }) + describe('only the selected projects can refuse it', () => { + const agentIn = (worktreeId: string, tabId: string, leafId: string): SleepingAgentSessionRecord => ({ + paneKey: `${tabId}:${leafId}`, + tabId, + worktreeId, + agent: 'codex', + providerSession: { key: 'session_id', id: `session-${tabId}` }, + prompt: 'Saved prompt', + state: 'done', + capturedAt: 1, + updatedAt: 1, + connectionId: null, + origin: 'live' + }) + const layout = (leafId: string) => ({ + root: { type: 'leaf' as const, leafId }, + activeLeafId: leafId, + expandedLeafId: null + }) + + it('is not refused by a live agent with its tab open, and a draft, in another project', () => { + const state = sourceState() + const other = agentIn(OTHER_WORKTREE, 'windows-shell', 'win-leaf') + state.workspaceSession.terminalLayoutsByTabId = { 'windows-shell': layout('elsewhere') } + state.workspaceSession.sleepingAgentSessionsByPaneKey = { [other.paneKey]: other } + state.workspaceSession.openFilesByWorktree = { + [OTHER_WORKTREE]: [ + { + filePath: 'C:\\code\\win\\notes.md', + relativePath: 'notes.md', + worktreeId: OTHER_WORKTREE, + language: 'markdown', + dirtyDraftContent: 'unsaved' + } + ] + } + expect(exportWslMoveManifest(storeFor(state), REQUEST).blockers).toEqual([]) + }) + + it('is still refused by the same kinds of state in the selected project', () => { + const state = sourceState() + const own = agentIn(WORKTREE, 'feature-shell', 'feature-leaf') + state.workspaceSession.terminalLayoutsByTabId = { 'feature-shell': layout('elsewhere') } + state.workspaceSession.sleepingAgentSessionsByPaneKey = { [own.paneKey]: own } + state.workspaceSession.openFilesByWorktree = { + [WORKTREE]: [ + { + filePath: '/home/me/app-feature/notes.md', + relativePath: 'notes.md', + worktreeId: WORKTREE, + language: 'markdown', + dirtyDraftContent: 'unsaved', + runtimeEnvironmentId: 'some-other-server' + } + ] + } + const { blockers } = exportWslMoveManifest(storeFor(state), REQUEST) + expect(blockers).toEqual( + expect.arrayContaining([ + expect.stringMatching(/codex session saved in/), + expect.stringMatching(/unsaved changes to/) + ]) + ) + }) + + it('never silently leaves behind an agent whose key may be the selected project', () => { + // `app` is also registered by an SSH host, so an unqualified `app::` key names either. + const state = sourceState() + state.repos.push(repo('app', '/srv/app', { connectionId: 'box' })) + const own = agentIn(WORKTREE, 'feature-shell', 'feature-leaf') + state.workspaceSession.terminalLayoutsByTabId = { 'feature-shell': layout('elsewhere') } + state.workspaceSession.sleepingAgentSessionsByPaneKey = { [own.paneKey]: own } + expect(exportWslMoveManifest(storeFor(state), REQUEST).blockers).toEqual([ + expect.stringMatching(/codex session saved in/) + ]) + }) + }) + it('refuses to sign a scope naming a project that is no longer here', () => { expect(() => exportWslMoveManifest(storeFor(sourceState()), { ...REQUEST, repoIds: ['app', 'gone'] })