Restore agent history after worktree renames (#6000)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-21 15:48:33 -07:00 committed by GitHub
parent f524fdf8bb
commit d517bb7250
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 301 additions and 22 deletions

View File

@ -355,6 +355,7 @@ describe('mergeWorktree', () => {
lastActivityAt: 1000,
workspaceStatus: 'in-review',
diffComments: [],
priorWorktreeIds: ['repo1::/workspaces/old-feature'],
automationProvenance: {
kind: 'created-by-automation' as const,
automationId: 'automation-1',
@ -401,6 +402,7 @@ describe('mergeWorktree', () => {
lastActivityAt: 1000,
workspaceStatus: 'in-review',
diffComments: [],
priorWorktreeIds: ['repo1::/workspaces/old-feature'],
automationProvenance: {
kind: 'created-by-automation',
automationId: 'automation-1',

View File

@ -62,6 +62,7 @@ export function mergeWorktree(
: {}),
...(meta?.baseRef !== undefined ? { baseRef: meta.baseRef } : {}),
...(meta?.pushTarget !== undefined ? { pushTarget: meta.pushTarget } : {}),
...(meta?.priorWorktreeIds !== undefined ? { priorWorktreeIds: meta.priorWorktreeIds } : {}),
workspaceStatus: meta?.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID,
// Why: diff comments are persisted on WorktreeMeta and forwarded verbatim
// so the renderer store mirrors on-disk state.

View File

@ -3795,6 +3795,15 @@ describe('registerWorktreeHandlers', () => {
})
it('lists a synthetic worktree for folder-mode repos', async () => {
const rootWorktreeId = 'repo-1::/workspace/folder'
const priorWorktreeIds = ['repo-1::/workspace/old-folder']
const rootMeta = makeWorktreeMeta({
instanceId: 'folder-instance',
projectId: 'repo:repo-1',
hostId: 'local',
projectHostSetupId: 'repo-1',
priorWorktreeIds
})
store.getRepos.mockReturnValue([
{
id: 'repo-1',
@ -3813,18 +3822,25 @@ describe('registerWorktreeHandlers', () => {
addedAt: 0,
kind: 'folder'
})
store.getAllWorktreeMeta.mockReturnValue({
[rootWorktreeId]: rootMeta
})
store.getWorktreeMeta.mockImplementation((worktreeId: string) =>
worktreeId === rootWorktreeId ? rootMeta : undefined
)
const listed = await handlers['worktrees:list'](null, { repoId: 'repo-1' })
expect(listed).toEqual([
expect.objectContaining({
id: 'repo-1::/workspace/folder',
id: rootWorktreeId,
repoId: 'repo-1',
path: '/workspace/folder',
displayName: 'folder',
branch: '',
head: '',
isMainWorktree: true
isMainWorktree: true,
priorWorktreeIds
})
])
expect(listWorktreesMock).not.toHaveBeenCalled()

View File

@ -640,6 +640,7 @@ function mergeFolderWorkspace(repo: Repo, worktreeId: string, meta: WorktreeMeta
...(meta.automationProvenance !== undefined
? { automationProvenance: meta.automationProvenance }
: {}),
...(meta.priorWorktreeIds !== undefined ? { priorWorktreeIds: meta.priorWorktreeIds } : {}),
workspaceStatus: meta.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID,
diffComments: meta.diffComments,
mobileDiffReview: meta.mobileDiffReview

View File

@ -1872,7 +1872,14 @@ describe('OrcaRuntimeService', () => {
addedAt: 1,
kind: 'folder' as const
}
const metaById: Record<string, WorktreeMeta> = {}
const rootWorktreeId = 'folder-repo::/workspace/folder'
const rootPriorWorktreeIds = ['folder-repo::/workspace/old-folder']
const metaById: Record<string, WorktreeMeta> = {
[rootWorktreeId]: makeWorktreeMeta({
instanceId: 'root-instance',
priorWorktreeIds: rootPriorWorktreeIds
})
}
const runtimeStore = {
...store,
getRepos: () => [folderRepo],
@ -1931,8 +1938,9 @@ describe('OrcaRuntimeService', () => {
totalCount: 2,
worktrees: [
expect.objectContaining({
id: 'folder-repo::/workspace/folder',
isMainWorktree: true
id: rootWorktreeId,
isMainWorktree: true,
priorWorktreeIds: rootPriorWorktreeIds
}),
expect.objectContaining({
id: result.worktree.id,

View File

@ -1226,6 +1226,7 @@ function mergeRuntimeFolderWorkspace(repo: Repo, worktreeId: string, meta: Workt
...(meta.automationProvenance !== undefined
? { automationProvenance: meta.automationProvenance }
: {}),
...(meta.priorWorktreeIds !== undefined ? { priorWorktreeIds: meta.priorWorktreeIds } : {}),
workspaceStatus: meta.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID,
diffComments: meta.diffComments,
mobileDiffReview: meta.mobileDiffReview

View File

@ -4,7 +4,12 @@ import { buildAiVaultResumeCommandForWorktree } from '@/lib/ai-vault-resume-comm
import { launchAiVaultSessionInNewTab } from '@/lib/launch-ai-vault-session'
import { useAppStore } from '@/store'
import { useActiveWorktree, useRepoById } from '@/store/selectors'
import { agentLabel, filterAiVaultSessions, groupAiVaultSessions } from './ai-vault-session-filters'
import {
agentLabel,
deriveAiVaultWorkspaceScopePaths,
filterAiVaultSessions,
groupAiVaultSessions
} from './ai-vault-session-filters'
import {
AI_VAULT_AGENTS,
type AiVaultAgent,
@ -25,6 +30,7 @@ export default function AiVaultPanel(): React.JSX.Element {
const activeWorktree = useActiveWorktree()
const activeRepo = useRepoById(activeWorktree?.repoId ?? null)
const agentCmdOverrides = useAppStore((s) => s.settings?.agentCmdOverrides ?? {})
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const [query, setQuery] = useState('')
const [scope, setScope] = useState<AiVaultScope>('workspace')
const [sort, setSort] = useState<AiVaultSort>('updated')
@ -42,6 +48,12 @@ export default function AiVaultPanel(): React.JSX.Element {
const isRemoteWorktree = Boolean(activeRepo?.connectionId)
const activeWorktreePath = activeWorktree?.path ?? null
// Why: AI Vault ownership is cwd-based, so we must consider live worktrees across all repos.
const liveWorktrees = useMemo(() => Object.values(worktreesByRepo).flat(), [worktreesByRepo])
const activeWorktreePaths = useMemo(
() => deriveAiVaultWorkspaceScopePaths(activeWorktree ?? null, liveWorktrees),
[activeWorktree, liveWorktrees]
)
const hasAllAgentsSelected = agents.length === AI_VAULT_AGENTS.length
const viewAdjustmentCount =
(hasAllAgentsSelected ? 0 : 1) +
@ -107,10 +119,10 @@ export default function AiVaultPanel(): React.JSX.Element {
agents,
scope,
sort,
activeWorktreePath,
activeWorktreePaths,
hideEmptySessions
}),
[activeWorktreePath, agents, hideEmptySessions, query, scope, sessions, sort]
[activeWorktreePaths, agents, hideEmptySessions, query, scope, sessions, sort]
)
const groups = useMemo(

View File

@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import {
AI_VAULT_SESSION_FILTER_QUERY_MAX_BYTES,
deriveAiVaultWorkspaceScopePaths,
filterAiVaultSessions,
folderLabel,
groupAiVaultSessions,
@ -50,12 +51,54 @@ describe('filterAiVaultSessions', () => {
agents: ['claude'],
scope: 'workspace',
sort: 'updated',
activeWorktreePath: '/Users/ada/repo',
activeWorktreePaths: ['/Users/ada/repo'],
hideEmptySessions: true
}).map((session) => session.id)
).toEqual(['claude:1'])
})
it('matches workspace sessions under prior renamed worktree paths', () => {
const sessions: AiVaultSession[] = [
{
...baseSession,
id: 'claude:old-path',
cwd: '/Users/ada/workspaces/orca/bream/src'
},
{
...baseSession,
id: 'claude:other-path',
cwd: '/Users/ada/workspaces/other/bream'
}
]
expect(
filterAiVaultSessions(sessions, {
query: '',
agents: ['claude'],
scope: 'workspace',
sort: 'updated',
activeWorktreePaths: [
'/Users/ada/workspaces/orca/fix-agent-history',
'/Users/ada/workspaces/orca/bream'
],
hideEmptySessions: true
}).map((session) => session.id)
).toEqual(['claude:old-path'])
})
it('returns no workspace matches when active workspace paths are empty', () => {
expect(
filterAiVaultSessions([baseSession], {
query: '',
agents: ['claude'],
scope: 'workspace',
sort: 'updated',
activeWorktreePaths: [],
hideEmptySessions: true
})
).toEqual([])
})
it('hides empty metadata-only sessions when requested', () => {
const emptySession: AiVaultSession = {
...baseSession,
@ -71,7 +114,7 @@ describe('filterAiVaultSessions', () => {
agents: ['claude'],
scope: 'all',
sort: 'updated',
activeWorktreePath: null,
activeWorktreePaths: [],
hideEmptySessions: true
}).map((session) => session.id)
).toEqual(['claude:1'])
@ -81,7 +124,7 @@ describe('filterAiVaultSessions', () => {
agents: ['claude'],
scope: 'all',
sort: 'updated',
activeWorktreePath: null,
activeWorktreePaths: [],
hideEmptySessions: false
}).map((session) => session.id)
@ -108,7 +151,7 @@ describe('filterAiVaultSessions', () => {
agents: ['claude'],
scope: 'all',
sort: 'updated',
activeWorktreePath: null,
activeWorktreePaths: [],
hideEmptySessions: true
}
).map((session) => session.id)
@ -140,7 +183,7 @@ describe('filterAiVaultSessions', () => {
agents: ['claude'],
scope: 'all',
sort: 'updated',
activeWorktreePath: null,
activeWorktreePaths: [],
hideEmptySessions: true
}
)
@ -161,7 +204,7 @@ describe('filterAiVaultSessions', () => {
agents: ['claude'],
scope: 'workspace',
sort: 'updated',
activeWorktreePath: 'c:\\users\\ada\\repo',
activeWorktreePaths: ['c:\\users\\ada\\repo'],
hideEmptySessions: true
}
)
@ -182,13 +225,119 @@ describe('filterAiVaultSessions', () => {
agents: ['claude'],
scope: 'all',
sort: 'updated',
activeWorktreePath: null,
activeWorktreePaths: [],
hideEmptySessions: false
})
).toEqual([])
})
})
describe('deriveAiVaultWorkspaceScopePaths', () => {
it('includes current and same-repo prior filesystem paths', () => {
expect(
deriveAiVaultWorkspaceScopePaths({
id: 'repo1::/Users/ada/workspaces/orca/fix-agent-history',
repoId: 'repo1',
path: '/Users/ada/workspaces/orca/fix-agent-history',
priorWorktreeIds: ['repo1::/Users/ada/workspaces/orca/bream']
})
).toEqual(['/Users/ada/workspaces/orca/fix-agent-history', '/Users/ada/workspaces/orca/bream'])
})
it('strips folder-workspace instance suffixes from prior ids', () => {
expect(
deriveAiVaultWorkspaceScopePaths({
id: 'repo1::/Users/ada/folders/orca',
repoId: 'repo1',
path: '/Users/ada/folders/orca',
priorWorktreeIds: [
'repo1::/Users/ada/folders/old-orca::workspace:123e4567-e89b-12d3-a456-426614174000'
]
})
).toEqual(['/Users/ada/folders/orca', '/Users/ada/folders/old-orca'])
})
it('ignores malformed, different-repo, relative, empty, and duplicate aliases', () => {
expect(
deriveAiVaultWorkspaceScopePaths({
id: 'repo1::C:\\Users\\Ada\\Repo',
repoId: 'repo1',
path: 'C:\\Users\\Ada\\Repo',
priorWorktreeIds: [
'not-a-worktree-id',
'repo2::C:\\Users\\Ada\\OldRepo',
'repo1::relative/path',
'repo1::',
'repo1::c:\\users\\ada\\repo',
'repo1::C:\\Users\\Ada\\OldRepo'
]
})
).toEqual(['C:\\Users\\Ada\\Repo', 'C:\\Users\\Ada\\OldRepo'])
})
it('ignores prior paths claimed by another live worktree in the same repo', () => {
expect(
deriveAiVaultWorkspaceScopePaths(
{
id: 'repo1::/Users/ada/workspaces/orca/fix-agent-history',
repoId: 'repo1',
path: '/Users/ada/workspaces/orca/fix-agent-history',
priorWorktreeIds: [
'repo1::/Users/ada/workspaces/orca/bream',
'repo1::/Users/ada/workspaces/orca/unclaimed-old-path'
]
},
[
{
id: 'repo1::/Users/ada/workspaces/orca/fix-agent-history',
repoId: 'repo1',
path: '/Users/ada/workspaces/orca/fix-agent-history'
},
{
id: 'repo1::/Users/ada/workspaces/orca/bream',
repoId: 'repo1',
path: '/Users/ada/workspaces/orca/bream'
}
]
)
).toEqual([
'/Users/ada/workspaces/orca/fix-agent-history',
'/Users/ada/workspaces/orca/unclaimed-old-path'
])
})
it('ignores prior paths claimed by another live worktree in a different repo', () => {
expect(
deriveAiVaultWorkspaceScopePaths(
{
id: 'repo1::/Users/ada/workspaces/orca/fix-agent-history',
repoId: 'repo1',
path: '/Users/ada/workspaces/orca/fix-agent-history',
priorWorktreeIds: [
'repo1::/Users/ada/workspaces/orca/bream',
'repo1::/Users/ada/workspaces/orca/unclaimed-old-path'
]
},
[
{
id: 'repo1::/Users/ada/workspaces/orca/fix-agent-history',
repoId: 'repo1',
path: '/Users/ada/workspaces/orca/fix-agent-history'
},
{
id: 'repo2::/Users/ada/workspaces/orca/bream',
repoId: 'repo2',
path: '/Users/ada/workspaces/orca/bream'
}
]
)
).toEqual([
'/Users/ada/workspaces/orca/fix-agent-history',
'/Users/ada/workspaces/orca/unclaimed-old-path'
])
})
})
describe('isAiVaultSessionFilterQueryTooLarge', () => {
it('counts UTF-8 bytes rather than UTF-16 code units', () => {
expect(

View File

@ -1,5 +1,7 @@
import {
isPathInsideOrEqual,
isRuntimePathAbsolute,
normalizeRuntimePathForComparison,
normalizeRuntimePathSeparators
} from '../../../../shared/cross-platform-path'
import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text'
@ -11,6 +13,8 @@ import type {
AiVaultSort
} from '../../../../shared/ai-vault-types'
import { aiVaultAgentLabel } from '../../../../shared/ai-vault-types'
import type { Worktree } from '../../../../shared/types'
import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id'
import { sessionPreviewSearchText } from './ai-vault-session-display'
export type AiVaultSessionFilterState = {
@ -18,7 +22,7 @@ export type AiVaultSessionFilterState = {
agents: readonly AiVaultAgent[]
scope: AiVaultScope
sort: AiVaultSort
activeWorktreePath: string | null
activeWorktreePaths: readonly string[]
hideEmptySessions: boolean
}
@ -62,18 +66,45 @@ export function filterAiVaultSessions(
if (filters.hideEmptySessions && session.messageCount === 0) {
return false
}
if (
filters.scope === 'workspace' &&
filters.activeWorktreePath &&
(!session.cwd || !isPathInsideOrEqual(filters.activeWorktreePath, session.cwd))
) {
return false
if (filters.scope === 'workspace') {
const cwd = session.cwd
if (
!cwd ||
!filters.activeWorktreePaths.some((pathValue) => isPathInsideOrEqual(pathValue, cwd))
) {
return false
}
}
return matchesQuery(session, parsedQuery)
})
.sort((left, right) => compareSessions(left, right, filters.sort))
}
export function deriveAiVaultWorkspaceScopePaths(
activeWorktree: Pick<Worktree, 'id' | 'path' | 'priorWorktreeIds' | 'repoId'> | null,
liveWorktrees: readonly Pick<Worktree, 'id' | 'path' | 'repoId'>[] = []
): string[] {
if (!activeWorktree) {
return []
}
const paths: string[] = []
addAiVaultWorkspaceScopePath(paths, activeWorktree.path)
for (const priorWorktreeId of activeWorktree.priorWorktreeIds ?? []) {
const parsed = splitWorktreeIdForFilesystem(priorWorktreeId)
if (!parsed || parsed.repoId !== activeWorktree.repoId) {
continue
}
if (isAiVaultWorkspaceScopePathClaimed(parsed.worktreePath, activeWorktree, liveWorktrees)) {
continue
}
addAiVaultWorkspaceScopePath(paths, parsed.worktreePath)
}
return paths
}
export function groupAiVaultSessions(
sessions: readonly AiVaultSession[],
group: AiVaultGroup
@ -180,6 +211,38 @@ function getFolderGroupKey(pathValue: string | null): string {
return pathValue ? normalizeRuntimePathSeparators(pathValue).toLowerCase() : 'unknown'
}
function addAiVaultWorkspaceScopePath(paths: string[], pathValue: string): void {
const trimmedPath = pathValue.trim()
if (!trimmedPath || !isRuntimePathAbsolute(trimmedPath)) {
return
}
const comparisonPath = normalizeRuntimePathForComparison(trimmedPath)
if (
paths.some((existingPath) => normalizeRuntimePathForComparison(existingPath) === comparisonPath)
) {
return
}
paths.push(trimmedPath)
}
function isAiVaultWorkspaceScopePathClaimed(
pathValue: string,
activeWorktree: Pick<Worktree, 'id'>,
liveWorktrees: readonly Pick<Worktree, 'id' | 'path'>[]
): boolean {
const trimmedPath = pathValue.trim()
if (!trimmedPath || !isRuntimePathAbsolute(trimmedPath)) {
return false
}
const comparisonPath = normalizeRuntimePathForComparison(trimmedPath)
// AI Vault sessions are keyed by cwd only, so any live worktree now owning this path wins.
return liveWorktrees.some(
(worktree) =>
worktree.id !== activeWorktree.id &&
normalizeRuntimePathForComparison(worktree.path) === comparisonPath
)
}
function tokenizeQuery(query: string): string[] {
const tokens: string[] = []
const pattern = /"([^"]+)"|'([^']+)'|(\S+)/g

View File

@ -394,6 +394,29 @@ describe('fetchWorktrees', () => {
expect(store.getState().sortEpoch).toBe(8)
})
it('updates the repo entry when only prior worktree id aliases change', async () => {
const store = createTestStore()
const existing = makeWorktree({
id: 'repo1::/path/current-name',
repoId: 'repo1',
path: '/path/current-name'
})
const refreshed = makeWorktree({
id: 'repo1::/path/current-name',
repoId: 'repo1',
path: '/path/current-name',
priorWorktreeIds: ['repo1::/path/old-name']
})
mockApi.worktrees.list.mockResolvedValue([refreshed])
store.setState({ worktreesByRepo: { repo1: [existing] }, sortEpoch: 7 } as Partial<AppState>)
await store.getState().fetchWorktrees('repo1')
expect(store.getState().worktreesByRepo.repo1).toEqual([refreshed])
expect(store.getState().sortEpoch).toBe(8)
})
it('keeps the last known worktree list when a refresh transiently returns empty', async () => {
const store = createTestStore()
const existing = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' })

View File

@ -263,6 +263,7 @@ function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): b
worktree.pushTarget?.remoteUrl === candidate.pushTarget?.remoteUrl &&
worktree.sparseBaseRef === candidate.sparseBaseRef &&
arraysShallowEqual(worktree.sparseDirectories, candidate.sparseDirectories) &&
arraysShallowEqual(worktree.priorWorktreeIds, candidate.priorWorktreeIds) &&
(worktree as WorktreeWithLineage).parentWorktreeId ===
(candidate as WorktreeWithLineage).parentWorktreeId &&
arraysShallowEqual(

View File

@ -493,6 +493,8 @@ export type Worktree = {
baseRef?: string
/** Remote/branch Orca should publish review commits to when it created this worktree. */
pushTarget?: GitPushTarget
/** Path-derived worktree ids this worktree had before folder renames. */
priorWorktreeIds?: string[]
workspaceStatus?: WorkspaceStatus
diffComments?: DiffComment[]
mobileDiffReview?: MobileDiffReviewState