Files
PaperClipAI/server
DottaandPaperclip 0991d9bda1 fix(workspaces): protect migration sources and add guarded recovery service (#20443)
## Thinking Path

> - Paperclip manages agents and their task workspaces.
> - Online migration saves files before it publishes remote workspace
authority.
> - A saved migration journal can survive the importing process.
> - Git cache expiration did not count that journal as a consumer.
> - Expiration could remove a source and change its fingerprint while
its import was incomplete.
> - This PR protects unfinished imports and exposes a guarded internal
service transition for an already affected cohort.
> - New imports can preserve the current sources while all old recovery
data stays retained.

## Linked Issues or Issue Description

**What happened?**

Git cache expiration could archive and remove a workspace after an
import process ended but before its saved cohort finished. The saved
source fingerprint then differed from the current archived workspace.
Exact cohort resume correctly refused that changed source. Reopening the
workspace cannot recreate the old metadata or fingerprint.

**Expected behavior**

An unfinished migration remains a durable consumer after process loss.
If an older server already expired some sources, one canonical
transition can fail the exact unpublished cohort without losing its
snapshots, objects, source history, or recovery obligations. New
attempts use the current verified sources.

**Steps to reproduce**

1. Start a cohort import of eight Git worktrees with local changes.
2. Interrupt it after four snapshot uploads.
3. Let an older server expire two idle source worktrees.
4. Resume the saved cohort. It refuses the changed archived sources.

Related: #20429 preserves exact cohort identity when independent
companion imports change only the scope proof. #20424 preserves prepared
recovery state for archived sources. This PR handles completed source
expiration, not a scope-only change. Public searches found related
expiration work and no matching compensation owner.

## What Changed

- Count saved migration markers and unfinished migration journals as Git
expiration consumers. Keep failed migration retention holds as consumers
too.
- Expose `supersedeExpiredRemoteWorkspaceMigrationCohort` as an internal
service API for a privileged operator with a real database handle and
reviewed company/cohort inputs. It is called directly by the
PostgreSQL/Git test and reviewed one-time administrative qualification.
It adds no CLI mode or HTTP route. The existing CLI still refuses a
damaged cohort; this PR does not claim automatic CLI recovery.
- Require an exact unpublished cohort of up to eight members. It
requires completed expiration and an unexpired restore recipe for each
missing member. The other members must retain their saved source
fingerprints.
- Recheck current source and recovery state under source, lifecycle,
finalizer, and row locks. One transaction fails all old journals, writes
audit records, and removes only their exact matched active markers.
- Keep old cohort proofs, operations, snapshots, and object references.
Genuine failure receipts use the existing failed-save retention hold.
This transition does not report migration success.
- Reuse the existing bounded source-lock queue when `sourceLockWaitMs`
is set. Keep fast refusal by default. Cancellation targets only this
waiter, and current checks still run after admission.
- Record predecessor IDs on new attempts and reserve a snapshot
generation above the existing ledger maximum. New success does not
retire the old hold.
- Document the owning transition and retention rules.

## Verification

- The two new journal-consumer cases failed under the old expiration
predicate and passed under the new predicate.
- The full new recovery case passed in 21.12 seconds with real
PostgreSQL locks, Git repositories, encrypted local object storage, and
archive restores. It covers four retained uploads, two real expirations,
unchanged old resume refusal, authority change refusal, transaction
rollback, a held native finalizer, idempotence, eight new imports,
dirty/untracked file restores, and a retention pass 30 days later with
no object deletion.
- Server TypeScript and module-boundary checks passed.
- On `7cec8c915acfad2d08874eaf26abaa852f560219`, the queued-admission
regression failed before forwarding the option (24.07 seconds), then
passed (22.25 seconds). It verifies a real held project lock,
cancellation that preserves the holder and all journals, release into
full compensation, retention, and eight restored fresh imports. Current
server TypeScript and module/diff checks passed. The only production
follow-up changes are the optional queue input and its existing-owner
call.
- The earlier `737a0df3a71b10fe48d7bbfe7ffd8ae296b4852c` CI run had one
Runner support failure: the unchanged credential-free Python/Pi
calibration exceeded its outer 25-second subprocess deadline. That job
passed 6,926 other tests and skipped four. The timeout is retained as a
nonpass. The changed logical head requires fresh CI and review; no test
deadline was raised.
- The affected cohort, Git expiration, and eligibility suites passed all
148 cases in 228.41 seconds on
`aa589f3f447e36d0277c32f8b44cbe5e84a673be`. Merged current master
`a9d0946e64`; the only conflict retained both independent added test
blocks. The complete merged cohort suite passed all 30 cases in 70.00
seconds on `737a0df3a71b10fe48d7bbfe7ffd8ae296b4852c`, including
upstream larger-cohort controls. Final-head server TypeScript,
incremental preflight, module-boundary, and diff checks passed.
Unchanged expiration/eligibility evidence remains valid. Fresh
`809ab704e619812d8f1bf38cf5de9c65893861cc` CI and review are pending.
- On `809ab704e619812d8f1bf38cf5de9c65893861cc`, the complete recovery
fixture passed in 21.38 seconds after adding one valid archived recipe
with no restore point. It refuses with
`migration_supersession_expiration_unverified` and keeps all workspaces,
journals, and snapshots unchanged. The same case then verifies queue
cancellation/release, full compensation, retained history, fresh
imports, and restored bytes. Server TypeScript passed. This follow-up
changes only the test and service-scope documentation; product bytes
remain identical to qualified
`7cec8c915acfad2d08874eaf26abaa852f560219`.
- Earlier fixture attempts did not pass. They exposed the selection of a
held source and an incorrect expected refusal string. The fixture now
selects eligible sources and asserts the canonical authority refusal.
Production guards were not relaxed.
- Live qualification of the exact owning product bytes completed in
230.419 seconds. All eight old operations became failed with auditable
supersession lineage. All eight old snapshot rows remained byte-for-byte
identical, and their object references remain retained. The existing
source-lock queue waited 1.151 seconds for a normal finalizer handoff.
The three complete source/recovery passes took about 218 seconds
including their transaction, which explains why the earlier 180-second
total budget was insufficient for that measured workload. No source
proof or publication guard was skipped. Compensation made no provider or
object-storage calls.
- Earlier live qualifications remain nonpasses: the 180-second attempt
did not finish; its instrumented successor completed source checks and
refused a busy source lock before mutation. The successful bounded-queue
attempt retained those failure records. Fresh archived/online imports
are a separate subsequent step and are not claimed complete here.
- The local checks do not claim browser acceptance, app deployment,
fleet enrollment, or a completed zero-downtime cutover.

## Risks

- This owner mutates migration bookkeeping. It preserves all-or-nothing
row comparisons and current source/recovery locks. A changed journal,
authority, writer, finalizer, expired recipe, or partial transaction
refuses the transition.
- Historical snapshots and objects remain pinned after new success. This
intentionally retains storage. Release requires a separate reviewed
action.
- An older importer cannot use the successor generation contract. Use
the containing canonical owner for recovery. Normal app delivery must
also carry the expiration guard; a private maintenance graph does not
patch a running server's cleanup worker.
- The migration owner crosses 1,000 lines. The bounded compensation
transition remains beside its private source inspection, fingerprint,
fence, reservation, and publication contracts. Moving it now would
require exporting or duplicating those safety owners. No unrelated
refactor or new framework is included.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, source editing, Git, and
local execution tools. The exact serving model ID and context-window
size 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
- [ ] All Paperclip CI gates are green
- [ ] 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-11-09 21:02:02 -06:00
..