Commit Graph
5 Commits
Author SHA1 Message Date
Devin FoleyandPaperclip fe85fd7ac4 Expire closed workspaces that open descendants do not use (#17464)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Git cache expiration removes closed issues' worktrees and saves
restore points for unsaved work
> - It treated a workspace as closed only when the source issue and
every descendant were done or cancelled
> - A routine that closes a parent and leaves children open for days
therefore kept every parent worktree, although the children work in
their own workspaces
> - The idle path did not help, because the children are updated every
day
> - This pull request counts only the issues that use the workspace when
it decides that a closed workspace can expire
> - The benefit is that closed parent worktrees are removed while their
children work elsewhere, and a child that later binds to the workspace
gets it back from the restore point

## Linked Issues or Issue Description

Refs #17271. Bug report described in-PR (bug report template).

**What happened?**
On one instance, an hourly routine creates a parent issue, works it in
its own worktree, marks the parent done, and leaves child issues open
(`blocked`, `in_review` or `in_progress`) for days. The children work in
their own workspaces. They are not bound to the parent's workspace. Git
cache expiration kept every parent worktree (about 300 MB each), because
the closed-tree check and the workspace reaper required all descendants
to be closed. In one project, 18 of 19 long-closed parent worktrees were
kept only by open children that did not use them. The idle path never
ran, because the children were updated daily.

**Expected behavior**
A closed issue's worktree expires while descendants are open, as long as
no open descendant uses that workspace.

**Steps to reproduce**
1. Enable git cache expiration and restore points.
2. Close an issue that has an isolated git worktree, and leave a child
issue open that works in its own workspace (or has no workspace
binding).
3. Wait past the terminal delay and run the workspace reaper: the parent
worktree stays, and the reaper counts it as `skippedNonTerminalTree`.

**Paperclip version or commit:** `master` at 410e5c6eed.
**Deployment mode:** any.
**Agent adapter(s) involved:** not adapter-specific (core).

## What Changed

- `isGitWorkspaceExpirationEligible` (closed path, not the idle path):
the "tree closed for one day" check now covers only the issues in the
tree that use the workspace. An issue uses the workspace when it is the
source issue, when its `execution_workspace_id` is the workspace, or
when its current `workspace_selection` or its
`workspace_pending_selection` selects it as the `existing` workspace.
Inheritance from a parent (`reuse_existing`) and `existing` selections
are both stored as `execution_workspace_id`, and heartbeat workspace
resolution reuses an existing workspace only through that binding or a
run's persisted native binding. An open descendant without one of these
references gets a new workspace on its next run, so it no longer keeps
the parent's worktree.
- All other checks stay the same: any open issue bound to the workspace,
live or unsettled runs of any issue in the tree or of the workspace
(including a run whose context names the workspace), finalizations,
leases, recovery actions, runtime services, shared cwd and the reopen
fence.
- Workspace reaper: when the source issue is closed, git cache
expiration applies, and no reopen is pending, the reaper routes the
workspace by the same rule. It takes the restore point due time from the
source issue and the descendants that use the workspace, also after
every descendant is closed. A recently closed child that used the
workspace still holds it for the terminal delay. The compaction delay
and the archive cooldown still use the whole tree when it is closed.
- Workspace reaper: compaction and the normal archive path keep their
behavior. While any descendant is open, the reaper does not compact the
worktree, and the expiration branch handles the workspace and continues.
The archive path still requires a closed subtree. With expiration off,
or a pending reopen, routing is unchanged.
- New exported helper `listIssuesUsingGitWorkspace` shares the SQL "uses
the workspace" predicate between eligibility and the reaper.
- Docs: `doc/recoverable-disk-cleanup.md` and the
`PAPERCLIP_GIT_RESTORE_POINT_TERMINAL_DELAY_DAYS` entry in
`doc/DEVELOPING.md`.
- The existing `preserves live descendants and pending reopening` case
now binds its open child to the workspace. An unbound open child no
longer keeps the worktree by design.

## Verification

- `pnpm exec vitest run
src/__tests__/git-restore-point-expiration.test.ts
src/__tests__/git-workspace-expiration.test.ts
src/__tests__/heartbeat-git-restore-point-resume.test.ts` in `server/`:
all pass.
- `pnpm exec vitest run src/__tests__/git-workspace-expiration
src/__tests__/git-restore-point src/__tests__/execution-workspace
src/__tests__/heartbeat-git-restore-point-resume
src/services/heartbeat/environment-selection
src/__tests__/isolated-repository` in `server/`: 15 of 17 files passed
in one parallel run. The other failures were the updated descendant case
(now fixed) and 45-second timeouts in restore point tests while 17
embedded Postgres files ran at the same time. Both files pass when run
on their own.
- `pnpm run typecheck` in `server/`: passes.
- New embedded Postgres cases with real git repositories:
- A closed parent with open `blocked`, `in_review`, `in_progress` and
`todo` children and a grandchild in their own workspaces (or with none):
the worktree expires with a restore point and the reopen restores it
exactly.
- The reaper archives it, timed from the issues that use the workspace.
A child closed one minute ago with its own workspace does not delay it.
- A recently closed child bound to the workspace holds it until its
terminal delay passes.
- When every child is closed and the most recent one used its own
workspace, the reaper does not restart the restore point delay.
- The worktree is kept when an open child is bound to it, when an open
grandchild under a child with its own workspace is bound to it, when a
child created later with `issueService.create` inherits it with
`reuse_existing`, when an open child selects it through
`workspace_selection` or `workspace_pending_selection`, when a child run
that references it is live, and when the parent was closed inside the
delay.
- The reaper never compacts the worktree while children are open, with
expiration on or off. A pending reopen keeps its current fence handling.
- After expiry, an open child that binds to the workspace reopens it
with the saved commits, index, working tree and untracked files.
- Heartbeat: a queued run for an open child that binds to the expired
workspace of a closed parent restores the workspace and runs the agent
in it with the saved state.
- Mutation checks (each run against the two restore point test files):
  - Closed-tree check restored to the whole tree: 7 tests fail.
  - `execution_workspace_id` removed from the rule: 3 fail.
  - `workspace_selection` removed from the rule: 1 fails.
  - `workspace_pending_selection` removed from the rule: 1 fails.
  - Reaper `latestTerminalAt` from the whole tree: 3 fail.
  - Reaper routing by the whole tree: 2 fail.
- Reaper restore point due time from the whole tree once it is closed: 1
fails.
  - Pending-reopen gate removed: 1 fails.
- Compaction gate (`!treeOpen`) removed: no test fails. Compaction's own
guard under the lifecycle lock still rejects open trees, so the gate
only skips work that cannot succeed.
- Not run: the full server suite and the UI suites. CI covers them.

## Risks

- Closed parent worktrees now expire while their children are open. A
child that later binds to the workspace depends on the restore point and
on the run-side reopen from #17271. The new tests cover this path. When
restore points are off, only exactly reproducible worktrees expire.
- An open child with no workspace binding no longer keeps the parent's
worktree. Heartbeat resolution never reuses a workspace without a
binding, so this does not change where the child runs.
- The rule is conservative. A workspace selected only by an issue's
current or pending selection is kept, and live runs of any issue in the
tree still block.
- The reaper runs one extra indexed query for each closed workspace when
git cache expiration applies. No migration.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), 1M context, through Claude Code
with tool use.

## 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>
2026-10-24 12:43:57 -07:00
Devin FoleyandPaperclip 6877760904 feat: save ignored files in git restore points (#17290)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Git cache expiration removes closed issues' worktrees after saving a
restore point of anything the remote cannot reproduce
> - Restore points left ignored files out, so any worktree with an
ignored file that was not a known regenerable directory was kept
> - On one instance that was the most common reason a closed worktree
was still kept, mostly build caches and agent evidence files
> - This pull request saves those ignored files in the restore point too
> - The benefit is that more closed worktrees are reclaimed, and nothing
an agent left behind is lost

## Linked Issues or Issue Description

Refs #17248, #17257. Bug report described in-PR (bug report template).

**What happened?**
With restore points enabled, one instance kept 15 of 43 inspected closed
worktrees with `unique_ignored_files`. Among 19 such worktrees on disk:
- 9 held `ui/tsconfig.tsbuildinfo`;
- others held build output outside the regenerable directory names
(`server/ui-dist/`, `test-results/`);
- 2 held materialized skill copies under `.claude/skills/`;
- about 5 held small files agents kept out of git: patches, evidence
archives, recovery receipts.

**Expected behavior**
Ignored files that are not regenerable are saved with the restore point
and restored on reopen, so the worktree can be removed.

**Steps to reproduce**
1. In a closed issue's worktree, leave an ignored file outside
`node_modules`, `dist` and the other regenerable directories (for
example `ui/tsconfig.tsbuildinfo`).
2. With restore points enabled, run expiration: the worktree is kept
with `unique_ignored_files`.

**Paperclip version or commit:** `master` at 412914cfa7.
**Deployment mode:** any.
**Agent adapter(s) involved:** not adapter-specific (core).

## What Changed

**Capture** (`server/src/services/git-restore-point.ts`):
- `restorePointSavesIgnoredFile` defines which ignored files are saved.
It leaves out:
  - `.env` and `.env.*` files;
- `.paperclip-repositories/` (nested checkouts have their own restore
points);
  - `.claude/settings.local.json` (saved with the workspace contents);
- anything inside a directory named in
`REGENERABLE_WORKSPACE_DIR_NAMES`.
- Those files are scanned with the same limits and checks as untracked
files, and snapshotted into a separate `ignored` tree, committed on top
of `HEAD`. Its ref travels in the bundle.
- They are hashed with `hash-object --no-filters` (symlinks by their raw
target bytes), so `.gitattributes` conversions such as `text=auto` never
change their bytes. Every path given to `--stdin-paths` is C-quoted, so
git always reads the literal workspace file, even for names that start
with `"`.
- An ignored nested repository skips capture, which keeps the worktree.
- A worktree whose only extra files are ignored is no longer "nothing to
save".

**Format:** a payload with an ignored tree uses
`paperclip.git-restore-point-payload.v2`, and one without stays `v1`.
Decoding requires `ignored` exactly for `v2`, so older servers reject
the new payloads instead of restoring without the files.

**Restore:** `applyRestorePoint` writes the ignored files directly from
their blobs, read in one `cat-file --batch` call, so no filter is
applied.
- It checks every target before writing, refuses any path that would
leave the worktree, and never overwrites an existing file: a conflict
fails the restore closed.
- If a write fails partway, it removes the files it wrote, so a retry
starts clean. The post-apply verification, the before-deletion snapshot
check and `restorePointSnapshotMatches` all include the ignored tree.
The stored reference (`GitRestorePointRef.ignored`) is validated and
checked against the decoded manifest.

**Expiration** (`git-workspace-restoration.ts`): in restore point mode,
the reproducibility check accepts the ignored files a restore point
saves. Strict mode is unchanged.

**Docs:** `doc/recoverable-disk-cleanup.md`.

No code comments were added.

## Verification

- `pnpm exec vitest run src/__tests__/git-restore-point.test.ts` (in
`server/`): 27 passed. New cases:
- **Round trip:** ignored files round-trip as a `v2` payload with their
modes, while `dist/`, `node_modules/` and `.env` stay out.
- **Conflict:** an existing conflicting file fails the restore closed
and is left as it was.
  - **Nested repository:** an ignored nested repository skips capture.
- **Forged payload:** a `v1` payload claiming an ignored tree is
rejected.
- **Line endings:** an ignored CRLF file in a `* text=auto` repository
restores byte for byte.
- **Partial failure:** a restore that fails partway leaves nothing
behind, and a retry succeeds.
- **Quoted names:** a crafted ignored path that looks like a quoted
absolute path saves and restores the workspace file, never the outside
file it names.
- **Symlinks:** a symlink target with bytes that are not valid UTF-8
restores exactly.
- `pnpm exec vitest run
src/__tests__/git-restore-point-expiration.test.ts` (in `server/`): new
cases.
- A closed worktree whose only extra files are an ignored build cache
and evidence file is kept without restore points, expires with one, and
reopens with those files and without `node_modules`.
  - An ignored file changed after capture rolls back.
- **Mutation checks:** each of these fails at least one case:
  - not relaxing the reproducibility check;
- not capturing ignored files (the end-to-end case then catches the
loss);
  - leaving the ignored tree out of the comparison;
  - not writing the files on apply;
  - hashing with filters;
  - dropping the partial-restore rollback;
  - passing unquoted paths;
  - decoding symlink targets as UTF-8.
- `pnpm exec vitest run git-restore-point git-workspace-expiration
git-workspace-contents git-workspace-restoration execution-workspaces
workspace-compaction heartbeat-git-restore-point-resume
src/services/heartbeat/environment-selection` (in `server/`): 296
passed.
- `pnpm run typecheck` (in `server/`): pass.
- Not run: server build and boot smoke.

## Risks

- **Only with restore points enabled.** Strict expiration is unchanged.
- **Bigger restore points.** Ignored files count toward the existing
limits (20,000 files, 64 MB per file, 256 MB total). Larger caches
outside the regenerable names make capture skip, which keeps the
worktree as before.
- **Rollback:** older servers cannot restore `v2` restore points; they
fail closed. Disable restore points before downgrading.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), 1M context, through Claude Code
with tool use (shell, file edits, tests, read-only inspection of a live
instance).

## 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>
2026-10-23 08:24:31 -07:00
Devin FoleyandPaperclip d69631b01b feat: reopen a fresh workspace with a warning when its restore point expired (#17269)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - #17248 and #17257 let git cache expiration remove closed issues'
worktrees after saving encrypted restore points
> - Restore points are kept for 90 days after an issue tree closes, then
deleted
> - Reopening a workspace whose restore point had expired failed, so the
issue could not get a workspace back
> - This pull request starts a fresh workspace on the same branch
instead, and warns that the saved work is gone
> - The benefit is that old issues can always be resumed, with a clear
record of what was lost

## Linked Issues or Issue Description

Refs #17248, #17257. Bug report described in-PR (bug report template).

**What happened?**
After `PAPERCLIP_GIT_RESTORE_POINT_RETENTION_DAYS` passes, the retention
worker marks the restore point expired and deletes it. Reopening that
workspace then fails with the git cache restoration error, every time.

**Expected behavior**
Reopening succeeds with a fresh workspace on the issue's branch, and the
issue says that unsaved work from before the cleanup is no longer
available.

**Steps to reproduce**
1. Enable restore points and let a closed issue's worktree expire with
unsaved work.
2. Let its restore point expire.
3. Reopen the issue: the workspace rebuild fails.

**Paperclip version or commit:** `master` at 650581b2f0.
**Deployment mode:** any.
**Agent adapter(s) involved:** not adapter-specific (core).

## What Changed

- **Detection:** `gitRestorePointExpired(recipe)` is true when the main
restore point or any nested checkout's restore point has `expiredAt`.
- **Reopen** (`execution-workspaces.ts`):
- `restoreGitCacheBase(..., { freshStart: true })` prepares the base
without the saved commit or the bundle.
- The saved recipe is left out of the metadata handed to the runtime,
and dropped from the stored metadata only when the reopen succeeds, so a
failed attempt can be retried and still warns. The runtime recreates the
worktree on its branch: the branch tip if the repository still has it,
otherwise a new branch from the base ref. When the expired recipe had
nested checkouts, the project's repositories are prepared with
`prepareProjectRepositoryWorkspaces` (without the expired saved state)
right after the worktree is recreated and before the provision command
runs, with the same copy identity and access rechecks as other reopen
copies.
- The result carries `warning`, the `execution_workspace.reopened`
activity records `restorePointExpired` and the warning, and a system
comment on the issue posts the warning in the same transaction.
- **Retention:** `markGitRestorePointExpired` now also marks nested
checkouts' restore points, matched by object key.
- **Docs:** `doc/recoverable-disk-cleanup.md` describes the fresh start.

No code comments were added.

## Verification

- `pnpm exec vitest run
src/__tests__/git-restore-point-expiration.test.ts` (in `server/`): 13
passed. New case: an expired restore point reopens fresh with the
warning and the system comment, on the original branch, even after the
base lost the branch and its unpushed commit; the recipe is gone. A
first attempt that fails keeps the expired recipe, and the retry still
warns and comments exactly once.
- New nested case in `git-workspace-expiration.test.ts`: an expired
nested checkout restore point is marked on the checkout entry and
reopens fresh with the warning, with the nested repository cloned again
at its pushed commit and without its expired uncommitted files.
- **Mutation checks:** each of these fails a case:
  - removing the fresh base path (reopen fails);
  - removing the system comment;
  - handing the runtime the old recipe;
  - dropping the stored recipe before the rebuild succeeds;
  - marking only the main restore point expired;
  - skipping nested repository preparation on a fresh start.
- `pnpm exec vitest run git-workspace-expiration git-workspace-contents
git-restore-point execution-workspaces` plus the environment selection
and heartbeat resume suites (in `server/`): 257 passed.
- `pnpm run typecheck` (in `server/`): pass.
- Not run: server build and boot smoke.

## Risks

- **Only with restore points enabled:** recipes get `expiredAt` only
from the restore point retention worker, so nothing changes otherwise.
- **Lost work is intended:** the fresh workspace has no uncommitted
files and, when the repository no longer has the branch, no unpushed
commits. The warning says so.
- **Recipe dropped:** after a fresh start the workspace no longer
carries the expired recipe, so later expirations start from a clean
slate.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), 1M context, through Claude Code
with tool use (shell, file edits, tests).

## 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>
2026-10-22 22:20:52 -07:00
Devin FoleyandPaperclip 5ef783d971 feat: expire idle open issues' worktrees with restore points (#17271)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - #17248 and #17257 let git cache expiration remove a closed issue
tree's worktree after saving an encrypted restore point
> - Issues that stay open but go untouched for weeks still keep their
worktrees forever, so disks keep filling
> - This pull request expires those idle worktrees the same way, and has
the next run restore its workspace automatically
> - The benefit is bounded disk use for stalled open work, with nothing
lost when work resumes

## Linked Issues or Issue Description

Refs #17248, #17257. Bug report described in-PR (bug report template).

**What happened?**
With restore points enabled, expiration only considers worktrees whose
whole issue tree has been `done` or `cancelled` for a while. A worktree
whose issue stays `todo`, `in_progress`, `blocked` or `in_review` with
no activity for weeks is never removed.

**Expected behavior**
An open issue's worktree that has been idle long enough is saved to a
restore point and removed, and the next run on that issue gets it back
exactly.

**Steps to reproduce**
1. Enable git cache expiration and restore points.
2. Leave an issue open with an isolated worktree and no activity for
weeks.
3. The worktree is never removed.

**Paperclip version or commit:** `master` at 650581b2f0.
**Deployment mode:** any.
**Agent adapter(s) involved:** not adapter-specific (core).

## What Changed

**Expiration** (`git-workspace-expiration.ts`):
- `isGitWorkspaceExpirationEligible(..., idleCutoff)`: in idle mode the
closed-tree conditions are replaced by idle ones. No issue in the tree,
and no other issue bound to the workspace, was updated after the cutoff,
and no relevant run was created, started or finished after it. Runs of
bound issues now count as relevant runs in both modes. Every other check
stays, including no live run, recovery action, unsettled finalization,
environment lease or running service.
- `gitWorkspaceLastActivityAt` returns the latest of those timestamps,
sharing the same tree and run scope.
- `expireReconstructableGitWorkspace` takes `idleCutoff` and passes it
to all three eligibility checks.

**Reaper** (`execution-workspaces.ts`):
- For an open tree's isolated git worktree, when restore points and git
cache expiration are on and `idleMs` is set, the reaper computes the
idle deadline (last activity + `PAPERCLIP_GIT_RESTORE_POINT_IDLE_DAYS`).
- Before the deadline, it records the deadline, so the next sweep wakes
then. Open trees cause no recurring checks before that.
- At the deadline, it runs expiration with a restore point. A kept
workspace is retried after an hour, like terminal ones.
- An expiration interrupted after archiving (`completed: false`) is
resumed by later sweeps even while the tree is open, with a retry
scheduled until cleanup finishes.

**Runs** (`heartbeat/environment-selection.ts`):
- When a run's issue is bound to a workspace that git cache expiration
removed (archived, `workspaceRemoved`), the run reopens it as the agent
through `reopenClosedIsolatedExecutionWorkspaceForIssue`, with the
restore point applied.
- It then clears the reopen fence, since the live run now holds the
workspace, and continues with the reopened row.
- A failed restore fails the run instead of silently starting a new
workspace.
- Comments and agent updates on the issue already reopened closed
workspaces through the issue routes.

**Config:** `PAPERCLIP_GIT_RESTORE_POINT_IDLE_DAYS` (default `7`, `0`
turns idle expiry off). Docs in `doc/recoverable-disk-cleanup.md` and
`doc/DEVELOPING.md`.

No code comments were added.

## Verification

- `pnpm exec vitest run
src/__tests__/git-restore-point-expiration.test.ts -t "idle open
issues"` (in `server/`): 7 passed, with real repositories and embedded
Postgres.
- **Round trip:** an open issue's worktree, idle for 8 days with unsaved
work, is skipped without idle mode, expires with a restore point in idle
mode, and reopens exactly.
- **Kept:** issue activity 3 days ago, or a run finished 2 days ago,
keeps the worktree.
- **Interrupted:** an idle expiration interrupted after archiving is
finished by the reaper while the issue stays open, and reopens exactly.
- **Shared workspace:** a recent update on another issue bound to the
workspace keeps it, and moves the last activity time.
- **Reaper:** before the deadline the sweep skips the worktree and
schedules exactly that deadline; at the deadline it expires it with an
active restore point. With idle expiry off, nothing is scheduled or
expired.
- `pnpm exec vitest run
src/services/heartbeat/environment-selection.test.ts` (in `server/`): 14
passed. New cases: a run reopens its expired workspace as the agent,
clears the fence with the reopen's generation and binds the reopened
row; a failed restore fails the run; a workspace archived for other
reasons is left alone.
- `pnpm exec vitest run
src/__tests__/heartbeat-git-restore-point-resume.test.ts` (in
`server/`): 1 passed. Assembled heartbeat journey with no mocked
restore: a real worktree with an unpushed commit and uncommitted notes
is idle-expired, a queued run executes, the agent runs in the original
worktree path with the original workspace id, the commit and notes are
back, the workspace is no longer archived with no reopen fence left, and
the issue stays bound. Disabling the run-side reopen fails it.
- `git-restore-point-expiration-config.test.ts`: the default is 7 and
`0` turns it off.
- **Mutation checks:** each of these fails a case:
  - dropping the idle deadline;
  - dropping the idle cutoff;
  - dropping the run activity condition;
  - not failing the run on a failed restore;
  - not re-reading the reopened workspace;
  - not resuming a pending expiration on an open tree;
  - ignoring bound issues' activity.
- `pnpm exec vitest run git-restore-point git-workspace-expiration
git-workspace-contents execution-workspaces
src/services/heartbeat/environment-selection
src/services/heartbeat/workspace-preparation` plus
`heartbeat-git-restore-point-resume` (in `server/`): 312 passed.
- `pnpm run typecheck` (in `server/`): pass.
- Not run: server build and boot smoke.

## Risks

- **Off unless enabled:** requires
`PAPERCLIP_GIT_CACHE_EXPIRATION_ENABLED=true` and
`PAPERCLIP_GIT_RESTORE_POINTS_ENABLED=true`.
`PAPERCLIP_GIT_RESTORE_POINT_IDLE_DAYS=0` keeps restore points for
closed trees only.
- **First run after idle expiry is slower:** it waits for the restore,
about as long as a reopen.
- **Failed restore blocks runs:** a restore that fails closed (for
example a missing restore point) fails each run on that issue until an
operator fixes or rebinds the workspace. This is deliberate, so work is
never silently continued in an empty workspace.
- **Kept idle workspaces are rechecked hourly**, matching the existing
behavior for closed trees that cannot be expired.
- **Retention:** restore points of open trees are not deleted while the
tree stays open, as before.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), 1M context, through Claude Code
with tool use (shell, file edits, tests).

## 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>
2026-10-22 20:02:21 -07:00
Devin FoleyandPaperclip 6d44b80a5e feat: expire closed git worktrees with unsaved work behind restore points (#17248)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Git cache expiration removes a closed issue's worktree only when the
remote can reproduce it exactly
> - Most closed worktrees hold unpushed commits or uncommitted files, so
they are kept forever and disks fill up
> - #17238 added restore points (an encrypted save of exactly what the
remote cannot reproduce) and #17241 added their lifecycle
> - This pull request lets expiration remove those worktrees after
saving a restore point, and restores them exactly when the work resumes
> - The benefit is bounded disk use for closed work without losing
anything a user could need, behind a setting that is off by default

## Linked Issues or Issue Description

Refs #17238, #17241. Bug report described in-PR (bug report template).

**What happened?**
With `PAPERCLIP_GIT_CACHE_EXPIRATION_ENABLED=true` and dry run off, one
instance's last 111 expiration checks removed nothing:

| Skip reason | Checks |
|---|---|
| `dirty_or_untracked_files` | 39 |
| `unpushed_managed_checkout` | 32 |
| `live_or_unresolved_consumer` | 21 |
| `unique_ignored_files` | 9 |
| commit not on the remote | 10 |

Without expiration, the terminal workspace reaper archives only merged,
clean worktrees. Closed issues' worktrees therefore accumulate without
bound.

**Expected behavior**
A closed worktree whose only obstacle is unsaved work can be removed
after that work is saved, and restored exactly if the issue is reopened
or work resumes.

**Steps to reproduce**
1. Close an issue whose worktree has an unpushed commit or an
uncommitted file.
2. Enable git cache expiration with dry run off.
3. The worktree is never removed.

**Paperclip version or commit:** `master` at 12bb7e1976.
**Deployment mode:** any.
**Agent adapter(s) involved:** not adapter-specific (core).

## What Changed

**Expiration** (`server/src/services/git-workspace-expiration.ts`):
- **When it applies:** with `PAPERCLIP_GIT_RESTORE_POINTS_ENABLED=true`
(default `false`), a worktree that fails the reproducibility check for a
relaxable reason is reconsidered once its issue tree has been closed for
`PAPERCLIP_GIT_RESTORE_POINT_TERMINAL_DELAY_DAYS` (default `3`). The
relaxable reasons are uncommitted or untracked files, a commit not on
the remote, reflog or `ORIG_HEAD` history, and ignored `.env` files. A
leftover `COMMIT_EDITMSG` still blocks expiration unless it matches a
commit in the worktree's reflog, because restore points do not save
draft messages.
- **What still blocks:** a relaxed inspection
(`inspectGitCacheRecipeForRestorePoint`) keeps every other check. Nested
managed checkouts, submodules, special git state, local config and other
unique ignored files still prevent expiration.
- **Capture:** inside the existing source and lifecycle locks, a restore
point is captured with the #17238 library against a remote-verified
boundary (the `HEAD` itself, or its merge base with `origin/HEAD`), then
encrypted, uploaded, read back and hash-checked
(`git-restore-point-storage.ts`).
- **Commit before touching disk:** it is activated in the #17241 table
in the same transaction that archives the workspace with a
`paperclip.git-cache-restoration.v2` recipe referencing it, still before
anything on disk is touched.
- **Final check:** under the file locks, the worktree must still match
the captured snapshot (`HEAD`, index, working tree, modes). Otherwise
the existing rollback runs. The rollback now also drops the restore
point reference and returns the recipe to v1, so a later restore can
never apply a stale restore point over the live worktree; the stored
restore point is marked superseded.
- **Failure paths:** the `pending` row is inserted in a transaction that
first registers a `git-restore-points` notification. If the upload or
read-back fails, the row is marked `superseded` so retention deletes any
uploaded object. Nothing is deleted from disk.
- **Encryption key:** restore points are refused
(`restore_point_key_not_durable`) on `s3` storage unless the secrets
master key comes from `PAPERCLIP_SECRETS_MASTER_KEY`.
- **Dry run** reports `restorePoint: true` candidates without storing
anything.

**Restore** (`git-workspace-restoration.ts`, `workspace-runtime.ts`,
`execution-workspaces.ts`):
- **Recipe versions:** `readGitCacheRestoration` and
`savedGitWorkspaceRestoration` accept v1 without a restore point, or v2
with a valid one. Older servers reject v2 instead of restoring the
commit without the saved work.
- **Before the saved commit is needed:** reopening
(`restoreGitCacheBase`) and the runtime path import the bundle when the
saved commit is missing locally.
- **After the worktree is re-added:** the runtime always imports the
bundle, so history-only commits are present again as objects even when
the base was rebuilt (they are not recreated as branches), then applies
the restore point, verifies the result, and records `appliedAt`. Reopen
persists that through a callback, as it already does for saved contents.
- **Never overwriting:** a worktree that already matches is only marked
applied. A worktree that diverged from the saved commit's clean state is
never overwritten; the restore fails closed and can be retried. Missing,
corrupt or expired restore points also fail closed.

**Retention:** the #17241 worker's expiry hook now marks the recipe's
restore point expired before its object is deleted.

**Config and docs:**
- `server/src/config.ts` adds the two settings;
`doc/recoverable-disk-cleanup.md` and `doc/DEVELOPING.md` describe them.
- No code comments were added.

**Not in this PR (follow-ups):**
- workspaces with nested `.paperclip-repositories` checkouts;
- removing idle worktrees of issues that are still open;
- starting a fresh workspace with a warning when a restore point has
expired.

## Verification

- `pnpm exec vitest run
src/__tests__/git-restore-point-expiration.test.ts` (in `server/`): 12
passed, with real repositories and embedded Postgres. The cases:
- **Round trip:** an unpushed commit, staged and unstaged edits of one
file, a deletion, and an untracked file are restored exactly on reopen,
`.env` is dropped, and the restore point is active. Restoring through
the runtime path works the same way, and a later run never re-applies
over newer work.
- **Kept:** a worktree that is not yet due, one with the feature
disabled (today's skip reason), and one whose encryption key is not
durable.
- **Change after capture:** rolls back, keeps the worktree, returns the
recipe to v1 and supersedes the restore point.
- **Missing restore point:** fails closed, keeps the row archived and
unapplied, and fails closed again on retry.
  - **Dry run:** stores nothing.
  - **Reproducible worktrees:** expire as before, with a v1 recipe.
- **History-only commits:** reopen imports them even after they are
pruned from the base.
  - **Draft messages:** a draft commit message keeps the worktree.
- **Upload failure:** a failed upload marks the pending restore point
for cleanup and keeps the worktree.
- **Mutation checks:** each of these fails at least one case:
  - removing the pre-deletion snapshot check;
  - keeping the restore point reference on rollback;
  - removing the runtime apply;
- removing the re-apply guard (the divergence check then fails closed
instead of overwriting);
- skipping the import for a matching worktree, relaxing the draft
message guard, or not marking a failed upload's row.
- `pnpm exec vitest run
src/__tests__/git-restore-point-expiration-config.test.ts`: 5 passed.
- `pnpm exec vitest run git-workspace-expiration git-workspace-contents
git-restore-point workspace-runtime execution-workspaces` (in
`server/`): 529 passed on the first revision. Two
`workspace-runtime.test.ts` cases about remote base refs fail, and they
also fail on unmodified `master` on this machine. After the review
fixes, the expiration, contents, restore point and execution workspace
suites passed again: 222 tests.
- `pnpm run typecheck` (in `server/`): pass.
- The server builds, the runtime bundle builds, and
`scripts/server-boot-smoke.sh node server/dist-runtime/index.mjs`
reaches healthy.

## Risks

- **Off by default:** nothing changes unless both
`PAPERCLIP_GIT_CACHE_EXPIRATION_ENABLED=true` and
`PAPERCLIP_GIT_RESTORE_POINTS_ENABLED=true` are set. Roll out with
`PAPERCLIP_GIT_CACHE_EXPIRATION_DRY_RUN=true` first to see which
worktrees would receive restore points.
- **Data at rest:** restore points contain source and uncommitted files,
encrypted with keys wrapped by the instance secrets key. Losing that key
makes them unrecoverable, which is why `s3` storage requires an
environment-provided key. Untracked `.env` files are not stored.
- **Interrupted apply:** a crash in the middle of applying a restore
point leaves a worktree that neither matches the restore point nor is
clean. The restore then fails closed rather than overwriting it, and an
operator must reset that worktree to retry.
- **Rollback:** disable the setting. Already-expired worktrees keep
their v2 recipes and restore points, so reverting the code would leave
those recipes unreadable until restored by a server with this change.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), 1M context, through Claude Code
with tool use (shell, file edits, tests, local server builds and boots,
read-only measurements on live instances).

## 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>
2026-10-22 16:50:15 -07:00