fix(host-service): let destroy complete when the project repo is gone from disk

Merged
SA
3mo ago
+316-17

Problem

A user deletes a workspace whose project repository directory no longer exists on disk (moved or deleted outside Superset) while the workspace's worktree directory still exists. workspaceCleanup.destroy fails every time with a 500 (Failed to open project repo at <repoPath>: Cannot use simple-git on a directory that does not exist), un-archives the row, and the workspace reappears. There is no way out from the UI: the events on HOST-SERVICE-3A are one user pressing delete on the same workspace ~10 times over 9 hours.

User-visible harm: a workspace whose project repo is gone can never be deleted from the UI; every attempt errors and the row comes back.

Reach: the events are from host-service 1.22.0; the code path (runDestroyPhases, step 3b) is unchanged on main, so this ships in the next host-service/desktop release and reaches every desktop user who deletes a workspace after moving or removing the project folder.

Why code, not archive/filter: archiving the issue leaves the user stuck; there is no other layer that can decide "the repo is gone" for the saga; the throw is a protective guard against orphaning disk, but the disk it protects is a dangling worktree whose gitdir pointer is already dead, so no retry can ever succeed. No in-flight PR touches this saga (checked #6381, #6605 — different files).

Root cause

runDestroyPhases step 3b (if (local && project)) always tries cleanupGitOps.resolveGitEnv(ctx, project.repoPath) to run git worktree remove in the project repo. When the repo directory is missing, simple-git's constructor throws, and because the worktree directory still exists the saga throws INTERNAL_SERVER_ERROR — a genuinely-unknown-state guard that is wrong for this specific, fully-known state.

Fix

Check the filesystem before opening the repo — no message matching, and only a genuine ENOENT counts: statSync(repoPath, { throwIfNoEntry: false }) === undefined. existsSync also answers false for a repo this process merely cannot read (EPERM under macOS privacy protection, EACCES), and that repo is intact — its worktree may hold uncommitted work — so it must not take this branch (triage-review addition, second commit). When the repo directory is really missing and the worktree directory still exists:

  • skip the git-based worktree remove — there is no repository to run it in;
  • delete the worktree folder directly only when it resolves strictly inside the project's managed worktrees root — new isInsideProjectWorktreesRoot(path, projectId, worktreeBaseDir) in worktree-paths.ts, same shape as isInsideSessionsRoot (realpath-normalised, strict-prefix). Third commit (from review): the base directory is canonicalised too and <base>/<projectId> must itself sit strictly inside it, so a project-root entry that is a symlink out of the base cannot let a path beneath it pass; unit tests in worktree-paths.test.ts (the symlink case fails against the previous guard). The root is resolved the same way workspace creation resolves it: project.worktreeBaseDir ?? getHostWorktreeBaseDir(ctx) → <base>/<projectId>;
  • anything outside that root is left on disk with a warning (Skipped worktree removal at …: project repo at … is missing and the folder is outside the managed worktrees root) and the delete still succeeds — an adopted or corrupt worktreePath can never be rm -rf'd;
  • mark the worktree removed and continue; step 4 (branch delete) is naturally skipped because there is no git env (branchDeleted: false).

Every other path is byte-for-byte unchanged: the repo-exists-but-won't-open case (permissions, corrupt .git, credential/remote failure) still throws Failed to open project repo … as a 500 — that is a genuine unknown state. Also unchanged: repo missing and worktree missing (still resolves via the old branch → warning + success, as before).

Still reports as 500 on this path, deliberately: Failed to open project repo … when the repo directory exists but cannot be opened, including when it exists but is unreadable by this process (EPERM/EACCES — pinned by the third test); Failed to remove worktree at … if the direct rm of a managed-root worktree fails (EACCES etc.). Both un-archive the row and stay retryable.

No typed cause: nothing reads one on this branch (no renderer branch, no retry decision), so it stays a plain TRPCError({ code, message }).

Not touched (different causes): Failed to verify worktree removal on task timeout/EAGAIN (HOST-SERVICE-17, HOST-SERVICE-2X) and Couldn't verify worktree state (HOST-SERVICE-28).

Verification

Both new tests reproduce the production failure against unfixed src/ (stashed):

$ git stash push -- src/ && bun test test/integration/workspace-cleanup.integration.test.ts -t "missing project repo"
  httpStatus: 500,
  stack: "TRPCError: Failed to open project repo at /private/var/folders/…/host-service-test-repo-1AgyuP: Cannot use simple-git on a directory that does not exist
    at runDestroyPhases (…/workspace-cleanup.ts:454:15)
(fail) workspaceCleanup.destroy integration > missing project repo: worktree outside the managed root is left on disk with a warning [748.17ms]
 0 pass
 2 fail

With the fix:

$ bunx biome check src/trpc/router/workspace-cleanup/workspace-cleanup.ts src/trpc/router/workspace-creation/shared/worktree-paths.ts test/helpers/seed.ts test/integration/workspace-cleanup.integration.test.ts
Checked 4 files. No fixes applied.   (one --write formatting pass on the test file before this)

$ cd packages/host-service && bun run typecheck
$ tsc --noEmit --emitDeclarationOnly false      (clean)

$ bun test test/integration/workspace-cleanup.integration.test.ts
 16 pass
 0 fail
 64 expect() calls
Ran 16 tests across 1 file. [9.95s]

$ bun test test/integration/workspace-create-delete.integration.test.ts test/integration/session-create-delete.integration.test.ts test/integration/workspace-delete-teardown.integration.test.ts src/trpc/router/workspace-cleanup
 23 pass
 0 fail
 95 expect() calls
Ran 23 tests across 3 files. [11.44s]

(src/trpc/router/workspace-cleanup has no unit tests; the destroy saga is covered by the integration suites above.)

New tests in workspace-cleanup.integration.test.ts:

  • positive — project with a custom worktreeBaseDir, real git worktree add under <base>/<projectId>/…, repo directory removed, destroy → success, worktreeRemoved: true, branchDeleted: false, no warnings, worktree directory gone, row archived.
  • negative — worktree at a path outside the managed root, repo removed, destroy → success, worktreeRemoved: false, exactly one outside the managed worktrees root warning, directory still on disk.
  • negative (unreadable ≠ missing) — repo parked under a parent with mode 000 so stat fails with EACCES; destroy still rejects with Failed to open project repo and the worktree stays on disk. Fails against the existsSync version (the destroy resolved and deleted the worktree), passes with the ENOENT-only check. Skipped when running as root.

Fourth commit: CI caught four cases in test/workspace-cleanup.test.ts (mocked git layer, placeholder /repo path) that now routed into the direct-removal branch because their repo path did not exist on disk — fixture change only, they get a real empty temp directory. Full bun run test in packages/host-service: 1135 pass, 0 fail across 113 files.

Third-commit verification: bunx biome check clean; bun run typecheck clean; bun test over the four destroy suites + workspace-creation/shared: 102 pass, 0 fail.

Second-commit verification: bunx biome check clean on both files; bun run typecheck clean; bun test over workspace-cleanup + workspace-create-delete + session-create-delete + workspace-delete-teardown: 40 pass, 0 fail.

Refs HOST-SERVICE-3A

https://claude.ai/code/session_a9535131-e42d-466f-b011-268a5e960a27

Summary by CodeRabbit

  • Bug Fixes
    • Improved workspace cleanup when project repositories are unavailable.
    • Safely removes missing worktrees only when they are within the managed worktree directory.
    • Preserves external worktrees and provides a warning instead.
    • Distinguishes inaccessible repositories from missing repositories, preserving them and reporting cleanup failures.
  • Reliability
    • Added path validation to prevent unintended directory deletion.
    • Improved support for custom worktree base directories.
    • Enhanced cleanup behavior for archived and temporary workspaces.