Files
PaperClipAI/doc/recoverable-disk-cleanup.md
T
DottaandPaperclip f1911a4c53 perf(skills): move repository packages out of Postgres (#18082)
<!-- entire-trail-link-start -->
https://entire.com/gh/paperclipai/-/trails/205/changes/paperclip/205
<!-- entire-trail-link-end -->

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents receive selected company skills before each turn.
> - Imported skills can need files from the whole source repository.
> - The current path stores those files in Postgres and reads large JSON
payloads during preparation.
> - This pull request stores verified packages in the configured file or
object store and keeps preparation queries small.
> - It also converts old rows automatically and restores missing files
from the exact saved commit.
> - The result reduces database traffic while preserving skill versions
and repository dependencies.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Company skill import, version storage, and preparation before agent
turns.

**Current behavior**

Repository snapshot bodies live in JSONB. Skill preparation can read
those bodies again even when the files already exist on disk. Historical
packages and native assets also need a bounded cleanup path.

**Proposed behavior**

Store package archives and manifests through the existing storage
service. Keep version identities, receipts, hashes, and recovery
metadata in Postgres. Convert legacy rows after the stored package
passes read-back verification. A missing package can use legacy bytes or
its exact Git commit under current repository authorization. No separate
data-migration command is required.

**Reason and benefit**

The warm path sends no repository bodies through SQL and reads no object
bytes. Cleanup runs through the existing deadline coordinator. Current
versions, active runs, explicit pins, and authored content retain their
protection.

**Breaking changes**

The normal application schema upgrade is required. Single-version and
skill-file responses keep their existing shape. Version lists,
current-version summaries, and test-run version summaries now contain
file names and kinds without file bodies. Diff and restore fetch
selected versions through the existing single-version endpoint. Older
server builds must not write after conversion unless they support the
new package format. The storage override and rollback limits are
documented in `doc/skill-package-storage.md`.

**Additional context**

Related work: #15731 preserves repository dependencies; #17923 changes
version-cache scheduling; #18080 changes delivery to remote Codex homes.
This change owns package persistence, acquisition, and expiry. It does
not replace those delivery or scheduler changes.

## What Changed

- Add company-scoped archive receipts, exact-commit recovery recipes,
migration deadlines, and a durable object-deletion queue.
- Add bounded archive upload, read-back verification, extraction,
concurrent request coalescing, and verified local caches.
- Convert old snapshots and versions automatically. Keep legacy bytes
when verification fails.
- Recover missing files through current source authorization and the
original commit. Validate repository identity, file hashes, modes, and
the aggregate hash.
- Expire unused recoverable Git packages and native assets after 30
days. Preserve active pins and the durable copy of authored or unproven
content.
- Keep warm preparation queries metadata-only. Bound file digest caches
and avoid repeated body hashing when file identities are unchanged.
- Extend the existing storage contract with cancellation. Register
package work with the existing coordinator. Optional conversion retries
retain the original bytes and may wait for ingress without blocking
sleep.
- Add regression tests, operating guidance, a deep trace, benchmark
receipts, and an optional maintenance command.

## Verification

### Final master integration and review repair — October 29, 2026

Current head: `a6065424249e763dfeac37161f1b52a9f25bd9b7`. Commit
`f8bbdd3dbd` resolves the conflicts with master `76a796e964`; the final
two commits repair the Apex deletion findings below.

- Keep both package-maintenance and version-retention notifications in
their write transactions.
- Keep the shared run-pin predicate and master's controller-lease
deadline metadata. Package expiry reads the updated protected-version
result.
- Preserve every master migration entry. Generate migration **0415
(`0415_zippy_zemo.sql`)** after master's 0414. Its skill SQL is
byte-for-byte unchanged from the previously tested idempotent migration.
Retain only the five newest schema snapshots. Migration numbering and
safety checks pass.
- Repair failed file/folder deletion: preserve a complete temporary
subtree until transaction commit, restore on upload or SQL failure, and
restore callback failures before releasing the mutation lock. Both
PostgreSQL regressions reproduced missing files before the fix and now
pass. They cover binary data, executable permissions, empty folders,
unlisted files, unchanged database/version rows, successful retry after
upload-claim expiry, and historical reads.
- Reconcile lost commit replies under the same advisory and row locks.
Check the saved version and source identity before restoring. Keep
committed deletions intact and preserve backups when the database
outcome is unknown or a newer edit exists. Four real-PostgreSQL outcome
tests pass; the prior code failed three of them.
- Full workspace typecheck and production build pass on the final
commit. All **204** focused PostgreSQL package/cache/retention/authoring
and filesystem-boundary tests pass, with none skipped.
- Apex review **18 passes 5/5**, with zero new comments on the exact
final head. All **34** review conversations are resolved.
- [Final-head
CI](https://github.com/paperclipai/paperclip/actions/runs/40914116508)
completes successfully on attempt 2. The head has **55 successful
checks**, two neutral reports, two skipped checks, and a successful Snyk
status. No checks are pending or failing. All eight browser shards pass.
- Runner Vitest 2/2 timed out once in the unchanged ACPX
controller-replacement fixture. The same job passed on the preceding
commit. One unchanged CI retry passes all five ACPX recovery cases,
**2,253** Runner package cases and **126** native server integration
cases; their standard one and five skips remain. The initial timeout's
root cause is not established. Local diagnosis also hit the fixture's
eight-second session.open timeout with an authenticated Runner; no local
Runner pass is claimed.
- Both full local `pnpm test:run` attempts were stopped when Apex
identified deletion defects and source repair became necessary; neither
is claimed as a passing full local run. The final **204** focused tests,
full workspace typecheck, and production build pass. Full-suite
acceptance uses the final-head CI jobs. The superseded merge-head CI
included runners terminated by shutdown signals; each source repair
started a new full run.

- Auto-merge is enabled for squash merge. The final head is
conflict-free and all checks pass. GitHub's active master ruleset still
requires a code-owner approval for the dependency files. The existing
review requests are with `devinfoley`, `nickyleach`, and `forgottendev`;
no policy bypass was used.
- The earlier hosted stress and full native receipts below remain tied
to `a2e597d800`; they are not relabeled as a deployment of this final
integration. The earlier local PostgreSQL startup failure remains
recorded below; PostgreSQL ran successfully for this integration
verification.

### Full native skill execution — October 29, 2026

The full native-agent acceptance gap is closed on candidate
`a2e597d800eb989d565780e7aa947d5368676444`. Two qualifying native turns
used OpenAI API / `gpt-5.4-mini` in real Daytona sandboxes, on the same
disposable hosted TEST tenant as the stress test. A follow-up on the
same task consumed a newly saved skill version.

The task text asked for the assigned skill workflow. It did not contain
the proof nonce or expected receipt. The assigned authored skill
contained a Python verifier and a random package-local nonce. The agent
read the actual installed skill files and executed that verifier against
the imported, automatically converted 1,360-file repository package. It
checked the repository file count and reference/binary bytes, and
directly executed the repository's executable probe. Independent
verification matched the emitted JSON to the nonce and expected byte
hashes. The agent persisted the same receipt as its task response;
native finalization committed, workspace finalization succeeded, and
task state became Done.

| Qualifying turn | End-to-end run time | Recorded skills.prepare |
Repository files | Binary hex | Shell receipt SHA-256 |
|---|---:|---:|---:|---|---|
| Initial task | 47.881 s | 1822 ms | 1360 | `00ff07800a` |
`a1deb30dc095f2306c7aa9cc5ec396da12c68961cf5b809f4d7c20f7fc68493b` |
| Corrected follow-up, new nonce/version | 37.974 s | 377 ms | 1360 |
`00ff07800a` |
`dbcdc33fa1376b4edc136feae1cdf770560a73292467e93619efdda20e4282a7` |

Both successful shell receipts reported `probe: STRESS18082` and
`assetSha256:
e8ea7a2f15f7bb3b332a46adbef848465884c8996595aa234748befd544d1f3a`. Their
proof nonces, selected version IDs and receipt hashes differed, and both
ran on the same task. These are observed timings, not latency SLOs; they
include real provider work. The service stress-test timings remain a
separate measurement boundary.

Earlier failures remain in the evidence:

- The original five-minute Daytona allocation timeout never reached
model execution. After normal owned-stack restore, its durable cleanup
record was `expired` / `success`. The cause of that original allocation
timeout remains unknown; a later success establishes recovery only.
- Initial continuation setup used unsupported generic agent-update
fields and returned HTTP 409 before dispatch. Setup was corrected to use
the supported skill-assignment and versioned instruction commands.
- The intervening completed native turn read repository bytes and ran
the probe, but omitted the verifier JSON under the older test agent's
overly-specific instruction file. Its strict receipt validator failed;
it is not counted as a qualifying turn. Correcting the test agent's
instruction file and sending an explicit task-scoped wake produced the
qualifying follow-up above.
- The pinned application's immutable rate card omits `gpt-5.4-mini`, so
completed usage triggered the normal block-on-unpriced budget policy.
Supported append-only accounting corrections estimated the three
completed runs using the [official OpenAI model rate
card](https://developers.openai.com/api/docs/models/gpt-5.4-mini): $0.75
input, $0.075 cached input, $4.50 output per million tokens, assuming
standard processing. Estimates totaled 12.482655 cents. Original usage
and missing-price records are preserved; the $1 hard stop and
unpriced-usage guard stayed active.
- Model shell attempts included a missing `xxd` command and incorrect
guessed file paths. Those failed calls and subsequent successful actual
verifier calls remain visible in complete event streams. No
zero-tool-error claim is made.

No product source changed during this continuation. The previously
measured contention, cold-start and memory limits remain. The later
merge resolution is covered by the separate integration checks above;
these hosted receipts qualify their recorded candidate.


### Hosted stress test — October 29, 2026

**Hosted acceptance:** full native-agent execution and a changed-skill
follow-up pass on the recorded hosted candidate. The final integration
section above records the subsequent conflict resolution and its
separate checks.

Tested baseline `929e840595c8933ca485ac7a2e99413e1e11ca64` and this
exact candidate sequentially on one disposable TEST tenant, with
external Neon PostgreSQL and actual S3. The fixture contained **1,360
files / 22.4 MB / ten skills sharing one repository**. Measurements
cover deployed skill-preparation and native-context services in an
adjacent diagnostic process, not a complete agent turn. The container
had **24 vCPU / 24 GB**.

| Warm workload | Median before → after | p95 before → after | DB
returned value bytes/preparation before → after |
|---|---:|---:|---:|
| One skill, serial | 1,060 → 125 ms | 1,303 → 155 ms | 22,610,389 →
1,771 |
| One skill, four concurrent | 4,415 → 297 ms | 5,143 → 328 ms |
22,610,389 → 1,771 |
| Ten skills, serial | 5,723 → 535 ms | 6,057 → 600 ms | 224,739,030 →
10,671 |
| Ten skills, four concurrent | 24,410 → 1,986 ms | 24,833 → 2,221 ms |
224,739,030 → 10,671 |

- Candidate warm preparation performed **zero S3 GETs**. Ordinary-pool
queries fell from 16 to 9 for one skill and 61 to 9 for ten, excluding
dedicated lock connections. DB numbers measure returned values before
Drizzle decoding, not wire bytes. Baseline/candidate sample counts were
30/100, 32/100, 5/50 and 8/48 respectively; the small baseline ten-skill
samples are comparative evidence, not SLO estimates.
- **Application-UID soak:** 800 ten-skill preparations over 10.3
minutes, four workers paced by 2.5 seconds; **zero failures**, median
**559 ms**, p95 **775 ms**, p99 **910 ms**, zero warm S3 reads. All 726
accompanying health/company-list requests returned HTTP 200. Their
end-to-end p95 was 569/502 ms, including network latency. The probe
overlaps most of the soak. Soak DB-response capture was limited to its
first 60 seconds.
- **Important remaining costs:** an unpaced 16-way ten-skill burst had
p95 **8.32 seconds** because company-level preparation serializes. Cold
preparation took approximately **1.1–2.5 seconds** for one skill and
**7.4–10.0 seconds** for ten. Ten-skill diagnostic RSS remained about
**1.17 GiB** (baseline 1.18 GiB), and the soak peaked at **1.35 GiB**. A
concurrent whole-container sample was 3.45 GB. This does not establish
suitability for a small-memory host or eliminate per-entrypoint native
asset duplication.
- **Upgrade without a manual data migration passed:** legacy snapshot
JSON shrank from 22,465,691 bytes to `[]`, with a stored package and
zero pending conversions.
- **Recovery passed:** sixteen concurrent cold requests coalesced into
two S3 GETs; full bytes/binary/executable modes matched; same-size
corruption repaired; an injected storage failure preserved warm access
and failed cold access safely. A disabled public-Git source denied
recovery, and after the actual five-minute backoff its exact SHA
reconstructed all 229 files. Cross-company descriptor access was denied.
Private-Git credential revocation was not tested.
- **Retention passed:** reclaimed 27,754,496 allocated cache bytes,
deleted an unreferenced package row and both objects, preserved aged
authored history, and expired/reacquired reconstructible Git content. A
version pinned by the failed native run was correctly retained. Only
owned fixture timestamps were aged.
- **Browser UI passed:** authored skill creation, saved edit surviving
reload, version history, and restoration of earlier content.
- **Initial native attempt (superseded by the qualifying turns above):**
one real run failed at Daytona allocation after five minutes
(`setup_failed`), before any model execution. Provider cleanup also
reported failure. That attempt does not count as a passing native turn.
Its receipt and all evidence remain preserved; its cleanup later
recorded success after normal restoration.
- Initial A/B commands ran as SSH root. Diagnostic-owned lock/cache
permissions caused one setup HTTP 500 and the first UID-1000 soak to
fail; exact fixture paths were repaired. The final soak, retention and
recovery ran as application UID 1000. A fresh UID-1000 one-skill batch
also passed (30 samples, median 116 ms, p95 321 ms, 1,771 DB value
bytes/preparation). Earlier harness-export, SSH, npm provenance, backoff
and UI-wait failures remain recorded alongside their successful retries.
- Private evidence includes raw timings, assertions, HTTP probes,
screenshots and failure/cleanup receipts. Initial failed-campaign
cleanup suspended and retained the owned TEST tenant and released the
shared QA account lease at **15:22:41 UTC**. The tenant was subsequently
resumed under its existing ownership for the native execution
qualification above. Final cleanup completed at **15:53:09 UTC**: the
account lease is released and the tenant is suspended/retained under the
original October 30, 12:24:52 UTC retention deadline. Its separate stack
lease remains held; no active or pending-cleanup environment lease
remained. The original retention window is deliberately preserved even
after successful continuation.

- Earlier hosted-tested head:
`a2e597d800eb989d565780e7aa947d5368676444`, integrated with master
`929e840595`. The earlier merge conflicts were resolved at this head.
[Current-head
CI](https://github.com/paperclipai/paperclip/actions/runs/40884087629)
passed; all checks are complete with 55 successes, two neutral reports,
two skips, and no failures. [Apex review
15](https://github.com/paperclipai/paperclip/pull/18082#issuecomment-6480257258)
passed 5/5 with zero findings on this exact head. All review
conversations are resolved.
- Ten real-filesystem boundary cases and server typecheck pass locally
for the security fix. Coverage includes cache roots, descendants,
individual files, symlink aliases, recursive ancestors, a future
concurrent cache fill, and allowed sibling directories. [Current-head
private-project integration
tests](https://github.com/paperclipai/paperclip/actions/runs/40884087629/job/123483306405)
passed all 29 cases, including authorized and unauthorized imports of a
cached private package. Full typecheck, build, server suites, all eight
browser shards, and Runner checks passed in current-head CI. Local
database tests could not run because PostgreSQL could not initialize;
this limitation is recorded below.
- The earlier generated migration preserved the newest five snapshots.
The final integration renumbers its unchanged SQL to 0415. Migration
safety/numbering checks pass. Its SQL is byte-for-byte identical to the
previously verified idempotent skill migration; repeated application on
this latest schema was blocked by the same local database startup
failure.
- The broader verification before the final migration-only rebase passed
1,025 package, lifecycle, remote-launch, native-session, and
asset-retention tests, 52 focused UI tests, full workspace
typecheck/build, token gates, and map checks. The linked complete CI run
passed before the latest focused fixes.
- A disposable MinIO instance passed multipart upload, verified
read-back, and cold restoration of an 18 MB archive.
- Twenty warm samples with a synthetic 1,349-file, 22.8 MB repository
retained five metadata queries and zero object reads. Median preparation
was 256 ms for one selected skill and 691 ms for ten, with 5 ms emulated
network delay each way. These are local measurements, not production
latency guarantees.
- No production instance was changed. Earlier verification failures and
their disposition are retained below.

<details>
<summary>Earlier failures, review findings, fixes, and rerun
evidence</summary>

- The first CI pass found a stale inventory-span expectation and a
missing repository-read span. Both are fixed; all three observability
tests pass. One browser test copied page text into its YAML parser after
reload; its first unchanged-head retry passed, but the race recurred
after rebasing. The test now waits for the renamed URL and completed
reload before copying the editor, retaining its original assertions. The
earlier full local suite did not complete cleanly: it reported setup
timeouts and assertions, while isolated relevant reruns passed. The
complete current-head CI run closes that broad verification gap. No
production instance was changed.

- The first Apex review scored 2/5 and identified six issues. The fixes
protect authored history even when it shares an in-flight Git upload,
recheck upload expiry during orphan deletion, bound failed-upload
cleanup, retain per-version kind/encoding metadata, add sanitized
failure diagnostics, and correct the migration guide. The final focused
run passes all 140 tests; the preceding eight-file run passed all 149
tests, including local storage and S3 diagnostics. Server typecheck and
migration safety/numbering checks pass after the fixes. The second Apex
review scored 1/5 and found unbounded maintenance storage calls and
mismatched package/Git batch limits. Those are fixed with a 30-second
cancellable cleanup deadline, shared 100 MiB/10,000-file scanner and
storage limits, and bounded Git read batches. The proposed manual
lockfile edit was rejected with evidence of the enforced CI-owned
lockfile policy. All 19 storage/recovery unit tests and server
typechecking pass. The new database-backed cleanup/conversion tests were
locally skipped because the host exhausted its PostgreSQL semaphore
capacity (confirmed directly by initdb). That gap is closed by
current-head CI: [package-store
tests](https://github.com/paperclipai/paperclip/actions/runs/40771320775/job/123109300889)
pass all 18 cases and [package-worker
tests](https://github.com/paperclipai/paperclip/actions/runs/40771320775/job/123109301157)
pass all 7, with no skips in either suite. The corrected YAML browser
shard passes. A separate planning-fixture test failed its cleanup DELETE
with an activity-log foreign key violation after its behavior assertions
passed; its one unchanged-head retry passed. The third Apex review
scored 1/5 and identified a one-slot pool deadlock, projected path
limits, cleanup holding the preparation lock during archive reads, and a
publication/expiry race. These are fixed with transaction-local reads,
prefix-aware bounds, isolated verification, and shared package row locks
with a publication grace. The separate Paperclip review also found stale
historical inspection, eager history downloads, and conversion retries
blocking sleep. Those are fixed. Local PostgreSQL capacity recovered:
the new database regressions ran, including the one-slot fork, paused
storage during a warm turn, and a locked publication during expiry. The
expanded run passed 144/145 tests; its remaining assertion still
expected file bodies from the version list and was updated to fetch the
selected version. The corrected company-skill suite passes all 81 tests,
and the final package/projection run passes all 27. All 52 focused UI
tests pass after updating their query-provider fixtures; the first UI
run exposed a fixture remount, and the follow-up exposed an editing
syntax error, both corrected. Full workspace typecheck and build, token
gates, and map checks pass. The sandbox initially blocked the build
tool’s local IPC socket; the authorized build rerun passed. Master then
claimed migration 0401. The branch is rebased on f8226b0515, the
unchanged SQL is regenerated as 0402, and repeated application passed
twice on the combined schema. Post-rebase workspace typecheck passes.
All 1,025 package, lifecycle, remote-launch, native-session, and
asset-retention tests pass on the combined head. All CI checks passed
for head 9d80a3b52d. The fourth Apex review scored 3/5 and found a
save/edit lock-order cycle, a retry retaining an uncommitted source ID
in its recovery recipe, and a summary read racing conversion. Commit
b7d9237c6a fixes all three. The new concurrency and retry tests pass
within all 61 package/source tests, and server typecheck passes. Master
then claimed migration 0402. The branch is now rebased on 65fa1cd617 and
the unchanged SQL is regenerated as 0403. All 68 package, source, and
worker regressions, server typecheck, migration safety/numbering, and
repeated migration application pass on current head 62553671a0.
Subsequent verification and review outcomes are recorded below.


- The fifth Apex review scored 4/5 and found cached version reads
unnecessarily depending on object storage and conversion reads missing
the coordinator cancellation signal. Commit 1eb90d80be fixes both:
cached reads verify bytes without the preparation lock; manifest and
archive reads carry cancellation through conversion. The first focused
run passed 70/71 tests, including all three new cases, but the existing
diagnostic test observed no warning. Its fixture now sets an explicitly
expired migration deadline; the final worker suite passes all nine
cases. Server typecheck passes.


- The sixth Apex review scored 4/5 with one finding: concurrent cached
inventories were unbounded. Commit 4b7589e14c gives cached inventories
and previews a two-reader/32-waiter limit independent of cold storage.
The first expanded pressure test observed a third reader before the
preview path had the same bound; both paths now share the local limit.
All 73 focused cases pass, followed by all 29 package cases after the
final overload adjustment; server typecheck passes. The separate
Paperclip review found no must-fix or should-fix issues. Its optional
suggestion to persist durability verification was considered: cleanup
intentionally verifies current external bytes before discarding the last
local authored copy, and the missing-object regression requires that
check. Shared-package eviction happens on cold fills with at most two
attempts and a ten-second budget.


- The seventh Apex review reached 5/5 with one remaining P2:
timeout-bearing manifest reads bypassed request coalescing. The security
review also found repeated archive acquisition after a cold
administrative read. Commit 79a80a39d2 shares manifest loads with
per-caller cancellation and cancels the underlying work when the last
caller leaves; coalesces identical cold version/file requests; and
publishes verified restored packages into the shared cache. Publication
uses a short nonblocking lock, while eviction verification runs outside
it. The first expanded run passed 73/74 tests and exposed a missing
originating DB for cache publication inside read-only inspection;
transaction readers now pass that owner explicitly, keeping metadata on
the transaction. The corrected run passed 75/75. A final eviction test
initially expected the shared package to remain after only its separate
runtime projection was reused; it now explicitly accesses the shared
package during verification. All 32 final package tests pass, including
independent caller cancellation, one acquisition across
concurrent/repeated reads, and warm preparation during stalled eviction
verification. Server typecheck passes.


- The eighth Apex review scored 4/5 with one finding: decoded cold
inventories outlived the storage semaphore while waiting for cleanup.
Commit 9d7921d1ec adds a separate two-response/32-waiter lifetime bound
held through cleanup, preserving the independent warm-read queue and
forwarding cancellation through eviction verification. The new
regression pauses cleanup after two responses decode, verifies a third
archive stays queued, and completes warm preparation before releasing
cleanup. All 77 package/source/worker cases and server typecheck pass.
On the preceding head, serialized server shard 6/9 was interrupted with
“The operation was canceled” after 57 seconds of tests; the log contains
no failing assertion. Fresh latest-head CI reruns that shard along with
the other gates.

- The ninth Apex review scored 4/5 with one P1: cold responses could own
both permits while waiting on a one-slot ordinary database pool held by
a queued transaction reader. Commit a94f0e409f reads caller-scoped
metadata before admission and isolates all acquisition/cleanup SQL on a
private connection. The new real-PostgreSQL regression reproduces the
deadlock against the previous head and passes with the fix; all 78
focused cases and server typecheck pass. The previous CI run completed
with 54 successful checks, including both formerly interrupted/stuck
serialized shards.

- The tenth Apex review scored 4/5 with one P1: preloading legacy bodies
before admission could retain many full repositories in queued requests.
Commit 276e353a37 removes that preload and also bounds legacy manifest
derivation/fallback, using private connections inside the I/O slot. All
80 focused tests pass; the final synchronized package suite passes all
36 tests and server typecheck passes.

- Apex review 11 returned 5/5 with zero comments on 276e353a37. While it
ran, master 8bc051245f added migration 0403, creating a metadata
conflict. Merge commit e1783764d9 integrates master and generates
0404_clumsy_leo.sql; its skill SQL is byte-for-byte identical to the
previously verified migration. The combined schema passes migration
safety/numbering, repeated SQL application twice, all 80 focused
PostgreSQL tests, full workspace typecheck, and full build. The
preceding a94f0e409f CI run was superseded and cancelled while its final
runner job was active; no old-head success is claimed for that run.

- Final acceptance: Apex review 12 returned 5/5 with zero comments on
e1783764d9. All 28 review threads are resolved. The final head has 55
successful checks, two neutral reports, two skipped checks, and no
pending or failing checks; the PR workflow completed successfully.
Superseded run 40789121902 was cancelled to free its workflow slot for
the final head; normal cancellation did not finish promptly, so force
cancellation completed it.

- October 29 conflict resolution (867c00673b): master 929e840595 added
installed-selection reuse and bundled relocation, routine cleanup
notifications, a hardened YAML copy helper, and migrations through 0413.
The merge retains those behaviors together with package read scoping and
the public-selection fast path; restricted selections still reauthorize
and the full row read rechecks scope. Generated migration 0414 retains
the existing SQL unchanged. Workspace typecheck/build and migration
checks pass. Local database verification is unavailable: initdb reports
semget / “could not create semaphores: No space left on device”; all 168
cases skipped, and repeated migration application failed at startup.
Docker returned HTTP 500 during container creation, so no Docker test
pass is claimed. Current-head CI must close this gap.

- Apex review 14 scored 3/5 with one P1: a local import could copy
cached private package files into a company-wide skill. Commit
a2e597d800 rejects cache trees and ancestors through a canonical
filesystem boundary, including aliases and ancestors checked before a
concurrent fill. Ten local filesystem tests and server typecheck pass.
The existing private-project integration case now rejects package-cache
imports for both authorized and unauthorized users. The preceding merge
commit’s complete CI run passed, and logs confirm all 168 locally
skipped cases executed successfully: package store 36, sources 35,
package worker 9, runtime selection 4, observability 3, authoring 81.

- Apex review 15 returned 5/5 with zero new findings on a2e597d800. The
separate Paperclip review found no must-fix or should-fix issues. Its
optional suggestions were considered: unexpected filesystem cleanup
failures remain visible, the durable marker cannot replace checking that
bytes still exist before deleting a last local authored copy, and
administrative history batching remains outside the pre-turn
optimization. The cache-path literal centralization suggestion is also
optional; the current producer and guard use the same directory, covered
by the new integration regression. All review conversations are
resolved.

- Earlier October 29 CI/Apex acceptance: a2e597d800 was mergeable at
that check, Apex review 15 passed 5/5 with zero findings, and all review
conversations were resolved. The hosted verification above records the
later acceptance limits and renewed conflicts. Workflow 40884087629
completed successfully, with 55 successful checks, two neutral reports,
two skips, and no pending or failed checks. The project-skill-sources
job log confirms all 29 cases ran and passed, closing the latest
integration gap. The preceding merge workflow also confirmed all 168
focused database cases ran and passed. No local PostgreSQL or Docker
pass is claimed.

</details>

## Risks

- First use after eviction can require storage or Git access. Revoked
grants, removed sources, and unreachable commits can prevent
reconstruction. Recovery never substitutes a newer commit.
- Historical packages without a proven recipe keep durable bytes.
Cleanup favors retention when a run pin cannot be interpreted safely.
- New uploads and conversion use bounded work and persisted retries.
Large initial conversions still add temporary storage and database load.
- Optional cleanup can wait until the next server wake. Supported
notifications assume one owning server process.
- Per-entrypoint native tree duplication and cold preparation cost
remain. The final ten-skill cold sample took 47.6 seconds on the shared
host.
- Generic authored edits can still hold an existing edit transaction
while a package uploads.
- Explicit file/folder deletion uses temporary disk space for rollback.
An uncertain commit with a changed skill or unavailable database retains
that backup and logs its location for recovery.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, repository tools, code
execution, and tests. The exact served model ID and context window are
not exposed in this session.

## 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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-29 12:19:37 -05:00

48 KiB

Recoverable disk cleanup

The server materializes immutable skill versions under <instance>/skills/<company>/__versions__/<skill>/<version>. These are disposable disk copies: company-scoped versions reference durable packages in the configured storage backend, or retain legacy database inventories. Normal skill resolution rebuilds missing copies, preserving binary bytes and executable flags. New Git packages can also restore the exact original commit from their saved recipe using current repository access. Authored/legacy packages without that recipe require independent external durability before eviction; unavailable storage leaves those caches intact. Shared extracted packages have a separate cold-fill capacity policy. See package storage.

Skill version caches

An hourly background sweep considers at most 50 directories per batch, yielding between batches. It starts after server readiness, and respects the existing worker shutdown, standby, and idle-drain controls. A non-current version becomes eligible after one hour without use. Current versions, active-run pins and unsettled recovery pins are retained. If an active run's version selection is unknown, that company's caches remain. Non-versioned skill directories and database snapshots are never removed by this worker.

Last-use records live separately under __version_access__. Older installations without those records receive a full one-hour observation period. Preparation, pin persistence and eviction share a company-scoped database lock across server processes. Snapshot validation and byte counting run outside that lock; cleanup rechecks current versions, run pins, last use, and directory identity before rename. Audits hold the lock through their file reads. Eviction atomically moves a cache into unique .trash-<UUID> directories; trash deletion uses its own lock so normal preparation can restore files while deletion finishes. A later sweep finishes interrupted trash removal without deleting a newly materialized version.

Controls:

Environment variable Default Effect
PAPERCLIP_SKILL_VERSION_RETENTION_ENABLED true Set false to stop future sweeps; restoration stays available
PAPERCLIP_SKILL_VERSION_RETENTION_DRY_RUN false Set true to report candidates without changing cache or access records

The internal skillVersionRetentionService supports company-scoped diagnosis, bounded continuations, cancellation between entries, and dry run. Explicit agent pins stay cached. Operators can also retain newest versions with PAPERCLIP_SKILL_VERSION_RETENTION_KEEP (default 0), or change the one-hour window with PAPERCLIP_SKILL_VERSION_RETENTION_GRACE_MINUTES (default 60). Each batch reports candidate and reclaimed allocated bytes, eviction counts, and skip reasons in the server log. Compare volume usage before and after to verify physical savings; concurrent writes and filesystem allocation can affect the net change. A dry run does not start the legacy observation period.

Roll out with dry run enabled, inspect candidates/skips, then enable removal. Recovery archives and runner backups have separate ownership and retention; this worker never scans or removes them.

Native session failover backups

After each remote turn, the server copies the sandbox's harness state (runner state plus the provider's session home, such as codex-home) to <instance>/runtime/paperclip-runner/durable-sessions/<scope>/failover-backups/current. A replacement sandbox restores the session from this copy, and an ephemeral sandbox restores it at the start of every turn. Each session keeps one copy, and a single copy can reach several gigabytes. Codex copies omit credentials, scratch directories, and data Codex downloads again on startup: remote plugin bundles under plugins/cache/<remote marketplace>, the remote plugin catalog cache, and plugin install staging. After a restore, remote plugins can be unavailable to a turn until Codex finishes downloading them.

An hourly background sweep expires the copy once the session can only resume by reopening closed work. It checks at most 200 sessions and expires at most 50 per batch, starting two minutes after readiness. A copy expires when all of these hold:

  • every issue the session ran on is done or cancelled, and closed before the window;
  • the copy itself was written before the window;
  • no run of the session is queued, running, or unfinished;
  • every native run finalization of the session is settled, with no recovery in progress;
  • no active, released, retained, or pending-cleanup environment lease references the copy;
  • the session is not executing, initializing, warm, or closing in this process, and no cleanup activation is pending in its directory.

A session whose control-plane state is missing, unreadable, or larger than 4 MB keeps its copy, because the sweep cannot identify its runs.

The sweep checks and locks these rows in one transaction, and removes the agent task session that points at the session there. A reopened issue therefore starts a fresh native session instead of resuming the expired one. The copy is atomically renamed to failover-backups/.retiring-<UUID>, and a final check looks for a run of the session. If one appeared, or the check fails, the rename is undone. Only a confirmed copy is renamed to .expired-<UUID> and deleted asynchronously. A later sweep restores an unconfirmed .retiring-* copy when current is missing, drops it when a newer current exists, and finishes an interrupted deletion. The rest of the session directory, including its control-plane state, is kept.

Environment variable Default Effect
PAPERCLIP_NATIVE_HARNESS_BACKUP_EXPIRATION_ENABLED true Set false to stop expiring copies
PAPERCLIP_NATIVE_HARNESS_BACKUP_EXPIRATION_DRY_RUN false Set true to report candidates without deleting copies or task sessions
PAPERCLIP_NATIVE_HARNESS_BACKUP_EXPIRATION_DAYS 7 Days an issue must be closed before its sessions' copies expire (minimum 1)
PAPERCLIP_NATIVE_RUNNER_EVIDENCE_RETENTION_DAYS 7 Days continuity-break archives and quarantined session state are kept (minimum 1)

Each batch that finds candidates logs them with candidate and reclaimed allocated bytes, cleared task sessions, and skip reasons.

Continuity-break archives and quarantined session state

The same sweep also removes two kinds of evidence that native sessions leave behind under the same root:

  • <session>/continuity-breaks/<epoch ms>-<UUID>/, written when a session starts a fresh provider session and moves the retired session's state aside. Nothing reads these archives again.
  • quarantine/<session>.<reason>.<epoch ms>.<pid>.<UUID>/, written when a session's state loses authority. Recovery and failed-run retry still consult these entries.

An entry's age comes from the epoch in its name, or from its modification time when that epoch is in the future. An entry is removed once it is older than PAPERCLIP_NATIVE_RUNNER_EVIDENCE_RETENTION_DAYS and its session is not executing, initializing, warm, or closing in this process. Quarantine entries created by native cleanup (<session>.identity_indeterminate.cleanup.<UUID>) are referenced from recovery history and kept. Symlinks are never followed or removed. Once a quarantined entry is removed, recovery and failed-run retry no longer see it, so a later run of that session may start a fresh provider session instead of refusing.

The evidence check runs on the sweep only when something is due: once at startup, then when the oldest retained entry, or an entry the executor has written since, reaches the window, and on the next sweep after a busy session, a failed removal, or a full batch. It only reads the filesystem and never queries the database. A dry run reports each candidate once until newly written evidence comes due.

An entry is renamed to .expired-<UUID> in its folder before it is deleted, and a later sweep finishes an interrupted deletion. An empty continuity-breaks folder is removed. Archives, quarantine entries, and leftovers count toward the batch limits of 200 entries and 50 removals shared with backup copies, and PAPERCLIP_NATIVE_HARNESS_BACKUP_EXPIRATION_ENABLED and _DRY_RUN apply to both.

Native workspace sync folders

Each native run that syncs a workspace with a remote sandbox writes a folder at <instance>/native-workspace-sync/<run>/ with its descriptor, manifest, and the workspace and git seed archives. A seed can reach more than a gigabyte. Only the same run reads the folder again: restart recovery, finalization export, board export retry, and retained workspace recovery. Later runs adopt warm workspaces from lease metadata and the shared generations cache, which this worker never touches.

A retention worker on the shared delivery-work scheduler removes a run's folder once nothing can read it. It runs at startup and at real deadlines: the earliest settled run's finish (or an orphan folder's newest file) plus the retention window, a recheck one window later for folders it had to keep, and a 15-minute retry after a failure, a held finalization lock or a leftover it could not delete. With no folders it schedules nothing; when a run then creates its folder in this server process, the worker wakes and schedules that folder's deadline. A folder named for a run id, that is a real directory and whose newest file is older than the window, is removed when one of these holds:

  • no run with that id exists;
  • the run has no valid workspace sync reference and is settled: it succeeded, failed, was cancelled, timed_out or interrupted, finished before the window, has no live process, no live controller lease and no finalization owner receipt;
  • the run is settled as above, and also:
    • its native run finalization, if any, has no lease owner or scheduled retry and is committed, or terminal_failure with no recovery state or blocked;
    • no active or escalated workspace export or owner-unverified recovery action names the run;
    • no lease of the run carries nativeWorkspaceExportResume or a cleanup in flight;
    • its referenced lease was destroyed, or no retained workspace recovery of the run is scheduled, running, awaiting evidence, queued or recovering;
    • historical recovery adoption has no pending work for the run.

Failed native runs can keep their recorded process ID after they finish. A recorded process blocks removal only while it can still be the run's own process: the run has a local lease or no lease, and the process ID or process group still exists on this host with the recorded start time (or a start time that cannot be read). A process ID from a remote sandbox, or one the operating system reused, does not block removal.

Each folder is checked in one transaction that holds the run's finalization ownership lock, so it never races a finalization or export of the same run. A held lock skips the folder. Inside that transaction the folder is renamed to .expired-<UUID>, and it is deleted after commit. The next sweep finishes an interrupted deletion. Each batch tries at most 50 leftovers, then checks at most 200 folders and removes at most 50. Leftovers and folders each continue from where the previous batch stopped, so a leftover that cannot be deleted does not block others. Deadline discovery reads run state 200 folders at a time and stops when the server drains or shuts down.

Environment variable Default Effect
PAPERCLIP_NATIVE_WORKSPACE_SYNC_RETENTION_ENABLED true Set false to stop removing folders
PAPERCLIP_NATIVE_WORKSPACE_SYNC_RETENTION_DRY_RUN false Set true to report candidates without removing folders
PAPERCLIP_NATIVE_WORKSPACE_SYNC_RETENTION_DAYS 7 Days a run must be finished, and its folder untouched, before removal (minimum 1)

Each batch that finds candidates logs them with candidate and reclaimed allocated bytes and skip reasons.

Git workspace restore points

Git cache expiration (PAPERCLIP_GIT_CACHE_EXPIRATION_ENABLED=true) normally removes a closed issue's worktree only when its remote can reproduce it exactly. With PAPERCLIP_GIT_RESTORE_POINTS_ENABLED=true, it can also remove a worktree that has unpushed commits, uncommitted or untracked files, or history-only commits, once its issue tree has been closed for PAPERCLIP_GIT_RESTORE_POINT_TERMINAL_DELAY_DAYS (default 3). For this, only the issues in the tree that use the workspace count: the source issue, and each descendant that is bound to it (execution_workspace_id, which is how a child that inherits the parent's workspace with reuse_existing is recorded) or that selects it as its current or pending existing workspace. The delay runs from the latest close time among those issues. A closed issue's worktree can therefore be removed while descendants that work in their own workspaces stay open. Any issue still bound to the workspace, and any live run, finalization, lease, recovery action or runtime service of an issue in the tree or of the workspace, still keeps it. If an open descendant later binds to the removed workspace, its next run or a comment reopens it from the restore point, as below. The workspace reaper does not compact or archive such a worktree by its normal rules while descendants are open; only expiration removes it. It also removes the worktree of an issue tree that is still open once nothing has touched it for PAPERCLIP_GIT_RESTORE_POINT_IDLE_DAYS (default 7, 0 turns this off): no issue in the tree or bound to the workspace was updated and no run for those issues or the workspace was created, started or finished in that time, and no run is still live. An interrupted removal is finished by later sweeps while the issue stays open. The workspace reaper sets a deadline for that moment, so open trees cost no recurring checks before then. When a run later finds its issue's workspace removed this way, it reopens the workspace, restore point included, before using it; if the restore fails, the run fails instead of starting in a new workspace. A comment or an agent update on the issue reopens it the same way. Before removal it saves a restore point:

  • a git bundle of commits the remote does not have, against a remote-verified base, including commits only reachable from the reflog or ORIG_HEAD;
  • deterministic snapshots of the index and working tree, including untracked files and non-default file modes. Untracked .env and .env.* files are left out, because provisioning recreates them with current secrets;
  • a separate snapshot of ignored files, such as build caches or files an agent kept out of git. It leaves out .env files, nested .paperclip-repositories checkouts (they get their own restore points), .claude/settings.local.json (saved with the workspace contents) and anything inside a regenerable directory such as node_modules or dist. A restore point with ignored files uses payload schema v2, which older servers reject instead of restoring without them. Restoring never overwrites an ignored file that already exists, and an ignored nested repository keeps the worktree.

The restore point is encrypted with a per-restore-point key wrapped by the instance secrets key, uploaded to file storage, read back and hash-checked, then activated in the same transaction that archives the workspace with a versioned recipe (paperclip.git-cache-restoration.v2). Older servers reject that recipe instead of restoring without the saved work. Under the expiration locks, the worktree must still match the captured snapshot exactly; otherwise expiration rolls back, keeps the worktree, and drops the restore point.

Nested managed checkouts under .paperclip-repositories/ get their own restore points when their checked-out branch has unpushed commits or uncommitted, untracked or history-only work. Each is stored under its checkout name, recorded in the recipe's contents, and verified against its own snapshot under the expiration locks. A nested checkout still blocks expiration when another local branch or tag has commits its remote lacks, when any reflog holds commits that are neither on the remote nor part of the checked-out history the restore point bundles, when it has objects reachable from nothing, or when its COMMIT_EDITMSG holds a draft that matches no commit in its history. A COMMIT_EDITMSG that matches a commit is accepted for every nested checkout. A nested checkout's remote-tracking refs (refs/remotes/origin/*) and their reflogs can point at commits the remote has since dropped, for example a branch deleted after a squash merge. Strict expiration keeps such a checkout. In restore point mode those commits are saved in the checkout's restore point instead, as history-only objects (the remote-tracking refs themselves are not recreated), and any change to the remote-tracking refs after capture rolls expiration back. Each restore point also records a digest of its HEAD reflog and ORIG_HEAD; under the expiration locks that history must be unchanged too, so a commit followed by a reset after capture rolls expiration back. Reopening re-clones the checkout, imports its bundle, checks out the saved commit and applies the restore point. Submodules, special git state and ignored files other than .env still keep a worktree from expiring. Restore points are refused when the file storage is s3 and the secrets master key does not come from PAPERCLIP_SECRETS_MASTER_KEY.

Isolated task directories

Tasks without a project repository run in a plain (non-Git) directory at <instance>/isolated-workspaces/<companyId>/<issueId>/. Its execution workspace row has strategy project_primary, provider local_fs and that exact path as cwd, in isolated_workspace or shared_workspace mode. Agents can leave the only copy of their work there, such as reports or generated files, because nothing pushes it anywhere.

Before this cleanup existed nothing removed these directories. The terminal workspace reaper archives a workspace only after proving delivery through Git (a merged pull request or a merged branch); for a directory that is not a Git checkout the status inspection fails, so the row was always skipped as undelivered. A workspace archived by hand keeps its directory too: artifact cleanup removes a local_fs directory only when the row says the runtime created it, and task directory rows never say so. Deleting them on archive would discard work silently, so retention saves the files first instead.

With PAPERCLIP_TASK_DIRECTORY_RETENTION_ENABLED=true the terminal workspace reaper handles an open task directory row once every issue in its tree has been done or cancelled for PAPERCLIP_GIT_RESTORE_POINT_TERMINAL_DELAY_DAYS (default 3), the same delay git restore points use. It registers that moment as its deadline, so a closed tree costs no checks before then. It removes the directory only when all of these hold, checked under the issue's source lock and the workspace lifecycle lock:

  • the source issue is still bound to the workspace (issue_not_bound otherwise), because reopening restores through that binding;
  • no run of the tree or the workspace is queued, running or unfinished, no native run finalization is unsettled, no environment lease or runtime service is live, no recovery action is unresolved and the workspace is not waiting for a reopen to be consumed (live_or_unresolved_consumer); these are the checks git cache expiration uses;
  • no other open workspace or project workspace points into the directory (closed rows for the same issue do not count);
  • the directory is a real directory, not a symbolic link (unsafe_task_directory).

It then saves a restore point of the directory: every file with its mode, every directory and every symbolic link (the link itself; links are never followed). A node_modules directory next to a package.json is left out, because installing the package recreates it. Sockets, devices and other special files keep the directory (unsupported_filesystem_entry), and so does a directory over the restore point limits (task_directory_too_many_files, task_directory_file_too_large, task_directory_too_large; 20,000 files, 64 MiB per file, 256 MiB in total). The captured snapshot is decoded once before anything is removed; a directory whose snapshot could not be restored stays (task_directory_not_restorable). The restore point uses the git restore point ledger, storage and encryption: it is sealed with a per-restore-point key wrapped by the instance secrets key, uploaded to file storage under git-restore-points/<workspace>/<generation>/task-directory/, read back and hash-checked, and activated in the same transaction that archives the workspace and records the isolatedTaskDirectoryRetention state in its metadata. It follows the git restore point retention (PAPERCLIP_GIT_RESTORE_POINT_RETENTION_DAYS, default 90), counted from the later of the tree's close and the removal, so a task that closed long ago still keeps its files for the full period. Restore points must be enabled (PAPERCLIP_GIT_RESTORE_POINTS_ENABLED=true, restore_points_disabled otherwise), and they are refused when the file storage is s3 and the secrets master key does not come from PAPERCLIP_SECRETS_MASTER_KEY.

Right before deletion the directory is read again. If anything changed since the capture, the workspace is made active again, the restore point is dropped and the directory stays (task_directory_changed). Otherwise the directory is renamed to a recorded .expired-<random> name beside it, no longer than the original name, and deleted. The archive and the removal are recorded as execution_workspace.task_directory_archived and execution_workspace.task_directory_removed activity. A removal the server could not finish is finished by a later sweep.

Reopening the workspace restores the directory before anything uses it: a comment or an agent update on an issue bound to a closed isolated_workspace, or the next run of the issue in either mode. The files are written to a .restoring-<random> staging directory beside it, whose name is no longer than the original, verified against the saved digest and renamed into place. An empty directory in the way is replaced; a non-empty one that does not match keeps the workspace closed and fails the reopen instead of overwriting it. After a successful reopen the restore point is superseded and removed; the next retention saves a new one. When the restore point has already expired, the reopen starts with an empty directory and posts a system comment on the issue saying the earlier files are no longer available.

Orphaned task directories

The reaper also walks isolated-workspaces/ once per server start, at most 8 issue directories per sweep, continuing from that position in the next sweep. It never follows symbolic links. For each directory:

  • an open execution workspace pointing at or into it keeps it (the reaper handles that row as above);
  • a closed workspace row that its issue is still bound to is handled like an open row: the directory is saved in a restore point on that row and removed after the terminal delay, and reopening restores it (outcome preserved_and_removed);
  • any other directory is an orphan: its workspace row is missing, or archived or cleanup_failed without the issue bound to it. An orphan is removed when its issue is gone, or every issue in the tree has been closed for PAPERCLIP_TASK_DIRECTORY_ORPHAN_GRACE_DAYS (default 7), and nothing in it changed during that period, and no project workspace, live runtime service, unsettled run, finalization, live lease or unresolved recovery action of the issue refers to it. Leases count when they belong to a run of the issue tree, to an issue in the tree, or to any workspace row of the directory. An orphan is not saved first: no workspace row can reopen it. If its issue is reopened later without another workspace, its next run starts in an empty directory.

After that first walk, the reaper looks only at the directories that changes point to: a workspace row that becomes archived or cleanup_failed, or an issue that becomes done or cancelled, sends the reaper to that issue's directory and the directories of its ancestors, whose trees may have just closed. A directory it kept is checked again at its own deadline: the end of the grace period or terminal delay, or an hour later when a lock was busy or a live run, lease, service, finalization or recovery action still used it. A directory whose issue is open waits for the issue to close. With nothing pending it registers no work. The walk also deletes leftover .expired-* and .restoring-* directories older than an hour; when deleting an orphan's trash fails, another walk is scheduled an hour later.

Controls and logs

Environment variable Default Effect
PAPERCLIP_TASK_DIRECTORY_RETENTION_ENABLED false Set true to let the reaper handle task directories
PAPERCLIP_TASK_DIRECTORY_RETENTION_DRY_RUN true Set false to save and remove; a dry run only reports candidates and needs no restore points
PAPERCLIP_TASK_DIRECTORY_ORPHAN_GRACE_DAYS 7 Days an orphan's issue tree must be closed and its contents unchanged before removal (minimum 1)

Each open or pending workspace logs event: "execution_workspace.task_directory_retention" with its outcome (removed, dry_run or skipped), skip reason, candidate bytes and reclaimed bytes. Each directory the walk inspects logs event: "execution_workspace.task_directory_cleanup" with its outcome (removed, preserved_and_removed, dry_run or skipped), reason and bytes. Each reopen that restores a directory logs event: "execution_workspace.task_directory_restore". A sweep saves and removes at most 8 open task directories; the rest continue in the next sweep. A workspace that was skipped is inspected again only when it or its issue changes, or at its retry deadline, so skipped workspaces do not use up that limit. Roll out with the dry run, inspect the candidates and skip reasons, then set the dry run to false.

Nested project repository worktrees

Extra project repositories that have no configured local folder are no longer full clones inside every task. Each task's .paperclip-repositories/<name>-<key> checkout is a Git worktree of one shared clone per company, project and repository URL: the managed project checkout for that URL under the instance's projects/ directory (the same clone that main task worktrees use when the repository is a task's primary source). Objects are stored once in that clone, so a new task adds only its working files. Repositories with a configured local folder are still seeded as independent copies. Existing standalone nested clones keep working unchanged until the optional conversion below turns them into worktrees of the shared clone.

Each nested worktree checks out its own branch, paperclip/<id>/<branch>, where <id> derives from the worktree path and <branch> is the remote's current default branch (refreshed before each new worktree) or the configured branch ref, tracking origin/<branch>. Reopening derives the upstream from the saved source ref, not from the branch name. A tag or commit ref is checked out detached, as before. Branch names therefore never collide between tasks. When a checkout kept under .paperclip-runtime/detached-repositories/ still holds that branch, the new worktree uses paperclip/<id>/r<n>/<branch> instead, so the retained work is never reset. git pull works as before; pushing needs an explicit destination such as git push origin HEAD:<branch>. Adding the worktree, fetching and removing registrations are serialized per shared clone. Each new worktree first fetches with --prune, so a branch deleted upstream is rejected as a fresh clone would reject it. Server-side Git commands on the shared clone run with hooks disabled, so hooks an agent plants there never run in the server.

Branches and reflogs of a worktree live partly in the shared clone. Expiration and restore points therefore scope their checks to what belongs to the nested worktree: its HEAD, HEAD reflog, ORIG_HEAD, index and working tree (stored per worktree) and every branch under its own paperclip/<id>/ prefix together with that branch's reflog. Those must be on the remote or saved in a restore point exactly as for a standalone checkout. Checking out any other branch, stashes, unsupported refs or custom local config in the shared clone still block expiration. Branches outside the prefix, other tasks' branches and their history stay in the shared clone and never block or get deleted. Commits that existed only in the nested worktree's HEAD reflog are written back into the restored worktree's HEAD reflog, so they stay protected after reopening.

Expiring a task removes the nested worktree's registration from the shared clone and deletes its own branch only at the verified recorded commit. Reopening recreates the worktree from the shared clone (re-cloning the shared clone if it is gone) on the same branch and commit and applies its restore point. Deleting or archiving a task removes the registration of each of its nested worktrees and deletes their branches only when every commit is on a remote-tracking ref; an unpushed branch stays in the shared clone. Moving a nested worktree to .paperclip-runtime/detached-repositories/ when its repository leaves the project repairs its registration.

Converting existing standalone nested clones

Tasks prepared before nested worktrees existed still hold a full standalone clone in each .paperclip-repositories/<name>-<key> checkout, often of the same large repository many times over. With PAPERCLIP_NESTED_CLONE_MIGRATION_ENABLED=true, the terminal workspace reaper converts each such checkout in an open task into a worktree of the project's shared clone and deletes the standalone clone. It holds the source lock and workspace lifecycle lock that git cache expiration holds, the per-shared-clone lock that nested worktree creation holds, and the expiration lock files (index.lock, HEAD.lock and the current branch's ref lock) in the clone, so no run, expiration, reopen, restore or other conversion against the same shared clone can interleave. A checkout is converted only when:

  • no run, native finalization, environment lease or runtime service is still live for the workspace, and it is not waiting for a reopen to be consumed (the issue may be open);
  • its name is the managed checkout name of one of the project's repository workspaces (unknown_managed_checkout otherwise), that repository has no configured local folder (local_source_checkout), and the clone's origin is exactly the repository URL (foreign_origin);
  • the project has a verified shared clone for that URL (no_shared_base, shared_base_repo_mismatch) whose local config runs no filters or commands (unsupported_base_git_config);
  • the clone holds only branches, tags and origin remote-tracking refs, no stash (nested_clone_has_stash; stashes are not converted, because refs/stash would be shared by every worktree of the shared clone), no worktrees of its own, no custom hooks, no local config beyond the stock keys, origin and branch tracking, no info/exclude pattern the shared clone lacks (unsupported_info_exclude), no in-progress merge, rebase or similar state, no hidden index flags, and no commit that only another branch's reflog holds (unique_branch_reflog_commits);
  • nothing a restore point refuses (submodules, filtered paths, special files, untracked nested repositories, oversized contents or history).

The conversion fetches the shared clone, then brings the clone's history in under the checkout's own prefix: the current branch becomes paperclip/<id>/<branch> at the same commit, tracking origin/<branch> (or the branch's configured origin upstream); every other local branch whose tip the shared clone's remote-tracking refs do not contain becomes paperclip/<id>/<branch>, every such tag becomes paperclip/<id>/tags/<tag> at the tagged commit, and every such origin remote-tracking ref (for example one whose branch was deleted or force-pushed upstream) becomes paperclip/<id>/remotes/origin/<branch>. <id> derives from the final checkout path, so later expiration and release treat those branches exactly like a nested worktree's own branches: they block expiration until pushed or saved and are never deleted while unpushed. A restore point of the clone, captured against commits both repositories share and preserving those branch tips, its HEAD reflog and ORIG_HEAD, is imported into the shared clone. Other tasks' branches and worktrees are never touched; a name that already exists in the shared clone skips the checkout (nested_clone_ref_conflict).

The new worktree is added at <name>.clone-migrating beside the clone, the restore point is applied (index, worktree changes, untracked and ignored files, file modes), the HEAD reflog and ORIG_HEAD are copied, and regenerable directories, untracked .env files and .claude/settings.local.json are moved in by rename. The two must then match exactly (HEAD, index, worktree, ignored files, modes, reflog, branch and every created ref). Any difference or failure before the swap moves the items back, removes the new worktree and the created branches, and leaves the clone untouched. The swap renames the clone to <name>.clone-standalone, moves the new worktree into <name> with git worktree move and runs git worktree repair in the shared clone. Then one database update records the worktree in a saved restoration recipe that lists the checkout, and the old clone is renamed to <name>.clone-trash and deleted.

Before it changes the shared clone, the conversion records nestedCloneMigrationPending.<name> in workspace metadata (shared clone, HEAD, created refs, moved items and phase). The next pass uses it after a crash: before the swap it moves items back, removes the new worktree and the created branches, then converts again; once the clone has been renamed aside it finishes the swap and deletes the old clone, so work done in the converted checkout after a crash is kept. A state that matches neither (for example a new checkout created at <name> while the clone was aside) is left alone with nested_migration_recovery_conflict. The reaper also finishes a pending record after the workspace is archived; if the task worktree is already gone, it forgets the record, removes the stale registrations and deletes only created branches whose commits are on the remote. Expiration treats the .clone- names as unexplained files and keeps the workspace until the conversion settles. A dry run reports a pending record without acting on it.

Each attempt logs event: "execution_workspace.nested_clone_migration" with the checkout name, outcome (migrated, recovered, rolled_back, dry_run or skipped), skip reason, new branch, candidate bytes (the clone's .git) and reclaimed bytes. A workspace is inspected again at most hourly, and a retry deadline it registered survives later sweeps during that hour. A retry deadline is registered only while a pending record exists or the skip comes from a live consumer, a busy lock or a changed workspace row; other skips, including a change caught by verification, wait for a later sweep. The reaper looks only at checkouts named after the project's configured repositories (plus pending records), never at arbitrary directories, and attempts at most 8 per workspace per sweep. Pending recoveries go first, and the rest rotate across sweeps. While a rotation has not yet visited every checkout it registers a retry deadline; after a full rotation without progress it waits for a later sweep, until a conversion succeeds or the set of candidate checkouts changes. PAPERCLIP_NESTED_CLONE_MIGRATION_DRY_RUN=true reports candidates without changing anything and registers no deadlines.

Reopening the issue, or the next run that recreates the worktree, imports the bundle, re-adds the worktree at the saved commit, applies the restore point and verifies the result. The bundle is always imported, so history-only commits are present again as objects in the repository; they are not recreated as branches or reflog entries, and the restore point keeps them until it expires. A draft commit message left by an aborted commit still keeps the worktree from expiring, because restore points do not save it. A worktree that already matches is only marked applied, and a worktree that diverged is never overwritten: the restore fails closed and can be retried. A missing or corrupt restore point also fails closed.

When a restore point has expired, through retention or on a nested checkout, reopening starts fresh instead: the worktree is recreated on its branch (the branch tip if the repository still has it, otherwise a new branch from the base ref), the project's nested repositories are cloned fresh before the provision command runs, the saved recipe is dropped, and a system comment on the issue warns that unpushed and uncommitted work from before the cleanup is no longer available. The same warning is returned with the reopen result and recorded on the execution_workspace.reopened activity.

Restore points are encrypted saves of the parts of a git worktree that its remote cannot reproduce: unpushed commits and uncommitted files. They let a closed issue's worktree be removed and restored later. The table execution_workspace_restore_points tracks each one with its storage key, hash, size and status (pending, active, expiring, superseded, delete_requested, deleted). The objects live in the instance's configured file storage (local_disk or s3).

An active restore point's delete_after is either its expiry time or empty while its source issue tree is open. A database trigger on issues keeps this current: when an issue's status or parent changes, or an issue is deleted, it marks every active restore point whose source issue is that issue, its old or new parent, or any of their ancestors as due now, in the same transaction. A retention worker on the shared delivery-work scheduler runs at startup, when issue changes settle while restore points exist, and at real deadlines: the earliest expiry, the end of an abandoned capture's grace period, or a failed deletion's retry. With nothing due, it schedules nothing, and open trees cause no recurring checks. At review it checks the source issue tree:

  • Tree still open: clear delete_after and wait for the next issue change.
  • Tree done or cancelled: expire PAPERCLIP_GIT_RESTORE_POINT_RETENTION_DAYS (default 90, minimum 1) after the tree's latest close, immediately if that time has passed. Expiry first moves the row to expiring, runs the expiry hook, and only then requests deletion. An expiring row's object is never deleted; after a restart or a hook failure the hook is retried, after 15 minutes when it failed.
  • Source issue gone: delete.

Deleting an issue or a company marks its restore points delete_requested in the same transaction and notifies the worker, so their objects are removed promptly. A pending restore point older than one hour was abandoned mid-capture and is deleted. superseded and delete_requested restore points have their stored object removed, then become deleted once the storage provider confirms the object is gone. A storage failure is recorded in last_error and retried after 15 minutes. Work is processed in batches of 50; a full batch continues immediately.

Rows have no foreign keys, so they outlive a deleted workspace, issue or company long enough to remove the stored object. Each run that changes anything logs its counts.

Isolated repository sources

For a while, workspace preparation gave some isolated git_worktree workspaces their own full clone of the project repository under instances/<id>/isolated-repository-sources/<companyId>/<issueId>/checkout, with the issue's worktree inside it. Workspace metadata marks these with isolatedRemoteSource. New workspaces no longer get one; they are worktrees of the project's shared managed clone. Each old clone still holds a full .git and an unused default-branch checkout.

With PAPERCLIP_ISOLATED_SOURCE_REHOME_ENABLED=true (and git cache expiration enabled), the terminal workspace reaper moves each such open workspace onto the shared clone and then deletes its private clone. It runs under the same source lock, workspace lifecycle lock and Git lock files as git cache expiration, so no run, expiration, reopen or restore can interleave. A workspace is moved only when:

  • no run, native finalization, environment lease or runtime service is still live for it, and it is not waiting for a reopen to be consumed (the issue may be open);
  • the project has a shared managed clone for the same repoUrl;
  • its worktree sits at the standard .paperclip/worktrees/<branch> location, and the branch is absent from the shared clone, points to an ancestor of the worktree's HEAD there, and is not checked out in another of its worktrees;
  • the shared clone's local config sets no filter drivers, includes, attribute files, worktree config or command settings (core.fsmonitor, core.hooksPath, core.sshCommand and similar), and it has no info/attributes, because the move checks out files there;
  • its Git state is transferable: no stash or replacement refs, no in-progress merge, rebase or other worktree-specific state, no hidden index flags, no shallow history, and nothing a restore point refuses (submodules, filtered paths, special files, untracked nested repositories). The transfer is local, so it uses much larger restore point limits than uploads do: 500,000 files, 1 GiB per file and in total, a 1.5 GiB bundle and 1,000,000 commits.

The move captures a restore point of the old worktree against commits both repositories share, imports it into the shared clone, creates or fast-forwards the issue branch, adds the new worktree at the same HEAD and applies the restore point (index, worktree changes, untracked and ignored files, file modes). The HEAD reflog and ORIG_HEAD are copied. What restore points leave out is moved by rename on the same filesystem: regenerable directories such as node_modules, untracked .env and .env.* files, .claude/settings.local.json, and each nested .paperclip-repositories/* checkout as a whole. A standalone nested clone moves as is. A nested checkout that is a worktree of its own shared clone moves too, and the move runs git worktree repair in that clone so its registration follows the new path. Its paperclip/<hash>/ branch is renamed to the prefix of the new path, because that prefix proves the checkout owns the branch (a rollback moves it back, repairs it and renames the branch back). Items inside a moved nested checkout, such as its node_modules, move with it. Any other ignored file that a restore point does not save keeps the workspace in place.

The new worktree must then match the old one exactly (HEAD, index, worktree, ignored files, modes and reflog), and every moved item must exist. Only then does one database update point the workspace at the new path, drop isolatedRemoteSource, rewrite a saved restoration recipe to the shared clone, and record isolatedRepositoryRehome ({ at, from, to, cleanedAt }). Any failure before that update removes the new worktree, moves items back, restores the branch, and leaves the old worktree untouched. Before it changes the shared clone, the move records isolatedRepositoryRehomePending (target, branch, previous branch tip and planned moves). If the server stops before the update, the next attempt uses that record to move items back, remove the new worktree and restore the branch, then starts again. A dry run reports such a record without undoing it. If an item exists in both places (for example a process recreated .env in the old worktree), the rollback stops with rehome_rollback_conflict and keeps the new worktree and the record, so neither copy is lost; later attempts retry the rollback once the conflict is resolved. A new worktree is removed only when the move created it: a target that appears from elsewhere is never deleted.

After the update, the old worktree is removed (its ownership is proven by git rev-parse or, when its registration in the clone was already pruned, by its .git pointer into the clone's worktrees directory). The clone is then renamed to trash and deleted when nothing else needs it and everything in it is reproducible:

  • no other workspace or project workspace refers to it (a workspace already expired with its worktree removed does not count) and no runtime service runs in it;
  • after git worktree prune, it has no worktree besides its own checkout;
  • its own checkout has no tracked changes and no untracked files (only Paperclip's compaction trash is allowed, even inside a directory with a regenerable name), and its ignored files are only inside regenerable directories (such as node_modules) or Paperclip's compaction trash. Empty directories, such as the .paperclip/worktrees parent left by the old worktree, do not count;
  • it has only branch, tag and remote-tracking refs (no stash, notes or replacement refs);
  • every commit that its local branches, local tags, reflogs (other than remote-tracking ones) or ORIG_HEAD hold is on one of its remote-tracking refs, present in the shared clone, or found on the remote. The remote is read fresh (branches and tags) with the company's Git identity, falling back to anonymous access, and only when some commit is in neither place. HEAD itself counts too, so a detached checkout is covered. Remote branches and release tags that the shared clone never fetched do not keep a clone.

A clone with more than 5,000 refs, reflog entries or such commits is kept (clone_history_budget), and so is one with more than 1,000 ignored directories outside regenerable ones (clone_file_budget); those directories are read in one Git call. The log entry names up to five paths, worktrees, refs or commits that kept a clone (cloneKeptPaths). A clone another workspace still uses is kept (cloneRetained); that workspace's own move removes it later. If the server stops after the update, a later sweep finishes the removal and sets cleanedAt, also after the workspace has been archived.

The reaper also sweeps orphaned clones under isolated-repository-sources/ once per server start: clones that no execution workspace refers to (no open workspace with matching isolatedRemoteSource, and no closed one that would need the clone to reopen) and that no pending move or cleanup still owns. The repository must be proven by the database: the workspaces of that issue must record exactly one repository URL, and the clone's origin must equal it (unproven_repository or source_identity_changed otherwise), so a rewritten origin is never fetched or trusted. It applies the same reproducibility check, using the shared clone of a workspace of the same issue and repository when one exists, and logs event: "execution_workspace.isolated_source_orphan_cleanup". It inspects at most eight issue directories per reaper sweep and continues from that position in the next sweep, so a slow remote cannot hold up other workspace cleanup. When the walk ends and it kept any orphan, it looks again a day later; otherwise it registers no further work. It also deletes leftover trash directories from interrupted removals.

Each attempt logs event: "execution_workspace.isolated_source_rehome" with the outcome, skip reason, candidate bytes and reclaimed bytes. A workspace that is skipped, or whose clone is kept for a reason that can change, is retried an hour later through the reaper's existing deadlines; nothing runs while there is no candidate. PAPERCLIP_ISOLATED_SOURCE_REHOME_DRY_RUN=true reports candidates without changing anything.

Re-homed workspaces get a new path. An agent that resumes an older session may still remember the old path; the run's working directory comes from the updated workspace.

Reopening or restoring an expired workspace whose recipe came from a private clone no longer recreates that clone when the project has a shared managed clone for the same repository, its branch there is absent or at the saved commit, and the shared clone has every commit the saved restore point bundle needs as a base. A recipe with worktree-backed nested checkouts on branches keeps the old behavior, because their branch names are bound to the old path. The workspace is rebuilt as a worktree of the shared clone at the standard location, with the saved commit and restore point imported and applied as before. Without a shared clone, the old behavior applies.