Files
PaperClipAI/doc/native-workspace-finalization-recovery.md
T
83e13db8fa fix(inbox): keep automatic recovery cleanup out of personal participation (#16063)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The personal inbox uses issue activity to identify a person's
participation.
> - Issue reads can cancel stale recovery actions and record that
cleanup as the reader's action.
> - This adds unrelated tasks to the inbox and refreshes their activity
timestamps.
> - A workspace owner hold can also alternate between creation and
cancellation after a task finishes.
> - This pull request corrects cleanup attribution, excludes historical
read cleanup, and retains one exact owner hold.
> - Agents can read authorized issue activity to diagnose the same
problem themselves.

## Linked Issues or Issue Description

Refs #15742 excludes logs by the execution-recovery actor from activity
timestamps. This PR covers reader-attributed stale recovery cleanup,
personal inbox participation, exact workspace owner holds, and sandbox
issue activity access. I searched open issues and PRs for recovery inbox
and activity bridge fixes. I found no duplicate for this defect.

**What happened?**

Loading issue lists cancels stale recovery actions under the reader's
identity. The personal inbox treats those rows as user participation.
One completed issue had hundreds of such rows and no edits or comments
by the affected reader. A retained native workspace owner repeatedly
creates a verification action that read projections then cancel. Sandbox
agents cannot inspect the issue activity because the reverse bridge
rejects the route.

**Expected behavior**

Automatic cleanup belongs to the system and does not add an issue to a
reader's inbox. Historical cleanup rows remain auditable but do not
count as participation or recent activity. An unresolved physical
workspace owner keeps one stable verification action. Agents can inspect
activity only for issues their identity may read.

**Steps to reproduce**

1. Finish a task whose native run retains an unverifiable workspace
owner receipt.
2. Repeat workspace recovery checks and GET requests for issue detail,
compact lists, and recovery actions.
3. Observe new recovery action IDs and reader-attributed cancellations
in the activity history.
4. Query the personal inbox for that reader and observe the unrelated
task.
5. From a sandbox agent, request GET /api/issues/:id/activity and
observe Route not allowed.

**Paperclip version or commit**

Observed at f5346c05b9. Regression tests
use current master behavior.

**Deployment mode**

Authenticated hosted instance with native and legacy sandbox agents. The
inbox defect is independent of the adapter.

## What Changed

- Attribute automatic stale recovery cleanup to the recovery system
actor.
- Exclude exact historical read cleanup rows from personal inbox
membership, unread counts, and both activity timestamp queries. Keep all
audit rows and explicit user recovery decisions.
- Retain owner verification holds only while the exact company, run,
issue, and owner fingerprint still match. Apply the same rule to reads
and background recovery.
- Make repeated checks for the same owner hold leave its identity,
attempts, and timestamps unchanged. Keep physical takeover blocked.
- Allow GET issue activity through the sandbox bridge. Keep the existing
company and issue-read authorization and reject broader audit routes.
- Add regressions and update the runtime API and recovery documentation.
Refresh generated source references for the documentation edits. Update
the paired activity, inbox, and recovery maps.

## Verification

- Focused Vitest verification: 315 tests pass across five files. The
updated terminal owner hold regression also passes. The files cover
issue service, recovery routes, activity authorization, sandbox callback
bridge, and native workspace finalization.
- `pnpm -r typecheck` and `pnpm build` pass locally. The full general
and serialized unit test matrix passes in CI on the latest commit. The
duplicate local `pnpm test:run` was stopped after about 30 minutes once
that full CI coverage passed; it did not report a local full-suite pass.
- `node scripts/check-maps.mjs` passes for all paired areas and
capability IDs.
- Apex is 5/5 on commit 44c3787888. Its
access map finding is fixed and resolved. All CI checks are green on
this commit.
- The new regression repeats reads and background sweeps for done and
cancelled tasks. It checks one unchanged hold, zero physical copyback,
zero personal inbox participation, and system cleanup after the exact
receipt is cleared.
- Historical cleanup tests preserve explicit recovery decisions,
nullable metadata, unread counts, and the original audit rows.
- Authorization tests permit an authorized agent and reject
cross-company or issue-boundary reads. Bridge tests reject write
methods, extra path segments, and company-wide audit routes.

## Risks

Existing inbox items can disappear if their only participation evidence
was automatic read cleanup. No data migration or audit deletion is
needed. An unresolved workspace owner remains visible after task
completion; this preserves the physical writer fence. A stale or cleared
owner does not retain the hold. Issue activity access expands only the
sandbox transport allowlist and still uses existing controller
authorization.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, repository inspection, code
editing, shell execution, and test tools. The session identifies the
model family as GPT-6. A more specific runtime model ID and context
window size are not exposed.

## 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>
Co-authored-by: Amy (Opus) <noreply@anthropic.com>
2026-10-12 19:25:15 -07:00

18 KiB

Native workspace finalization ownership and recovery

Native workspace export and merge acquire a PostgreSQL advisory lock scoped to company and run before the first physical copyback. The live heartbeat and the reconciler share that lock. Recovery skips a busy owner without recording another workspace operation or spending a retry. Recovery also rechecks the coordinator's terminal state and retry time under ownership: an earlier sweep snapshot cannot start another export after live finalization publishes a permanent repair or delay. A completed workspace barrier is reread under ownership before export, and a committed coordinator cannot be overwritten by a late failure receipt.

An unverified workspace owner keeps one board recovery action for its exact run and owner fingerprint. Repeated recovery checks do not consume attempts or rewrite that action. Task completion or cancellation does not prove the old physical writer stopped, so reads and recovery sweeps retain this action while the matching run still carries that owner receipt. After the receipt is cleared or replaced, ordinary stale-action cleanup applies. Cleanup is attributed to the system; historical cleanup from page reads does not add tasks to a person's inbox or refresh issue activity timestamps.

The lock transaction holds no row locks. Ordinary progress and finalization receipts remain visible through the normal database pool. A dedicated connection outside the application pool is reserved for the duration of copyback and closed when it settles; even a one-connection application pool remains available for progress writes. Its run profile carries nativeWorkspaceFinalizationOwner, an exact token, host, PID, and process-start receipt. Losing the lock connection does not prove physical copyback stopped: a contender still refuses a receipt whose controller is alive. The original callback joins before its token is released, and publication checks the lock connection and token. Graceful completion clears the receipt. If that cleanup write fails after the callback joins, only the same exact controller boot retains positive in-process join evidence and may resume after reconnecting; an unknown token or a new boot does not inherit that authority. A controller that has exited on the same host can be recovered automatically only when a durable successful workspace barrier proves its copyback finished. A dead parent can leave tar/Git children alive, so incomplete copyback requires operator stop verification even on the same host. PID reuse is checked against its recorded start time rather than trusted by PID alone.

Unverified copyback after controller replacement

Generic orphan recovery does not turn a missing provider PID into process_lost while the native coordinator is workspace_finalizing with an accepted result bound to the same company, issue, and run. The finalizer owns that unfinished suffix, including the interval before it acquires its physical owner receipt. The heartbeat's phase or profile alone does not establish this authority. The conditional orphan write rechecks the durable result and the heartbeat's PostgreSQL tuple version, so a result published while the write waits on its row lock cannot be overwritten using an older statement snapshot. Result publishers lock and update that heartbeat in the same transaction as acceptance.

The running run keeps its source lease while the existing finalizer either completes, records its bounded failure policy, or requires operator stop verification. A new queued run or a healthy child task cannot clear an unverified copyback hold. Verified finalization clears the matching run's hold after commitment. Existing terminal-task dispositions and explicit cancellation remain unchanged; this does not recover a source already destroyed by an older controller or claim that an unfinished export succeeded.

The controller cannot verify a process on a foreign or unknown host, or orphaned copyback children after an abrupt parent death before the success barrier. It surfaces native_workspace_finalization_owner_unverified as board-owned recovery, with no automatic provider wake. This is an intentional limit: elapsed time or a missing database connection never proves the old copyback process stopped.

An instance operator must first verify through the deployment platform that the exact prior controller and its copyback subprocesses have stopped. Retain the sandbox, accepted native result, and workspace descriptor. Do not run a new provider turn, delete workspace contents, or relax archive confinement.

After that platform verification, use a database maintenance transaction to release only the exact receipt shown by the recovery action. Replace the four placeholders with the action's company, run, token, and source issue. The advisory lock prevents concurrent acquisition during this change; the token comparison prevents clearing a newer owner. A zero-row update means ownership changed and requires fresh inspection. This maintenance operation is for a full-control instance operator, not an agent tool.

BEGIN;
SELECT pg_advisory_xact_lock(hashtextextended(
  'native-workspace-finalization:<company-id>:<run-id>', 0));
UPDATE heartbeat_runs
SET runner_profile_json = runner_profile_json - 'nativeWorkspaceFinalizationOwner'
WHERE company_id = '<company-id>'::uuid
  AND id = '<run-id>'::uuid
  AND native_issue_id = '<issue-id>'::uuid
  AND runtime_mode = 'native'
  AND runner_profile_json->'nativeWorkspaceFinalizationOwner'->>'token' = '<owner-token>'
RETURNING id;
COMMIT;

Resolve the existing board recovery action with a note containing the platform stop evidence. The ordinary reconciliation sweep then resumes workspace finalization from the accepted result. Confirm the run's native phase and resultJson.finalizationPhase are committed, there is no nextAttemptAt, and no workspace operation is still running. The accepted provider result is reused.

Automatic unsafe archive recovery

An unsafe link in a native workspace must not fail a completed task or require an operator to repair a sandbox. The accepted agent result remains authoritative.

Daytona validates every exported archive before extraction. If validation rejects an archive, export once more using files, directories, and relative symlinks whose resolved targets stay inside the workspace. The fallback does not follow or delete symlinks. It omits unsafe or unresolvable links, stores hard-linked files as ordinary bytes, and preserves directory exclusions and empty directories. The second archive passes the same confinement checks. A fixed informational message records the fallback in the provider log.

If native copyback still rejects the archive or its source confinement check, Paperclip discards that export, records workspace_export_omitted at info level in the local run log, and completes finalization using the original accepted result. It does not create a task warning, recovery card, repair request, or new provider turn. The normal completion policy still enforces ownership and explicit workflow constraints. Lost remote files are an accepted tradeoff.

The live path and restart finalizer use the same policy under workspace finalization ownership. Ownership loss, transport failures, missing sandboxes, and other unrelated errors retain their existing handling. No unsafe archive is extracted. No host path or link target is copied into the informational run event.

Repairing a failed workspace export without rerunning the agent

Transient export failures retry three times, then produce native_workspace_sync_out_retry_exhausted. The accepted result stays saved, and the exact sandbox is stopped and retained for export-only recovery. The reason identifies a transport/copyback failure, not an inferred disk-space cause. New unsafe archives use the automatic policy above and do not create this hold. Historical unsafe-archive holds recover automatically as described below; they do not use this operator flow.

A board member with runtime management access can complete the saved result:

  1. Read the recovery action, run, and environment lease. Verify the company, run, provider sandbox ID, and stopped-provider receipt. Preserve that exact sandbox; do not acquire a replacement or seed the host workspace over it.
  2. Through the provider console or official SDK, resume that sandbox and inspect the export failure while preserving all user files. Restore transport access or other provider prerequisites before retrying. Extending the sandbox retention window can be necessary during this repair.
  3. In the task's Workspace export needs repair notice, describe the repair and choose Retry workspace export. The equivalent board API is POST /api/issues/:id/recovery-actions/retry-workspace-export with { "actionId": "<recovery-action-id>", "runId": "<run-id>", "repairNote": "<repair and preservation evidence>" }.
  4. Confirm the same run commits, the saved result determines the task outcome, and the recovery action resolves. No new provider turn or wake is created. A reusable repair sandbox is stopped and retained again. An ephemeral sandbox follows its original destroy-after-turn policy only after the exact result commits and the workspace reference contains a finalized host-copy receipt.

Admission requires the same accepted result, task owner, descriptor, and lease; confirmed prior provider stop; an available repaired sandbox; and no newer task execution or competing current lease. It shares finalization ownership and resumes only the recorded provider lease through its verified lifecycle method. That method drains old activity and verifies the saved workspace identity before reopening the provider's controller admission gate; an external console restart alone does not reopen that gate. Admission rejects a missing or replacement sandbox and revalidates after probing the exact workspace. A changed binding or unavailable sandbox returns 409 without reopening work. A duplicate queued request is idempotent. Unsafe archives use automatic salvage or omission during retry. Generic ordinary recovery resolution cannot retry, mark done, or send the task for review in place of export-only retry. Explicit board false-positive/cancellation dispositions remain deliberate overrides; they do not claim successful copyback.

Before publishing terminal export failure, the finalizer atomically records a stop-only intent with the failed run. This includes ephemeral allocations: ordinary release policy cannot delete their unexported files. A crash before cleanup leaves enough exact lease, result, and plugin authority for restart recovery. The same intent remains while export-only retry is active.

Before resuming the retained sandbox, the controller durably records a stop-only cleanup intent on that exact lease and removes the old stopped receipt. If the resume reply is lost, probing fails, or admission changes, it stops and retains the sandbox. A failed stop remains pending_cleanup; a restarted controller's bounded cleanup sweep retries the verified stop without destroying saved files. An in-flight request holds a 15-minute cleanup claim, so a controller crash may delay that sweep until the claim expires. Neither an unconfirmed stop nor a stale receipt grants admission, and a changed lease or competing sandbox owner prevents cleanup from taking ownership. Retry export after the lease has a new confirmed stopped receipt. No provider turn is created by this recovery. Cleanup and its readiness probe use the plugin ID recorded on that lease. Another plugin with the same provider name cannot take over; an unavailable original plugin defers cleanup without consuming an attempt. Stop-only cleanup requires the exact plugin worker to advertise environmentStopLease. Older plugins without that hook receive neither release nor destroy; resume admission also defers until safe compensation is available. New intents use schema v2 with an explicit plugin pin. A v1 intent created before that field existed remains recoverable using only the plugin ID already recorded on its exact lease; an explicit mismatched pin is still rejected.

The opt-in native-workspace-export-resume.live.test.ts is a provider-boundary fault integration, separate from the browser Product E2E. After building the Daytona plugin, run that exact Vitest file with PAPERCLIP_LIVE_EXPORT_RESUME=1, DAYTONA_API_KEY, and PAPERCLIP_LIVE_EXPORT_RESUME_IMAGE set to an immutable image digest. It creates one disposable ephemeral sandbox and database, injects probe and stop transport failures, and verifies a fresh runtime can stop the sandbox while preserving exact nonce bytes. It also injects three transient failures at the production finalizer boundary and verifies retained work plus export-only retry admission. This boundary test does not claim physical copyback or result commitment; historical browser Product E2E supplied that proof before automatic unsafe-export recovery. It deletes only that owned fixture after proof.

Historical unsafe exports

The reconciliation sweep automatically recovers accepted results that an older controller terminalized with native_workspace_sync_out_unsafe_archive. It omits the already-rejected export without accessing the old provider, so a stopped, unavailable, or deleted sandbox cannot require user repair. The omission barrier and result re-admission are atomic under finalization ownership. The normal status arbiter still enforces current ownership, contracts, and gates.

This works with active, incorrectly resolved, or missing old repair actions. It clears the matching stale unsafe notices instead of restoring them. Newer runs, changed task ownership or contracts, another active recovery action, and explicit operator dispositions prevent replay of old results. No provider turn is created. Diagnostics appear only in the local run log; no unsafe-export activity notice, repair card, or recovery chip is created. Existing stop-only allocation intents retain their exact ownership and cleanup protections.

Daytona stop-only preservation disables provider auto-delete and refreshes the provider record to confirm the disabled policy before stopping. An unavailable or unconfirmed policy leaves cleanup pending; it never falls back to stop or delete. The original ephemeral destroy policy applies only after exact accepted-result copyback and commitment.

New ephemeral native allocations receive an acquisition-time workspace sentinel bound to their run and provider allocation. Export-only resume requires that proof and the unchanged durable intent. A legacy ephemeral allocation without this proof remains blocked; recovery never creates a sentinel or accepts a replacement during resume. The reusable workspace identity hash is unchanged.

Disabling auto-delete retains provider storage while repair waits. No automatic lease-age sweep deletes these released, confirmed-stopped allocations. Storage costs can continue until committed copyback applies the original cleanup policy or an operator explicitly deletes the allocation.

Generic stop-only cleanup

An explicit sandbox stop_and_retain also requires the dedicated provider hook when no export repair is involved, including successful per-turn reusable runs and startup cancellation. Before dispatch, the controller stores a separate sandboxStopAndRetain intent bound to the company, run, lease, provider allocation, and original plugin. The initial cleanup and restart sweep both require that plugin to advertise environmentStopLease. Missing capability or failed stop leaves pending cleanup; ordinary release and destroy are never fallback methods.

A matching stopped receipt clears the pending intent, leaves the lease released and resumable, and saves sandboxStopAndRetainReceipt. Its request identity, original plugin and method: "environmentStopLease" attest this dispatch route. The accepted-result export intent and its commitment requirements are unchanged.

The built-in fake provider uses its explicit stopLease operation rather than a plugin RPC. Its intent pins the registered built-in provider and its receipt says builtin.stopLease. This provider owns no real process or filesystem; the receipt models its lifecycle. Missing built-in stop support or an unconfirmed receipt also remains pending through restart, without calling release or destroy.

Legacy copy-back after a confirmed stop

A legacy adapter can report failed workspace copy-back after cancellation has already stopped and released its sandbox lease. The host records the exact lease snapshot from before adapter dispatch. For an originally ephemeral source, the recorder can preserve the released allocation for repair only while the current lease is still ephemeral and its company, run, issue, workspace, provider, allocation, acquisition time, plugin, and realized root still match. It requires both the dedicated stop-and-retain receipt and the matching stopped-execution receipt. A competing owner or changed source fails closed. The recorder leaves release status and receipts intact, excludes the allocation from reuse, and creates the existing board-owned file-repair obligation without replaying work or changing a newer run or the task's status.

This late-recording path does not admit reusable leases. Provider resume can precede the database handoff, so an old stop receipt and a database row lock cannot prove that reuse has not started. Existing active-source and previously recorded recovery paths retain their behavior. A missing or rejected original snapshot does not authorize adopting another released lease.

A retained source receipt records control-plane evidence; it does not prove the provider still holds the original bytes or that copy-back succeeded. Operators must verify and recover the exact source separately. Later successful runs and completed tasks do not certify recovery of earlier workspace files.