Files
PaperClipAI/server/src/services/workspace-runtime-lease-settlement.ts
T
DottaandClaude Opus 5.5 2aac7d29ff feat(recovery): settle workspace runtime leases of proven-gone owners on the shared path (#20015)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A task that starts, stops or repairs a workspace's runtime services
takes a runtime lease, so two tasks never drive the same workspace at
once
> - A lease whose owner task or run has ended must be released. Until
now that happened only when the next claimant arrived and checked, or
when 30 minutes of inactivity passed
> - The durable ownership plan releases a record when its owner is
proven gone, by identity and never by age, through the shared reconciler
> - This pull request registers the runtime lease with the reconciler.
Proof releases a lease; the 30-minute idle rule stays only as a fairness
policy at claim time
> - The benefit is that a workspace locked by a finished task frees
itself at once, and a live task's lease is never taken by age

## Linked Issues or Issue Description

Refs #19831 (plan, Phase 2 sections A and C), #19838 (inventory:
"Execution workspace runtime lease", and the "Released only by TTL/age"
list).

**What happened?**
`execution_workspace_runtime_lease` was a report-only kind. A lease
whose issue or run had ended stayed held until another claim came, or
until its 30-minute TTL expired. The claim path also mixed the TTL with
the ownership check.

**Expected behavior**
An issue- or run-owned lease is released when its owner is proven gone:
the issue is missing, hidden or terminal, or the run is missing or
terminal. Age is not proof. An agent-scoped owner has no identity to
prove, so the lease's expiry plus the next claim's locked takeover is
the fenced handoff.

## What Changed

- New `workspace-runtime-lease-settlement.ts`:
`settleExecutionWorkspaceRuntimeLease` locks the row, requires the same
owner key, re-proves that owner gone, and only then deletes the lease
(compare-and-swap).
- `workspace-runtime-leases.ts`: the new
`proveWorkspaceRuntimeLeaseOwnerGone` holds the identity proof. The
claim path checks it first, then applies the 30-minute idle reclaim as a
separate fairness policy.
- `recovery/ownership-registry.ts`: the kind registers the settle.
Owners are now `issue`, `run`, or a fence on `expires_at` for
agent-scoped keys. `RecordOwner` gains an `issue` kind.
- `recovery/ownership-invariant-report.ts`: issue owners are classified
(missing, hidden or terminal means gone).

Salvaged from the stopped Phase 2 workspace agent's uncommitted work. I
resolved it against current `master` and verified it.

## Verification

- New `workspace-runtime-lease-ownership.test.ts`: 8 pass. Six fail on
`master` and pass here:
  - a crash after the claim;
  - hidden or missing issues, and terminal or missing runs;
  - an agent-scoped lease never settles;
  - a read-only pass changes nothing;
  - a lease taken over after the proof is kept;
  - the release reason names the ownership proof, not the idle policy.

Two guards pass on both: a live owner is never released by age, and the
idle reclaim still works at claim time.
- `ownership-reconciliation-report.test.ts` (25),
`workspace-runtime-leases.test.ts` (10) and
`execution-workspace-runtime-lease-route.test.ts` (4) pass.
- The server package typecheck passes.

## Risks

- The reconciler now deletes leases whose owner is proven gone. It
always re-proves under the row lock against the exact owner key.
- The idle reclaim behaves as before at claim time.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code
execution in Claude Code.

## 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 Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:45:48 -06:00

50 lines
3.0 KiB
TypeScript

import { and, eq } from "drizzle-orm";
import { executionWorkspaceRuntimeLeases, type Db } from "@paperclipai/db";
import { persistActivity, publishActivity, type ActivityPublication } from "./activity-log.js";
import type { OwnerProof } from "./owner-proof.js";
import type { InProgressRecord, SettleOutcome } from "./recovery/ownership-registry.js";
import { proveWorkspaceRuntimeLeaseOwnerGone } from "./workspace-runtime-leases.js";
/**
* Settle of the `execution_workspace_runtime_lease` ownership kind (plan Phase 2).
*
* The shared reconciler calls this only after it proved the lease's issue or run
* owner gone (issue terminal, hidden or missing; run terminal or missing). Settle
* releases exactly that lease: under the row lock it re-reads the row, requires
* the same owner key, and re-proves the owner gone, so a lease renewed or taken
* over since the proof is never released. Age plays no part. Agent-scoped leases
* are never settled here: no identity proves such an owner gone, and their expiry
* plus the next claim's locked takeover is the fenced handoff.
*/
export async function settleExecutionWorkspaceRuntimeLease(db: Db, record: InProgressRecord,
_context: { now: Date; proof: OwnerProof }): Promise<SettleOutcome> {
const ownerKey = record.owner.kind === "issue" ? `issue:${record.owner.issueId}`
: record.owner.kind === "run" ? `run:${record.owner.runId}` : null;
if (!ownerKey) return { settled: false, reason: "owner_not_provable" };
let publication: ActivityPublication | undefined;
const outcome = await db.transaction(async transaction => {
const tx = transaction as unknown as Db;
const [row] = await tx.select().from(executionWorkspaceRuntimeLeases)
.where(and(eq(executionWorkspaceRuntimeLeases.id, record.recordId),
eq(executionWorkspaceRuntimeLeases.ownerKey, ownerKey)))
.for("update");
if (!row) return { settled: false, reason: "lease_no_longer_held" };
const gone = await proveWorkspaceRuntimeLeaseOwnerGone(tx, row);
if (!gone) return { settled: false, reason: "owner_changed_since_proof" };
const deleted = await tx.delete(executionWorkspaceRuntimeLeases)
.where(and(eq(executionWorkspaceRuntimeLeases.id, row.id), eq(executionWorkspaceRuntimeLeases.ownerKey, ownerKey)))
.returning({ id: executionWorkspaceRuntimeLeases.id });
if (!deleted.length) return { settled: false, reason: "lease_no_longer_held" };
// The lease row is gone; the activity entry keeps who held it and why it was released.
publication = (await persistActivity(tx, {
companyId: row.companyId, actorType: "system", actorId: "ownership-reconciler",
action: "execution_workspace.runtime_lease_released", entityType: "execution_workspace",
entityId: row.executionWorkspaceId, runId: row.ownerRunId, issueId: row.ownerIssueId,
details: { leaseId: row.id, previousOwnerKey: row.ownerKey, lastAction: row.lastAction, proof: gone },
})).publication;
return { settled: true, reason: gone };
});
if (outcome.settled && publication) publishActivity(publication);
return outcome;
}