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:
Neil
2026-07-02 03:11:44 -07:00
co-authored by Orca
parent 26a3f2e6c1
commit 47bb5edc03
2 changed files with 60 additions and 2 deletions
+36
View File
@@ -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
View File
@@ -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)]