mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
Fix GC false positives: folder-workspace instances, WSL paths on Windows
From adversarial review of the GC commit: - Folder-project workspace instances are keyed repoId::path::workspace: <uuid>; splitting on the first :: made the existence probe test a path-with-suffix that never exists, deleting idle folder workspaces - whose meta IS the workspace record - and cascading into terminal- history deletion. Instance keys are now skipped entirely. - On Windows, WSL linked worktrees carry Linux-style paths from git porcelain that a Windows existsSync cannot probe; skip non-Windows paths on win32. - Idle-grace check now runs before existsSync (skips the stat fan-out for active entries and shrinks the slow-NFS startup tail), and a null worktreeMeta map in the file is normalized instead of throwing outside the parse guard. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -4634,6 +4634,42 @@ describe('Store', () => {
|
||||
expect(store.getWorktreeLineage(deadKey)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('never GCs folder-workspace instance metas — the meta IS the workspace', async () => {
|
||||
const OLD = Date.now() - 40 * 24 * 60 * 60 * 1000
|
||||
const folderInstanceKey = `r1::${join(testState.dir, 'gone-folder')}::workspace:11111111-1111-4111-8111-111111111111`
|
||||
writeDataFile({
|
||||
repos: [makeRepo({ kind: 'folder' })],
|
||||
worktreeMeta: {
|
||||
[folderInstanceKey]: { displayName: 'Session A', comment: '', lastActivityAt: OLD }
|
||||
}
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
expect(Object.keys(store.getAllWorktreeMeta())).toContain(folderInstanceKey)
|
||||
})
|
||||
|
||||
it('never GCs Linux-style WSL worktree paths on Windows', async () => {
|
||||
const OLD = Date.now() - 40 * 24 * 60 * 60 * 1000
|
||||
const wslLinkedKey = 'r1::/home/user/gone-worktree'
|
||||
writeDataFile({
|
||||
repos: [makeRepo()],
|
||||
worktreeMeta: {
|
||||
[wslLinkedKey]: { displayName: '', comment: '', lastActivityAt: OLD }
|
||||
}
|
||||
})
|
||||
|
||||
await withPlatform('win32', async () => {
|
||||
const store = await createStore()
|
||||
expect(Object.keys(store.getAllWorktreeMeta())).toContain(wslLinkedKey)
|
||||
})
|
||||
})
|
||||
|
||||
it('tolerates a null worktreeMeta map in the durable file', async () => {
|
||||
writeDataFile({ worktreeMeta: null })
|
||||
const store = await createStore()
|
||||
expect(store.getAllWorktreeMeta()).toEqual({})
|
||||
})
|
||||
|
||||
// ── GitHub cache sidecar ───────────────────────────────────────────
|
||||
|
||||
it('cache refreshes never rewrite the durable state file', async () => {
|
||||
|
||||
+24
-2
@@ -126,9 +126,14 @@ import {
|
||||
import { agentHookServer } from './agent-hooks/server'
|
||||
import { pruneLocalTerminalScrollbackBuffers } from '../shared/workspace-session-terminal-buffers'
|
||||
import { pruneWorkspaceSessionBrowserHistory } from '../shared/workspace-session-browser-history'
|
||||
import { getRepoIdFromWorktreeId, getWorktreePathBasenameFromId } from '../shared/worktree-id'
|
||||
import {
|
||||
FOLDER_WORKSPACE_INSTANCE_SEPARATOR,
|
||||
getRepoIdFromWorktreeId,
|
||||
getWorktreePathBasenameFromId
|
||||
} from '../shared/worktree-id'
|
||||
import {
|
||||
isPathInsideOrEqual,
|
||||
isWindowsAbsolutePathLike,
|
||||
normalizeRuntimePathForComparison
|
||||
} from '../shared/cross-platform-path'
|
||||
import { normalizeTerminalQuickCommands } from '../shared/terminal-quick-commands'
|
||||
@@ -347,11 +352,20 @@ function getGithubCacheFile(): string {
|
||||
const WORKTREE_META_GC_GRACE_MS = 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
function gcStaleWorktreeMeta(state: PersistedState): number {
|
||||
// Why: a hand-corrupted file with `"worktreeMeta": null` overrides the
|
||||
// defaults merge; normalize instead of throwing outside the parse guard.
|
||||
state.worktreeMeta ??= {}
|
||||
const repoById = new Map(state.repos.map((repo) => [repo.id, repo]))
|
||||
const projectIds = new Set((state.projects ?? []).map((project) => project.id))
|
||||
const now = Date.now()
|
||||
let removed = 0
|
||||
for (const key of Object.keys(state.worktreeMeta)) {
|
||||
// Why: folder-project workspace instances are keyed
|
||||
// `repoId::path::workspace:<uuid>` and their meta IS the workspace
|
||||
// record — never a filesystem-checkout row. Skip them entirely.
|
||||
if (key.includes(FOLDER_WORKSPACE_INSTANCE_SEPARATOR)) {
|
||||
continue
|
||||
}
|
||||
const separator = key.indexOf('::')
|
||||
if (separator === -1) {
|
||||
continue
|
||||
@@ -377,16 +391,24 @@ function gcStaleWorktreeMeta(state: PersistedState): number {
|
||||
if (!isAbsolute(worktreePath) || isWslUncPath(worktreePath)) {
|
||||
continue
|
||||
}
|
||||
if (existsSync(worktreePath)) {
|
||||
// Why: WSL linked worktrees on Windows carry Linux-style paths from git
|
||||
// porcelain; a Windows existsSync cannot probe those and would falsely
|
||||
// condemn live worktrees.
|
||||
if (process.platform === 'win32' && !isWindowsAbsolutePathLike(worktreePath)) {
|
||||
continue
|
||||
}
|
||||
// Why keep timestamp-less entries: without lastActivityAt/createdAt we
|
||||
// cannot prove the 30-day idle grace elapsed; the measured dead entries
|
||||
// all carry timestamps, so this costs almost nothing in reclaimed bytes.
|
||||
// Grace runs before the stat so healthy profiles skip the existsSync
|
||||
// fan-out (and its slow-NFS tail) for active entries entirely.
|
||||
const newestTouch = Math.max(meta?.lastActivityAt ?? 0, meta?.createdAt ?? 0)
|
||||
if (newestTouch === 0 || now - newestTouch < WORKTREE_META_GC_GRACE_MS) {
|
||||
continue
|
||||
}
|
||||
if (existsSync(worktreePath)) {
|
||||
continue
|
||||
}
|
||||
delete state.worktreeMeta[key]
|
||||
delete state.worktreeLineageById[key]
|
||||
delete state.workspaceLineageByChildKey[worktreeWorkspaceKey(key)]
|
||||
|
||||
Reference in New Issue
Block a user