Files
PaperClipAI/doc
DottaandClaude 9296c74e8d fix(native): let a restart claim take back its lapsed lease at budget admission (#20650)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - After a control-plane restart, restart recovery claims an
interrupted native run (`resume_dead_runner`) and grants a 20-minute
lease. Budget admission then re-checks that the claim still owns the run
before the resumed run may start provider work.
> - A host sleep, or the restart-chaos fault TM2, can make that stored
lease look expired while its owner is alive. #20625 lets the owner take
back such a lease when the row still shows exactly its own claim. That
rule did not cover budget admission.
> - So a claim whose lease lapsed before dispatch was refused with
`Native recovery no longer owns provider dispatch`. The run failed with
`adapter_failed`, recovery held it with
`native_continuation_requires_reconciliation`, and the user had to click
Retry.
> - This pull request applies the #20625 rule in budget admission: the
claim takes its lease back through the existing re-acquire condition.
> - The benefit is that a run resumed after a restart survives a time
jump before dispatch with no Retry. Every fence and every budget check
stays the same.

## Linked Issues or Issue Description

Refs #20625, #20474

**What happened?**
Restart-chaos wave L7 (seed 9107, local) on a branch with #20625, task
codex B5 TM2. A TM3 restart claimed the run (`resume_dead_runner`,
generation 2). TM2 then aged every database time by one hour before the
resume reached dispatch. Budget admission
(`lockRunBudgetRecoveryOwnerInTransaction`) required a live lease and
threw `Native recovery no longer owns provider dispatch`. The run failed
with `adapter_failed`, recovery stopped with
`native_continuation_requires_reconciliation` /
`preserve_without_replay_v1`, and the task needed Retry.

**Expected behavior**
The claim's own lapsed lease is taken back, and the resume is admitted
when the budget allows it.

**Steps to reproduce**
`node cli/node_modules/tsx/dist/cli.mjs tests/restart-chaos/main.ts
--env local --adapters codex,claude --workloads A1,B5,C1 --faults
TM1,TM2,TM3 --time-age-hours 1 --concurrency 3 --tasks 6 --seed 9107`
(timing-dependent).

## What Changed

- `native-execution-lease.ts`: two new helpers. Both use the #20625
own-claim rule. Neither takes a lock out of order.
- `readOwnNativeRecoveryClaimLease` (read-only) returns `live`,
`lapsed_own_claim` or `lost`. It compares the lease against the database
clock. A lapsed lease counts as `lapsed_own_claim` only when all of
these hold:
    - the same lease owner;
    - this process's controller boot, pid and process start;
    - a non-null `recovery_state`;
- `nativeExecutionLeaseHeldCondition({ remote: false,
controllerGeneration })`, which applies the #20625 own-claim rule and
every #20474 untouched check;
    - no non-local environment lease on the run.
- `extendOwnNativeRecoveryClaimInTransaction` extends such a lease by
the local execution TTL, with one conditional UPDATE on the same
predicate.
- `budget-reservations.ts`:
- `lockRunBudgetRecoveryOwnerInTransaction` still refuses another owner,
and refuses a lapsed lease unless it is the run's own untouched claim.
It no longer locks the run row, so the documented coordinator → issue →
run order holds.
- The lease is extended only at the end of an admitted reservation
(`reserveRunBudget`) or an admitted dispatch recheck
(`recheckRunBudgetReservationInTransaction`). At those points the caller
already holds the run row lock. A refused admission, for example one
over budget, leaves the lease lapsed.
- `heartbeat-native-restart-suspension.test.ts`: the test "still settles
a recovery whose claim merely expired" encoded "expired means not the
owner". #20474 and #20625 changed that rule for an untouched own claim.
The test now has two cases:
- an expired claim that another actor recorded on (`awaiting_evidence`)
still settles with no provider session;
  - an untouched expired claim is dispatched.
- `doc/plans/2026-11-09-explicit-native-lease-claim-tokens.md`: Step 1
lists the new user of the rule.

## Verification

- New
`server/src/__tests__/native-recovery-dispatch-lease-reacquire.test.ts`:
- `reserveRunBudget` takes back the claim's own lapsed lease and admits
the resume. This fails before the change.
  - An over-budget resume is still refused, and its lease stays lapsed.
- Fenced variants each throw `Native recovery no longer owns provider
dispatch` and leave the row unchanged: a graceful-shutdown suspension,
an appended failure settlement, `awaiting_evidence`, `blocked`, a claim
from an earlier generation, another boot, a remote sandbox lease, and
another lease owner.
- At the dispatch gate (`recheckRunBudgetReservationInTransaction`), an
admitted own claim gets its lease extended, and a fenced claim is
refused with the row unchanged.
- Related suites pass (308 tests):
`native-execution-lease-reacquire.test.ts`,
`closed-native-receipt-accounting.test.ts`,
`heartbeat-native-predispatch-retry.test.ts`,
`run-dispatch/adapters/postgres.test.ts`,
`heartbeat-native-restart-suspension.test.ts` and
`heartbeat-restart-claim-lapse.test.ts`.
- `cd server && npx tsc --noEmit` passes.
- Live: one L7 wave (seed 9107) ran on the first commit of this branch
(`w-20261110T212307-s9107`). It logged 0 `Native recovery no longer owns
provider dispatch` refusals, against 1 in the previous wave. Codex B5
TM2 got past dispatch, but it still needed Retry for another reason:
after a `runner.drain` timeout, the scheduled same-run resume did not
send the continuation of #20645. That case is tracked separately. The
other five tasks passed.

## Risks

- This widens when a restart claim can take back its own lapsed lease,
and only at budget admission. It is local only. It needs the same owner,
boot and generation, and the row must show exactly that claim. A claim
that any other actor touched still fails as before.
- Budget hard-stop behaviour is unchanged. The lease is extended only
after budget admission passes.
- No schema change and no migration. Rows saved by earlier releases
carry the same history shape.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file edits, test runs). Extended reasoning.

## 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

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 17:43:46 -06:00
..
…
…
…