mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Isolated task workspaces are Git worktrees on the server disk, and the terminal workspace reaper keeps that disk usage bounded > - #17369 moves old workspaces off their private per-issue clones onto the project's shared clone and then deletes the private clone > - On one instance, the first live sweep moved 12 workspaces but deleted none of their clones (about 600 MB each), and 3 workspaces could not move because their restore point was "too large" > - The kept clones are not referenced by any workspace, so no other cleanup removes them > - This pull request replaces the clone check with a real reproducibility check, sweeps the orphaned clones, lifts the restore point limits for local transfers, and fixes a false `source_identity_changed` > - The benefit is that the disk space #17369 was meant to reclaim is reclaimed, without deleting unique work ## Linked Issues or Issue Description Refs #17369. Bug report described in-PR (bug report template). **What happened?** On one instance, with `PAPERCLIP_ISOLATED_SOURCE_REHOME_ENABLED=true` and dry run off, the first sweep re-homed 12 workspaces, skipped 5 with `live_or_unresolved_consumer` and 3 with `restore_point_too_large`. No clone was removed: 11 logged `cloneKeptReason: "clone_checkout_has_files"` and 1 logged `"source_identity_changed"`. Each clone's own checkout was clean (`git status --porcelain --untracked-files=all` empty). Its only history that the shared clone lacked was remote branches and release tags that the shared clone never fetched, which exist on the remote. An operator removed 11 clones by hand after verifying them. **Expected behavior** A re-homed clone whose checkout is clean and whose local history is in the shared clone or on the remote is deleted. A clone with real unique work is kept. Workspaces whose changes exceed the upload limits still move, because the transfer is local. **Steps to reproduce** 1. Use a project repository whose `.gitignore` (or the clone's `info/exclude`) contains `.paperclip/`, with a release tag on a commit that is not on any branch. 2. Re-home an isolated workspace of that repository with #17369. 3. The old worktree is removed, the empty `.paperclip/worktrees` directory stays, and `git ls-files --others --ignored --directory` lists `.paperclip/`. The clone is kept with `clone_checkout_has_files`. Without the ignore line, the local release tag keeps it with `unique_clone_history`. 4. Add a file larger than 64 MiB to a workspace. The move skips with `restore_point_too_large`. **Paperclip version or commit:** `master` ataa0037a137(rebased onee29e2850d, after #17407). **Deployment mode:** any. **Agent adapter(s) involved:** not adapter-specific (core). ## What Changed - New clone reproducibility check (`cloneDisposable` in `server/src/services/isolated-repository-rehome.ts`), used before deleting a re-homed clone: - `git worktree prune`, then only the clone's own worktree may remain (`base_has_worktrees`); - no tracked changes and no untracked files (`clone_checkout_has_files`); the only untracked exception is Paperclip's compaction trash; - ignored files only inside regenerable directories (`node_modules`, `dist`, ...) or Paperclip's compaction trash. Ignored entries are listed with `--no-empty-directory`, and a non-regenerable ignored directory is expanded file by file, so empty directories such as the old worktree's `.paperclip/worktrees` parent no longer count; - only `refs/heads`, `refs/tags` and `refs/remotes` (no stash, notes or replacement refs: `unsupported_clone_refs`); - every commit reachable from local branches, local tags, non-remote reflogs, `ORIG_HEAD` and `HEAD` (so a detached checkout counts) must be on a remote-tracking ref, present in the shared clone, or on the remote. The remote check reuses `hasCommitsMissingUpstream` (now exported), which fetches the remote's branches and tags into a temporary store, and it runs only when some commit is in neither place. A local tag is accepted when its commit is on the remote. Otherwise the clone is kept (`unique_clone_history`), or kept with `managed_checkout_upstream_unavailable` when the remote cannot be read; - existing 5,000 budgets for refs, reflog entries and unique commits (`clone_history_budget`), plus at most 1,000 ignored directories outside regenerable ones, read in one Git call (`clone_file_budget`); - the log entry now includes `cloneKeptPaths`: up to five paths, worktrees, refs or commits that kept the clone. - Cleanup also checks that the clone's `origin` still matches the workspace `repoUrl` before deleting it. - Old worktree ownership (#4): it is proven by `git rev-parse --git-common-dir`, or, when that fails, by the worktree's `.git` pointer into `<clone>/.git/worktrees/`. - Orphan sweep (`sweepOrphanedIsolatedRepositorySources`): at the first reaper rotation after server start (feature on), it walks `isolated-repository-sources/<company>/<issue>/checkout`. It holds the isolated source lock and skips clones that a workspace still needs (an open workspace with matching `isolatedRemoteSource`, a closed one that is not expired with its worktree removed, a project workspace or a running service), clones with a pending move, and clones whose re-homed workspace still has its own cleanup pending. The repository URL comes only from the database: the issue's workspaces must record exactly one URL and the clone's `origin` must equal it (`unproven_repository` or `source_identity_changed` otherwise), so a rewritten `origin` is never fetched. It then applies the same check (using the shared clone of a workspace of the same issue and repository when one exists), renames to trash and deletes. It logs `event: "execution_workspace.isolated_source_orphan_cleanup"` and removes leftover trash directories from interrupted removals. It inspects at most eight issue directories per reaper sweep and resumes from the saved position in the next sweep, so slow remotes cannot hold up other cleanup. When a full walk kept any orphan, it registers a deadline one day later in the reaper's existing deadline set. Otherwise it registers nothing until the next server start. No new timer. - Restore point limits for re-home (#3): `ISOLATED_REHOME_RESTORE_POINT_LIMITS` (500,000 files, 1 GiB per file and in total, 1.5 GiB bundle, 1,000,000 commits) replaces the upload defaults for this local transfer. Verification is unchanged (exact snapshot equality). In `git-restore-point.ts`, `applyRestorePoint` now streams `git diff --binary` into `git apply` instead of buffering it (and skips the pipe when there is no diff, so no new Git version is needed). On a failure it kills both processes and waits for both. The ignored-file read sizes its buffer from the tree instead of the default limit. - Docs: `doc/recoverable-disk-cleanup.md`. ### What I found for `source_identity_changed` (#4) In #17369, cleanup ran `git rev-parse --git-common-dir` in the old worktree and used `""` when the command failed. Then it compared that to the clone's `.git` path, so any Git failure in the old worktree was reported as `source_identity_changed`. Git fails there when the old worktree's registration in the clone (`<clone>/.git/worktrees/<name>`) is gone while the directory still exists. Its `.git` file then points to a missing directory. I reproduced this by deleting the registration after a re-home: the old code keeps the clone with `source_identity_changed`, the new code removes the old worktree and the clone. I could not inspect the instance, so the exact event that removed that one registration is not confirmed. The check was a false positive in that case, and the fix proves ownership from the `.git` pointer instead. A worktree whose pointer leads outside the clone is still kept. ## Verification - `pnpm exec vitest run src/__tests__/isolated-repository-rehome.test.ts` in `server/`: 28 passed (real Git repositories, embedded Postgres). New cases: - a clean clone whose only extra history is a remote branch and a release tag on a commit outside any branch, both absent from the shared clone, with `.paperclip/` ignored in the clone: deleted; - a clone with a local branch commit that is neither upstream nor in the shared clone: kept (`unique_clone_history`, the commit is in `cloneKeptPaths`); - a clone with a tracked change, then an ignored non-regenerable file next to an ignored `node_modules`, then an untracked `build/notes.txt`: each kept (`clone_checkout_has_files` with the path), then deleted after cleanup; - a clone with a second registered worktree: kept (`base_has_worktrees`); - a clone whose detached `HEAD` holds the only copy of a commit (reflogs expired): kept (`unique_clone_history`); - an old worktree whose registration was pruned: old worktree and clone removed; - an orphan whose `origin` was rewritten is kept with `source_identity_changed`, and with `unproven_repository` once no workspace records its repository; - 1,001 ignored directories keep a clone (`clone_file_budget`); - orphan sweep: a batch of one returns a resume position and the next batch finishes the walk; a dry run changes nothing. Then an orphan whose history is in the shared clone or on the remote is deleted, and an orphan with no workspace rows and an unpushed commit is kept, with the next deadline one day later; - a workspace with 65 MiB untracked and ignored files (default capture refuses them with `restore_point_too_large`) moves with an exact snapshot match. - After rebasing on #17407 (nested clone migration, which reuses helpers from the re-home module): `isolated-repository-rehome`, `nested-clone-migration`, `execution-workspaces-service`, `execution-workspaces-routes`, `execution-workspace-reopen`, `execution-workspace-compaction`, `execution-workspace-policy`, `execution-workspace-runtime-control-conflict`, `execution-workspace-runtime-lease-route`, `git-workspace-expiration`, `git-restore-point`, `git-restore-point-expiration`: 405 passed; typecheck passes. - Related suites in `server/` (before the rebase): `git-workspace-expiration`, `git-restore-point`, `git-restore-point-expiration`, `execution-workspace-reopen`, `execution-workspaces-service`, `execution-workspace-compaction`, `heartbeat-git-restore-point-resume`, `heartbeat-project-repositories`, `git-reproducible-managed-checkouts`, `isolated-remote-workspace-source`, with the new suite: 398 passed. - `pnpm run typecheck` in `server/` passes. - Mutation checks, each failing a test: - remote proof replaced by "always unique": the unfetched-history and orphan tests fail; - remote proof replaced by "never unique": the local-commit and orphan tests fail; - `.git` pointer fallback removed: the pruned-registration test fails; - re-home limits removed: the large-file test fails; - orphan sweep removed from the reaper: the orphan test fails; - worktree count check removed: the second-worktree test fails; - regenerable-directory exception applied to untracked files: the dirty-checkout test fails; - orphan batch limit removed: the orphan test fails; - orphan URL taken from the clone again: the rewritten-origin test fails; - ignored directory budget removed: the many-directory test fails; - `HEAD` left out of the checked commits: the detached-HEAD test fails. - Dropping only `--no-empty-directory` does not fail a test, because the per-directory expansion also accepts an empty ignored directory. The two mechanisms are redundant on purpose. - Not run: the full server suite, or the change on a live instance. ## Risks - Deleting a clone now depends on reading the remote when some local commit is neither on a remote-tracking ref nor in the shared clone. If the remote cannot be read, the clone is kept. - Commits that only remote-tracking refs hold (for example a remote branch deleted upstream after the clone fetched it) are treated as not unique, as before. They were pushed once. - The orphan sweep deletes clones that no workspace refers to. A closed workspace that is expired with its worktree removed does not count as a user, because reopening it rebuilds from the shared clone or clones again. - Re-home transfers can now use up to about 1.5 GiB of memory for a single restore point payload, so a very large workspace uses more memory during its move than before. - The orphan sweep runs once per server start and then only while it keeps an orphan, so a clone that becomes orphaned later waits for the next restart. - No migration. No schema change. ## Model Used Claude Opus 5.5 (`claude-opus-5-5`), 1M context, through Claude Code with tool use (shell, file editing, Git, test runs). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>