From 8b37d052bd1a4eb88fc6fdd704e351f7e65c3702 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 22 Jun 2026 13:30:52 -0700 Subject: [PATCH] Fix canonical worktree identity CI failures Co-authored-by: Orca --- src/main/ipc/worktree-logic.test.ts | 7 +- src/main/ipc/worktrees-windows.test.ts | 25 ++- src/main/runtime/orca-runtime.test.ts | 163 +++++++++++------- src/main/runtime/orca-runtime.ts | 7 +- src/main/runtime/runtime-rpc.test.ts | 55 +++--- src/main/usage-worktree-metadata.test.ts | 7 +- .../worktree-change-refresh-queue.test.ts | 20 +-- .../src/store/slices/worktrees.test.ts | 30 +++- 8 files changed, 214 insertions(+), 100 deletions(-) diff --git a/src/main/ipc/worktree-logic.test.ts b/src/main/ipc/worktree-logic.test.ts index a9da7fcd3a6..7d802e22650 100644 --- a/src/main/ipc/worktree-logic.test.ts +++ b/src/main/ipc/worktree-logic.test.ts @@ -3,6 +3,7 @@ single setup-free pure-logic module, and splitting them would make the related edge cases harder to audit together. */ import { posix, resolve } from 'path' import { describe, expect, it } from 'vitest' +import { makeWorktreeKey } from '../../shared/worktree-id' import { sanitizeWorktreeName, sanitizeWorktreeDisplayName, @@ -372,7 +373,11 @@ describe('mergeWorktree', () => { } const result = mergeWorktree('repo1', baseGit, meta) expect(result).toEqual({ - id: 'repo1::/workspaces/feature', + id: makeWorktreeKey({ + hostId: 'ssh:openclaw-2', + repoId: 'repo1', + path: '/workspaces/feature' + }), repoId: 'repo1', path: '/workspaces/feature', head: 'abc123', diff --git a/src/main/ipc/worktrees-windows.test.ts b/src/main/ipc/worktrees-windows.test.ts index 8a0f5f5ce3d..90f4f564aec 100644 --- a/src/main/ipc/worktrees-windows.test.ts +++ b/src/main/ipc/worktrees-windows.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { makeWorktreeKey } from '../../shared/worktree-id' const { handleMock, @@ -147,7 +148,8 @@ describe('registerWorktreeHandlers – Windows path handling', () => { getSettings: vi.fn(), getWorktreeMeta: vi.fn(), setWorktreeMeta: vi.fn(), - removeWorktreeMeta: vi.fn() + removeWorktreeMeta: vi.fn(), + migrateWorktreeIdentity: vi.fn() } beforeEach(() => { @@ -187,6 +189,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { store.getWorktreeMeta.mockReset() store.setWorktreeMeta.mockReset() store.removeWorktreeMeta.mockReset() + store.migrateWorktreeIdentity.mockReset() for (const key of Object.keys(handlers)) { delete handlers[key] @@ -260,6 +263,11 @@ describe('registerWorktreeHandlers – Windows path handling', () => { }) it('accepts a newly created Windows worktree when git lists the same path with different separators', async () => { + const worktreeId = makeWorktreeKey({ + hostId: 'local', + repoId: 'repo-1', + path: 'C:/workspaces/improve-dashboard' + }) listWorktreesMock.mockResolvedValue([ { path: 'C:/workspaces/improve-dashboard', @@ -283,14 +291,14 @@ describe('registerWorktreeHandlers – Windows path handling', () => { false ) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::C:/workspaces/improve-dashboard', + worktreeId, expect.objectContaining({ lastActivityAt: expect.any(Number) }) ) expect(result).toMatchObject({ worktree: expect.objectContaining({ - id: 'repo-1::C:/workspaces/improve-dashboard', + id: worktreeId, path: 'C:/workspaces/improve-dashboard', branch: 'refs/heads/improve-dashboard' }) @@ -298,6 +306,11 @@ describe('registerWorktreeHandlers – Windows path handling', () => { }) it('preserves create-time metadata on the next list when Windows path formatting differs', async () => { + const worktreeId = makeWorktreeKey({ + hostId: 'local', + repoId: 'repo-1', + path: 'C:/workspaces/improve-dashboard' + }) const worktreeEntry = { path: 'C:/workspaces/improve-dashboard', head: 'abc123', @@ -314,8 +327,8 @@ describe('registerWorktreeHandlers – Windows path handling', () => { linkedIssue: 123, linkedPR: 456 }) - store.getWorktreeMeta.mockImplementation((worktreeId: string) => - worktreeId === 'repo-1::C:/workspaces/improve-dashboard' + store.getWorktreeMeta.mockImplementation((id: string) => + id === worktreeId ? { lastActivityAt: 123, displayName: 'Improve Dashboard', @@ -337,7 +350,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { expect(listed).toMatchObject([ { - id: 'repo-1::C:/workspaces/improve-dashboard', + id: worktreeId, displayName: 'Improve Dashboard', linkedIssue: 123, linkedPR: 456, diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 99dfaabda29..4378d90155a 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -712,12 +712,16 @@ const TEST_WINDOW_ID = 1 const TEST_REPO_ID = 'repo-1' const TEST_REPO_PATH = '/tmp/repo' const TEST_WORKTREE_PATH = '/tmp/worktree-a' -const TEST_WORKTREE_ID = `${TEST_REPO_ID}::${TEST_WORKTREE_PATH}` const TEST_CANONICAL_WORKTREE_ID = makeWorktreeKey({ hostId: 'local', repoId: TEST_REPO_ID, path: TEST_WORKTREE_PATH }) +const TEST_WORKTREE_ID = TEST_CANONICAL_WORKTREE_ID +const makeLocalTestWorktreeId = (path: string): string => + makeWorktreeKey({ hostId: 'local', repoId: TEST_REPO_ID, path }) +const makeSshTestWorktreeId = (repoId: string, connectionId: string, path: string): string => + makeWorktreeKey({ hostId: `ssh:${connectionId}`, repoId, path }) const TEST_FOLDER_PROJECT_GROUP_ID = 'folder-project-group-1' const TEST_FOLDER_WORKSPACE_ID = 'folder-workspace-1' const TEST_FOLDER_WORKSPACE_KEY = `folder:${TEST_FOLDER_WORKSPACE_ID}` @@ -1350,7 +1354,7 @@ describe('OrcaRuntimeService', () => { tabs: [ { tabId: 'tab-1', - worktreeId: 'repo-1::/tmp/worktree-a', + worktreeId: TEST_WORKTREE_ID, title: 'Claude', activeLeafId: 'pane:1', layout: null @@ -1359,7 +1363,7 @@ describe('OrcaRuntimeService', () => { leaves: [ { tabId: 'tab-1', - worktreeId: 'repo-1::/tmp/worktree-a', + worktreeId: TEST_WORKTREE_ID, leafId: 'pane:1', paneRuntimeId: 1, ptyId: 'pty-1' @@ -1371,7 +1375,7 @@ describe('OrcaRuntimeService', () => { const terminals = await runtime.listTerminals('branch:feature/foo') expect(terminals.terminals).toHaveLength(1) expect(terminals.terminals[0]).toMatchObject({ - worktreeId: 'repo-1::/tmp/worktree-a', + worktreeId: TEST_WORKTREE_ID, branch: 'feature/foo', ptyId: 'pty-1', title: 'Claude', @@ -1603,7 +1607,8 @@ describe('OrcaRuntimeService', () => { }) it('matches explicit-id cwd PTYs for folder workspace instance IDs', async () => { - const folderWorktreeId = `${TEST_REPO_ID}::${TEST_FOLDER_WORKSPACE_PATH}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}11111111-1111-4111-8111-111111111111` + const folderWorktreeRootId = makeLocalTestWorktreeId(TEST_FOLDER_WORKSPACE_PATH) + const folderWorktreeId = `${folderWorktreeRootId}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}11111111-1111-4111-8111-111111111111` vi.mocked(listWorktrees).mockClear() vi.mocked(listWorktrees).mockRejectedValue( new Error('folder explicit-id fallback should not rescan worktrees') @@ -1621,7 +1626,9 @@ describe('OrcaRuntimeService', () => { const terminals = await runtime.listTerminals(`id:${folderWorktreeId}`) expect(listWorktrees).not.toHaveBeenCalled() - expect(terminals.terminals.map((terminal) => terminal.worktreeId)).toEqual([folderWorktreeId]) + expect(terminals.terminals.map((terminal) => terminal.worktreeId)).toEqual([ + folderWorktreeRootId + ]) expect(terminals.terminals[0]?.worktreePath).toBe(TEST_FOLDER_WORKSPACE_PATH) }) @@ -1916,8 +1923,18 @@ describe('OrcaRuntimeService', () => { addedAt: 1, kind: 'folder' as const } - const rootWorktreeId = 'folder-repo::/workspace/folder' - const rootPriorWorktreeIds = ['folder-repo::/workspace/old-folder'] + const rootWorktreeId = makeWorktreeKey({ + hostId: 'local', + repoId: 'folder-repo', + path: '/workspace/folder' + }) + const rootPriorWorktreeIds = [ + makeWorktreeKey({ + hostId: 'local', + repoId: 'folder-repo', + path: '/workspace/old-folder' + }) + ] const metaById: Record = { [rootWorktreeId]: makeWorktreeMeta({ instanceId: 'root-instance', @@ -1958,7 +1975,9 @@ describe('OrcaRuntimeService', () => { expect(addWorktreeMock).not.toHaveBeenCalled() expect(result.worktree).toEqual( expect.objectContaining({ - id: expect.stringMatching(/^folder-repo::\/workspace\/folder::workspace:[0-9a-f-]{36}$/), + id: expect.stringMatching( + /^orca-worktree:\/\/v1\?hostId=local&repoId=folder-repo&path=%2Fworkspace%2Ffolder::workspace:[0-9a-f-]{36}$/ + ), repoId: 'folder-repo', path: '/workspace/folder', displayName: 'folder-session', @@ -1998,9 +2017,9 @@ describe('OrcaRuntimeService', () => { id: result.worktree.id, comment: 'note' }) - await expect( - runtime.removeManagedWorktree('id:folder-repo::/workspace/folder') - ).rejects.toThrow('Cannot delete the project root workspace') + await expect(runtime.removeManagedWorktree(`id:${rootWorktreeId}`)).rejects.toThrow( + 'Cannot delete the project root workspace' + ) deletedWorktreeId = result.worktree.id await expect(runtime.removeManagedWorktree(`id:${result.worktree.id}`)).resolves.toEqual({}) expect(localProvider.shutdown).toHaveBeenCalledWith(`${result.worktree.id}@@pty-1`, { @@ -2949,7 +2968,7 @@ describe('OrcaRuntimeService', () => { isBare: false, isMainWorktree: false } - const childId = `${TEST_REPO_ID}::${created.path}` + const childId = makeLocalTestWorktreeId(created.path) const metaById: Record = {} const workspaceLineageByChildKey: Record = {} const runtimeStore = { @@ -5037,11 +5056,11 @@ describe('OrcaRuntimeService', () => { expect(terminals.terminals).toEqual( expect.arrayContaining([ expect.objectContaining({ - worktreeId: `${TEST_REPO_ID}::C:\\Repo`, + worktreeId: makeLocalTestWorktreeId('C:\\Repo'), worktreePath: 'C:\\Repo' }), expect.objectContaining({ - worktreeId: `${TEST_REPO_ID}:://Server/Share/Repo`, + worktreeId: makeLocalTestWorktreeId('//Server/Share/Repo'), worktreePath: '//Server/Share/Repo' }) ]) @@ -11774,6 +11793,7 @@ describe('OrcaRuntimeService', () => { }) it('spawns fresh headless SSH mobile session terminals instead of reattaching synthetic local ids', async () => { + const sshWorktreeId = makeSshTestWorktreeId(TEST_REPO_ID, 'ssh-1', TEST_WORKTREE_PATH) const remoteRepo = { ...store.getRepo(TEST_REPO_ID)!, connectionId: 'ssh-1' } const remoteStore = { ...store, @@ -11790,12 +11810,12 @@ describe('OrcaRuntimeService', () => { }) runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) - await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`) + await runtime.createMobileSessionTerminal(`id:${sshWorktreeId}`) expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ connectionId: 'ssh-1', - worktreeId: TEST_WORKTREE_ID, + worktreeId: sshWorktreeId, persistHostSessionBinding: true }) ) @@ -13192,14 +13212,15 @@ describe('OrcaRuntimeService', () => { }) it('reattaches hydrated SSH headless terminals with the persisted relay identity', async () => { + const sshWorktreeId = makeSshTestWorktreeId(TEST_REPO_ID, 'ssh-1', TEST_WORKTREE_PATH) const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( makeWorkspaceSessionWithHeadlessTerminal({ tabsByWorktree: { - [TEST_WORKTREE_ID]: [ + [sshWorktreeId]: [ { id: 'host-tab', ptyId: 'ssh:ssh-1@@relay-pty', - worktreeId: TEST_WORKTREE_ID, + worktreeId: sshWorktreeId, title: 'Remote Terminal', customTitle: null, color: null, @@ -13232,7 +13253,7 @@ describe('OrcaRuntimeService', () => { }) runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) - await runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab') + await runtime.activateMobileSessionTab(`id:${sshWorktreeId}`, 'host-tab') expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ @@ -13246,15 +13267,16 @@ describe('OrcaRuntimeService', () => { }) it('spawns fresh after an expired hydrated SSH headless reattach clears persistence', async () => { + const sshWorktreeId = makeSshTestWorktreeId(TEST_REPO_ID, 'ssh-1', TEST_WORKTREE_PATH) const stalePtyId = 'ssh:ssh-1@@relay-pty' const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( makeWorkspaceSessionWithHeadlessTerminal({ tabsByWorktree: { - [TEST_WORKTREE_ID]: [ + [sshWorktreeId]: [ { id: 'host-tab', ptyId: stalePtyId, - worktreeId: TEST_WORKTREE_ID, + worktreeId: sshWorktreeId, title: 'Remote Terminal', customTitle: null, color: null, @@ -13282,7 +13304,7 @@ describe('OrcaRuntimeService', () => { ...session, tabsByWorktree: { ...session.tabsByWorktree, - [TEST_WORKTREE_ID]: session.tabsByWorktree[TEST_WORKTREE_ID].map((tab) => + [sshWorktreeId]: session.tabsByWorktree[sshWorktreeId].map((tab) => tab.id === 'host-tab' ? { ...tab, ptyId: null } : tab ) }, @@ -13308,9 +13330,9 @@ describe('OrcaRuntimeService', () => { runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) await expect( - runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab') + runtime.activateMobileSessionTab(`id:${sshWorktreeId}`, 'host-tab') ).rejects.toThrow('SSH session expired') - await runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab') + await runtime.activateMobileSessionTab(`id:${sshWorktreeId}`, 'host-tab') expect(spawn).toHaveBeenNthCalledWith( 1, @@ -13476,14 +13498,15 @@ describe('OrcaRuntimeService', () => { }) it('spawns fresh SSH terminals when hydrated persistence has no relay identity', async () => { + const sshWorktreeId = makeSshTestWorktreeId(TEST_REPO_ID, 'ssh-1', TEST_WORKTREE_PATH) const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( makeWorkspaceSessionWithHeadlessTerminal({ tabsByWorktree: { - [TEST_WORKTREE_ID]: [ + [sshWorktreeId]: [ { id: 'host-tab', ptyId: null, - worktreeId: TEST_WORKTREE_ID, + worktreeId: sshWorktreeId, title: 'Remote Terminal', customTitle: null, color: null, @@ -13514,7 +13537,7 @@ describe('OrcaRuntimeService', () => { }) runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) - await runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab') + await runtime.activateMobileSessionTab(`id:${sshWorktreeId}`, 'host-tab') expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ @@ -15871,8 +15894,16 @@ describe('OrcaRuntimeService', () => { addedAt: 1, connectionId: 'ssh-1' } - const childId = `${remoteRepo.id}::/home/user/repo-child` - const parentId = `${remoteRepo.id}::/home/user/repo-parent` + const childId = makeSshTestWorktreeId( + remoteRepo.id, + remoteRepo.connectionId, + '/home/user/repo-child' + ) + const parentId = makeSshTestWorktreeId( + remoteRepo.id, + remoteRepo.connectionId, + '/home/user/repo-parent' + ) const canonicalChildId = makeWorktreeKey({ hostId: 'ssh:ssh-1', repoId: remoteRepo.id, @@ -15946,8 +15977,16 @@ describe('OrcaRuntimeService', () => { addedAt: 1, connectionId: 'ssh-1' } - const childId = `${remoteRepo.id}::/home/user/repo-child` - const parentId = `${remoteRepo.id}::/home/user/repo-parent` + const childId = makeSshTestWorktreeId( + remoteRepo.id, + remoteRepo.connectionId, + '/home/user/repo-child' + ) + const parentId = makeSshTestWorktreeId( + remoteRepo.id, + remoteRepo.connectionId, + '/home/user/repo-parent' + ) const metaById: Record = { [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }), [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }) @@ -16185,8 +16224,8 @@ describe('OrcaRuntimeService', () => { it('keeps workspace lineage in sync when manually reparenting a worktree', async () => { const parentPath = '/tmp/worktree-parent' const childPath = '/tmp/worktree-child' - const parentId = `${TEST_REPO_ID}::${parentPath}` - const childId = `${TEST_REPO_ID}::${childPath}` + const parentId = makeLocalTestWorktreeId(parentPath) + const childId = makeLocalTestWorktreeId(childPath) const canonicalParentId = makeWorktreeKey({ hostId: 'local', repoId: TEST_REPO_ID, @@ -16258,7 +16297,7 @@ describe('OrcaRuntimeService', () => { it('clears workspace lineage when manually removing a parent', async () => { const childPath = '/tmp/worktree-child' - const childId = `${TEST_REPO_ID}::${childPath}` + const childId = makeLocalTestWorktreeId(childPath) const metaById: Record = { [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }) } @@ -16323,8 +16362,8 @@ describe('OrcaRuntimeService', () => { it('ignores stale instance-mismatched lineage when validating manual cycle repairs', async () => { const parentPath = '/tmp/worktree-a' const childPath = '/tmp/worktree-b' - const parentId = `${TEST_REPO_ID}::${parentPath}` - const childId = `${TEST_REPO_ID}::${childPath}` + const parentId = makeLocalTestWorktreeId(parentPath) + const childId = makeLocalTestWorktreeId(childPath) const metaById: Record = { [parentId]: makeWorktreeMeta({ instanceId: 'new-parent-instance' }), [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }) @@ -16388,8 +16427,8 @@ describe('OrcaRuntimeService', () => { it('rejects lineage updates when upgraded metadata is missing a parent instance id', async () => { const parentPath = '/tmp/worktree-parent' const childPath = '/tmp/worktree-child' - const parentId = `${TEST_REPO_ID}::${parentPath}` - const childId = `${TEST_REPO_ID}::${childPath}` + const parentId = makeLocalTestWorktreeId(parentPath) + const childId = makeLocalTestWorktreeId(childPath) const metaById: Record = { [parentId]: makeWorktreeMeta(), [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }) @@ -16435,8 +16474,8 @@ describe('OrcaRuntimeService', () => { it('rotates a missing parent instance during runtime selector scans before same-path reuse', async () => { const parentPath = '/tmp/worktree-parent' const childPath = '/tmp/worktree-child' - const parentId = `${TEST_REPO_ID}::${parentPath}` - const childId = `${TEST_REPO_ID}::${childPath}` + const parentId = makeLocalTestWorktreeId(parentPath) + const childId = makeLocalTestWorktreeId(childPath) const metaById: Record = { [parentId]: makeWorktreeMeta({ instanceId: 'old-parent-instance' }), [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }) @@ -16520,8 +16559,8 @@ describe('OrcaRuntimeService', () => { it('does not prune lineage when a runtime local worktree scan fails', async () => { const parentPath = '/tmp/worktree-parent' const childPath = '/tmp/worktree-child' - const parentId = `${TEST_REPO_ID}::${parentPath}` - const childId = `${TEST_REPO_ID}::${childPath}` + const parentId = makeLocalTestWorktreeId(parentPath) + const childId = makeLocalTestWorktreeId(childPath) const metaById: Record = { [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }), [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }) @@ -16591,8 +16630,16 @@ describe('OrcaRuntimeService', () => { addedAt: 1, connectionId: 'ssh-1' } - const parentId = `${remoteRepo.id}::/home/user/repo-parent` - const childId = `${remoteRepo.id}::/home/user/repo-child` + const parentId = makeSshTestWorktreeId( + remoteRepo.id, + remoteRepo.connectionId, + '/home/user/repo-parent' + ) + const childId = makeSshTestWorktreeId( + remoteRepo.id, + remoteRepo.connectionId, + '/home/user/repo-child' + ) const metaById: Record = { [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }), [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }) @@ -16735,8 +16782,8 @@ describe('OrcaRuntimeService', () => { it('keeps valid orchestration lineage when caller terminal context is stale', async () => { const parentPath = '/tmp/worktree-parent' const childPath = '/tmp/workspaces/worker-child' - const parentId = `${TEST_REPO_ID}::${parentPath}` - const childId = `${TEST_REPO_ID}::${childPath}` + const parentId = makeLocalTestWorktreeId(parentPath) + const childId = makeLocalTestWorktreeId(childPath) const metaById: Record = { [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance', @@ -16813,8 +16860,8 @@ describe('OrcaRuntimeService', () => { it('enriches caller-terminal lineage with active orchestration dispatch context', async () => { const workerPath = '/tmp/worktree-worker' const childPath = '/tmp/workspaces/worker-child' - const childId = `${TEST_REPO_ID}::${childPath}` - const workerId = `${TEST_REPO_ID}::${workerPath}` + const childId = makeLocalTestWorktreeId(childPath) + const workerId = makeLocalTestWorktreeId(workerPath) const metaById: Record = { [TEST_WORKTREE_ID]: makeWorktreeMeta({ instanceId: 'parent-instance', @@ -17205,8 +17252,8 @@ describe('OrcaRuntimeService', () => { it('falls back to cwd lineage when the caller terminal handle is stale', async () => { const parentPath = '/tmp/worktree-parent' const childPath = '/tmp/workspaces/cwd-child' - const parentId = `${TEST_REPO_ID}::${parentPath}` - const childId = `${TEST_REPO_ID}::${childPath}` + const parentId = makeLocalTestWorktreeId(parentPath) + const childId = makeLocalTestWorktreeId(childPath) const metaById: Record = { [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }) } @@ -17312,8 +17359,8 @@ describe('OrcaRuntimeService', () => { it('infers orchestration lineage from task-id comments when dispatch is completed', async () => { const workerPath = '/tmp/worktree-worker' const childPath = '/tmp/workspaces/worker-child' - const childId = `${TEST_REPO_ID}::${childPath}` - const workerId = `${TEST_REPO_ID}::${workerPath}` + const childId = makeLocalTestWorktreeId(childPath) + const workerId = makeLocalTestWorktreeId(workerPath) const metaById: Record = { [workerId]: makeWorktreeMeta({ instanceId: 'worker-instance', @@ -17410,8 +17457,8 @@ describe('OrcaRuntimeService', () => { it('infers orchestration lineage from task creator when no dispatch context exists', async () => { const parentPath = '/tmp/worktree-parent' const childPath = '/tmp/workspaces/parent-child' - const childId = `${TEST_REPO_ID}::${childPath}` - const parentId = `${TEST_REPO_ID}::${parentPath}` + const childId = makeLocalTestWorktreeId(childPath) + const parentId = makeLocalTestWorktreeId(parentPath) const metaById: Record = { [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance', @@ -19991,7 +20038,7 @@ describe('OrcaRuntimeService', () => { try { const worktree = await runtime.showManagedWorktree(`path:${duplicatePath}`) - expect(worktree.id).toBe(`${TEST_REPO_ID}::${duplicatePath}`) + expect(worktree.id).toBe(makeLocalTestWorktreeId(duplicatePath)) expect(worktree.path).toBe(duplicatePath) } finally { getRepos.mockRestore() @@ -20690,7 +20737,7 @@ describe('OrcaRuntimeService', () => { addedAt: 1, connectionId: 'ssh-missing-fs' } - const worktreeId = `${repo.id}::${localPath}` + const worktreeId = makeSshTestWorktreeId(repo.id, repo.connectionId, localPath) const metaById: Record = { [worktreeId]: makeWorktreeMeta({ orcaCreatedAt: Date.now(), @@ -21270,7 +21317,7 @@ describe('OrcaRuntimeService', () => { expect(listed.worktrees).toMatchObject([ { - id: 'repo-1::C:/workspaces/improve-dashboard', + id: makeLocalTestWorktreeId('C:/workspaces/improve-dashboard'), displayName: 'Improve Dashboard' } ]) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index d4fc8fecc12..f16f08ba6ec 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -16685,10 +16685,13 @@ export class OrcaRuntimeService { store.removeWorktreeLineage(childId) store.removeWorkspaceLineage?.(worktreeWorkspaceKey(childId)) } - if ( + const parentMissingFromLegacyScan = lineage.parentWorktreeId.startsWith(repoPrefix) && !legacyLiveIds.has(lineage.parentWorktreeId) - ) { + const parentMissingFromCanonicalScan = + isRuntimeCanonicalWorktreeIdForRepoHost(repo, lineage.parentWorktreeId) && + !liveIds.has(lineage.parentWorktreeId) + if (parentMissingFromLegacyScan || parentMissingFromCanonicalScan) { const parentMeta = store.getWorktreeMeta(lineage.parentWorktreeId) if (!parentMeta || parentMeta.instanceId === lineage.parentWorktreeInstanceId) { // Why: preserving child lineage powers the repair UI, but a missing diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 964afa701e6..36d782bbab9 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -15,6 +15,7 @@ import { createRuntimeTransportMetadata, OrcaRuntimeRpcServer } from './runtime- import { parsePairingCode } from '../../shared/pairing' import { decrypt, deriveSharedKey, encrypt, generateKeyPair } from './rpc/e2ee-crypto' import { DeviceRegistry } from './device-registry' +import { makeWorktreeKey } from '../../shared/worktree-id' vi.mock('../git/worktree', () => ({ listWorktrees: vi.fn().mockResolvedValue([ @@ -259,6 +260,25 @@ class FakeWebSocket extends EventEmitter { } describe('OrcaRuntimeRpcServer', () => { + const LEGACY_WORKTREE_A_ID = 'repo-1::/tmp/worktree-a' + const WORKTREE_A_ID = makeWorktreeKey({ + hostId: 'local', + repoId: 'repo-1', + path: '/tmp/worktree-a' + }) + const makeWorktreeMeta = (overrides?: { isUnread?: boolean }) => ({ + displayName: 'foo', + comment: '', + linkedIssue: 123, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: overrides?.isUnread ?? false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 + }) + const makeStore = (overrides?: { isUnread?: boolean }) => ({ getRepo: (id: string) => makeStore(overrides) @@ -279,27 +299,20 @@ describe('OrcaRuntimeRpcServer', () => { ...makeStore(overrides).getRepo(id), ...updates }) as never, - getAllWorktreeMeta: () => ({ - 'repo-1::/tmp/worktree-a': { - displayName: 'foo', - comment: '', - linkedIssue: 123, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: overrides?.isUnread ?? false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 0 + getAllWorktreeMeta: () => { + const meta = makeWorktreeMeta(overrides) + return { + [LEGACY_WORKTREE_A_ID]: meta, + [WORKTREE_A_ID]: meta } - }), + }, getWorktreeMeta: (worktreeId: string) => - worktreeId === 'repo-1::/tmp/worktree-a' + worktreeId === LEGACY_WORKTREE_A_ID || worktreeId === WORKTREE_A_ID ? (makeStore(overrides).getAllWorktreeMeta()[worktreeId] as never) : undefined, setWorktreeMeta: (_worktreeId: string, meta: Record) => ({ - ...makeStore(overrides).getAllWorktreeMeta()['repo-1::/tmp/worktree-a'], + ...makeStore(overrides).getAllWorktreeMeta()[WORKTREE_A_ID], ...meta }) as never, removeWorktreeMeta: () => {}, @@ -2240,7 +2253,7 @@ describe('OrcaRuntimeRpcServer', () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const runtime = new OrcaRuntimeService(makeStore() as never) const server = new OrcaRuntimeRpcServer({ runtime, userDataPath }) - const worktreeId = 'repo-1::/tmp/worktree-a' + const worktreeId = WORKTREE_A_ID const leftLeaf = '11111111-1111-4111-8111-111111111111' const topLeaf = '22222222-2222-4222-8222-222222222222' const bottomLeaf = '33333333-3333-4333-8333-333333333333' @@ -2507,7 +2520,7 @@ describe('OrcaRuntimeRpcServer', () => { ok: true, result: { terminal: { - worktreeId: 'repo-1::/tmp/worktree-a', + worktreeId: WORKTREE_A_ID, surface: 'background' } } @@ -2520,7 +2533,7 @@ describe('OrcaRuntimeRpcServer', () => { authToken, method: 'session.tabs.list', params: { - worktree: 'id:repo-1::/tmp/worktree-a' + worktree: `id:${WORKTREE_A_ID}` } }) @@ -2609,7 +2622,7 @@ describe('OrcaRuntimeRpcServer', () => { const metadata = readRuntimeMetadata(userDataPath) const laptopEndpoint = metadata!.transports[0]!.endpoint const laptopAuthToken = metadata!.authToken - const worktree = 'id:repo-1::/tmp/worktree-a' + const worktree = `id:${WORKTREE_A_ID}` const leafId = '11111111-1111-4111-8111-111111111111' try { @@ -2757,7 +2770,7 @@ describe('OrcaRuntimeRpcServer', () => { result: { worktrees: [ { - worktreeId: 'repo-1::/tmp/worktree-a', + worktreeId: WORKTREE_A_ID, repoId: 'repo-1', repo: 'repo', path: '/tmp/worktree-a', @@ -2903,7 +2916,7 @@ describe('OrcaRuntimeRpcServer', () => { tabs: [ { tabId: 'tab-1', - worktreeId: 'repo-1::/tmp/worktree-a', + worktreeId: WORKTREE_A_ID, title: 'Terminal 1', activeLeafId: 'pane:1', layout: null diff --git a/src/main/usage-worktree-metadata.test.ts b/src/main/usage-worktree-metadata.test.ts index 0a6221122b5..f182dbbfe14 100644 --- a/src/main/usage-worktree-metadata.test.ts +++ b/src/main/usage-worktree-metadata.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { makeWorktreeKey } from '../shared/worktree-id' import { loadKnownUsageWorktreesByRepo } from './usage-worktree-metadata' describe('loadKnownUsageWorktreesByRepo', () => { @@ -36,7 +37,11 @@ describe('loadKnownUsageWorktreesByRepo', () => { 'repo-1', [ { - worktreeId: 'repo-1::/workspace/repo-a', + worktreeId: makeWorktreeKey({ + hostId: 'local', + repoId: 'repo-1', + path: '/workspace/repo-a' + }), path: '/workspace/repo-a', displayName: 'Repo A' }, diff --git a/src/renderer/src/hooks/worktree-change-refresh-queue.test.ts b/src/renderer/src/hooks/worktree-change-refresh-queue.test.ts index be61b8dc3b0..1be74a5fb6d 100644 --- a/src/renderer/src/hooks/worktree-change-refresh-queue.test.ts +++ b/src/renderer/src/hooks/worktree-change-refresh-queue.test.ts @@ -28,13 +28,13 @@ describe('createWorktreeChangeRefreshQueue', () => { queue.enqueue({ repoId: 'repo-1' }) expect(handler).toHaveBeenCalledTimes(1) - expect(handler).toHaveBeenCalledWith('repo-1', undefined) + expect(handler).toHaveBeenCalledWith('repo-1', undefined, undefined) firstRefresh.resolve() await flushPromises() expect(handler).toHaveBeenCalledTimes(2) - expect(handler).toHaveBeenNthCalledWith(2, 'repo-1', undefined) + expect(handler).toHaveBeenNthCalledWith(2, 'repo-1', undefined, undefined) }) it('does not overlap refreshes for the same repo', async () => { @@ -73,8 +73,8 @@ describe('createWorktreeChangeRefreshQueue', () => { queue.enqueue({ repoId: 'repo-2' }) expect(handler).toHaveBeenCalledTimes(2) - expect(handler).toHaveBeenNthCalledWith(1, 'repo-1', undefined) - expect(handler).toHaveBeenNthCalledWith(2, 'repo-2', undefined) + expect(handler).toHaveBeenNthCalledWith(1, 'repo-1', undefined, undefined) + expect(handler).toHaveBeenNthCalledWith(2, 'repo-2', undefined, undefined) repoOneRefresh.resolve() await flushPromises() @@ -100,8 +100,8 @@ describe('createWorktreeChangeRefreshQueue', () => { await flushPromises() expect(handler).toHaveBeenCalledTimes(3) - expect(handler).toHaveBeenNthCalledWith(2, 'repo-1', renamed) - expect(handler).toHaveBeenNthCalledWith(3, 'repo-1', undefined) + expect(handler).toHaveBeenNthCalledWith(2, 'repo-1', undefined, renamed) + expect(handler).toHaveBeenNthCalledWith(3, 'repo-1', undefined, undefined) } finally { consoleError.mockRestore() } @@ -116,8 +116,8 @@ describe('createWorktreeChangeRefreshQueue', () => { queue.enqueue({ repoId: 'repo-1', renamed }) await flushPromises() - expect(handler).toHaveBeenNthCalledWith(1, 'repo-1', undefined) - expect(handler).toHaveBeenNthCalledWith(2, 'repo-1', renamed) + expect(handler).toHaveBeenNthCalledWith(1, 'repo-1', undefined, undefined) + expect(handler).toHaveBeenNthCalledWith(2, 'repo-1', undefined, renamed) }) it('keeps a plain refresh queued after a rename', async () => { @@ -136,8 +136,8 @@ describe('createWorktreeChangeRefreshQueue', () => { await flushPromises() expect(handler).toHaveBeenCalledTimes(3) - expect(handler).toHaveBeenNthCalledWith(2, 'repo-1', renamed) - expect(handler).toHaveBeenNthCalledWith(3, 'repo-1', undefined) + expect(handler).toHaveBeenNthCalledWith(2, 'repo-1', undefined, renamed) + expect(handler).toHaveBeenNthCalledWith(3, 'repo-1', undefined, undefined) }) it('drops queued trailing refreshes after disposal', async () => { diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 129c26db9f0..89b8bb10850 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -5036,7 +5036,35 @@ describe('migrateWorktreeIdentity', () => { return [{ id: 'group1', worktreeId }] case 'recentlyClosedEditorTabsByWorktree': return [{ id: 'file1', worktreeId }] - default: + case 'deleteStateByWorktreeId': + case 'baseStatusByWorktreeId': + case 'remoteBranchConflictByWorktreeId': + case 'fileSearchStateByWorktree': + case 'activeBrowserTabIdByWorktree': + case 'activeFileIdByWorktree': + case 'activeTabTypeByWorktree': + case 'activeTabIdByWorktree': + case 'tabBarOrderByWorktree': + case 'pendingReconnectTabByWorktree': + case 'rightSidebarTabByWorktree': + case 'rightSidebarExplorerViewByWorktree': + case 'layoutByWorktree': + case 'activeGroupIdByWorktree': + case 'gitStatusByWorktree': + case 'gitStatusHeadByWorktree': + case 'gitStatusHugeByWorktree': + case 'gitIgnoredPathsByWorktree': + case 'gitConflictOperationByWorktree': + case 'trackedConflictPathsByWorktree': + case 'gitBranchChangesByWorktree': + case 'gitBranchCompareSummaryByWorktree': + case 'gitBranchCompareRequestKeyByWorktree': + case 'gitBranchCompareRequestStatusHeadByWorktree': + case 'remoteStatusesByWorktree': + case 'showDotfilesByWorktree': + case 'expandedDirs': + case 'lastVisitedAtByWorktreeId': + case 'defaultTerminalTabsAppliedByWorktreeId': return { marker: key } } }