fix(wsl-mover): never leave behind an agent whose key may be the selected project

Review of #32343: scoping the session check to the selected projects let an unqualified key for a
repo id another host also registers fall out of both the transfer and the refusal. Such a key now
refuses. Adds tests that fail on the old code: another project's live agent with its tab open and
its draft don't refuse, the same kinds of state in the selected project still do.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-23 03:58:08 -07:00
co-authored by Claude
parent 18ca408e4d
commit 5c3801bb3d
3 changed files with 99 additions and 1 deletions
@@ -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
}
@@ -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) {
@@ -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'] })