Files
PaperClipAI/server/src/services/controller-instance-lease.ts
T
Devin FoleyandPaperclip 9b71d03215 fix: recover interrupted native preparations and unblock upgrades (#20177)
## Thinking Path

> - Paperclip manages AI-agent companies and their work.
> - A native run prepares its workspace before a runner PID or
checkpoint exists.
> - A server crash can interrupt that stage and leave its controller on
another host.
> - Recovery needs a durable owner proof, a delivered recovery wake, and
the saved execution contract.
> - The old paths missed controller lease registration and boot proof,
skipped checkpoint-free preparations, and counted retained work as live
upgrade blockers.
> - This PR connects preparation to the existing controller lease and
recovery paths, and separates retained work from the upgrade census.
> - The result is bounded recovery without a second executor or loss of
saved work.

## Linked Issues or Issue Description

**What happened?**

A native preparation interrupted by a server crash could remain running
indefinitely with no runner PID or checkpoint. Native controller stamps
did not wake the shared instance lease. That lease was disabled on
volume-backed Railway services, and native takeover omitted the boot
binding needed for its cross-host proof. Scoped recovery selected
checkpoint-backed runs but skipped these preparations. The upgrade probe
then counted the retained row as current execution and could block every
later upgrade.

**Expected behavior**

Track the preparation's controller before a runner starts. Recover it
through the normal classifier after proving its owner stopped or fencing
that boot. Keep live or unprovable owners held. Deliver recovery wakes
to preparations without checkpoints. Preserve unresolved historical work
and report it separately from current execution during upgrade
observations.

**Steps to reproduce**

1. Save an observed native preparation with no runner process, result,
provider-start evidence or checkpoint.
2. Replace its controller with a process in another PID space. Let its
owner lease expire and send a scoped recovery wake.
3. Before this fix, recovery skips the checkpoint-free preparation or
cannot prove its foreign controller stopped. Upgrade readiness continues
to count that old row as a live blocker.

Related work: #18037, #19651, #19868, and #20168. The foreign-PID guard
from #19868 remains. The queued first-turn input fix from #20168 is
included through the current base.

## What Changed

- Register the existing instance lease when native execution and restart
claims are written. Keep passive identity reads free of ownership
effects. Keep that lease enabled on volume-backed Railway services
unless explicitly disabled.
- Preserve the existing PID/start-time proof within the same readable
PID space. Bind foreign or unknown-space takeover to its saved boot and
last write. A live lease or grace period keeps the run held with a
scheduled check. Repeated evidence waits preserve their timestamp.
- Include explicitly fenced, checkpoint-free preparations in the
existing finite recovery candidate inventory. Normal launch, checkpoint,
provider evidence and admission checks still decide whether work may
resume.
- Expose the existing board stop-confirmation operation for historical
foreign-host owners. Bind confirmation to the exact run, boot, PID,
process start and host. Reject changed ownership.
- Report narrowly defined old, unowned preparations as
`retainedBlockedRuns` outside the live upgrade census. Local executions,
pending admission, drain state, truncation and positive checkpoint proof
retain their barriers.
- Update the execution contract and add PostgreSQL, real-route, race and
preservation regressions.

## Verification

- Genuine red/green regressions cover the old upgrade census, missing
native lease registration, the volume-backed default, foreign-host
confirmation, and scoped recovery skipping checkpoint-free preparation.
Test bodies ran against real PostgreSQL before their corresponding
production changes.
- Combined verification before the final idle correction (head
`055be6c84d9`): **459 passed, 22 skipped, 11 files passed**. Covers
controller lease/identity, native restart classification and
integration, host confirmation, heartbeat recovery, instance route
authorization, upgrade readiness, and queued first-turn input. The
focused command used `pnpm exec vitest run` with those 11 suites and
`--maxWorkers=1 --no-file-parallelism`. The 22 skipped cases require the
Rust debug fake-Codex fixture, which was not built locally; the
real-process CI coverage must pass.
- `pnpm --filter @paperclipai/server typecheck`: passed on head
`055be6c84d9b058064daad0af71bb5d893ab9529`.
- Actual route proof uses the real heartbeat service and PostgreSQL.
Unauthorized access returns 403. The retained run and owner records
remain unchanged. Recovery integration admits one bootstrap claim across
two replacement controllers, keeps a live owner held, and preserves
local execution.
- `pnpm verify`: exit 2, requests broad CI because the execution
contract changed. Full local suite not run. Current-head CI must supply
broad coverage.
- Local source checkout on macOS ARM64, Node v25.6.1, pnpm 9.15.4. Tests
use isolated PostgreSQL. No live provider restart is claimed by those
fixtures.
- CI found an idle-query regression in the first version of the lease
wiring. Two focused PostgreSQL regressions reproduced the misplaced
identity-read notification and the missing claim notification. The fix
moves registration to actual ownership writes. `env -u npm_execpath pnpm
exec vitest run server/src/__tests__/maintenance-server-routes.test.ts
--maxWorkers=1 --no-file-parallelism`: **17 passed**, including 90 real
seconds and two qualified hourly windows with no extra idle queries. An
initial local setup attempt inherited npm metadata and did not run
tests; clearing that metadata selected the correct pnpm helper. Three
focused controller/native suites also passed **168 tests, 22
fixture-gated skips**. Server typecheck passed after the correction on
head `4a527326dcd5b3a9e286f0e4c3e761d3056af4d9`.
- The older CI server shard also exposed a same-space graceful-restart
regression. The new boot fallback had overridden local process-exit
proof. Seven suspension cases and a focused unit regression reproduced
it; the fix limits boot fallback to foreign or unknown PID spaces. Fresh
verification on `acf1065d98150210c1e227c7f3b05a1eb489730b`: **251
passed, 22 fixture-gated skips, 4 files passed**, including all **40**
graceful-restart suspension tests. Server typecheck passed. The local
regression failed before this correction, and the same-space and
foreign-space paths pass together after it.
- Current-head CI completed successfully on
`acf1065d98150210c1e227c7f3b05a1eb489730b` (run 42568109784). Greptile
reviewed that exact head at 5/5 with no unresolved review threads.
- Secret and private-data scans passed. No workflow, lockfile or
migration changes.

## Risks

- The instance lease now applies to volume-backed Railway services. Its
existing 30-second renewal, quiescence, grace and self-fence behavior
applies while work is held. Idle servers retain the existing no-query
behavior. An explicit `PAPERCLIP_CONTROLLER_INSTANCE_LEASE=off` remains
available.
- A controller lease proves only the server owner's state. It does not
prove an agent process stopped or authorize replay, resource release or
volume removal. The existing classifier and provider/runner checks
remain responsible for those decisions.
- Previous-release records keep their shape and saved state. A missing
boot lease stays unprovable and requires the scoped stop-confirmation
path. A changed confirmation target is rejected.
- The upgrade observation does not recover a held task by itself. Source
tests do not qualify a hosted replacement. Deployment and the affected
task's useful result must be verified separately.
- This repairs recovery after a crash; it does not claim to identify or
fix every source of heap exhaustion.

## Model Used

OpenAI `gpt-6.1-sol`, xhigh reasoning effort, through Codex in T3 Code,
with repository, shell, and GitHub tools. Context window size is not
exposed by the runtime.

## 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-11-08 21:53:13 -08:00

293 lines
15 KiB
TypeScript

import { and, eq, isNull, sql } from "drizzle-orm";
import { controllerInstances, type Db } from "@paperclipai/db";
import { logger } from "../middleware/logger.js";
import {
currentControllerBootId, currentProcessSpaceHash, onControllerStamp,
} from "./controller-process-identity.js";
import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
import { DELIVERY_QUEUES } from "./delivery-work-notifications.js";
import { setControllerLeaseQuiesce, subscribeIdleWorkSettled } from "./task-admission.js";
export const CONTROLLER_LEASE_TTL_MS = 90_000;
export const CONTROLLER_LEASE_RENEW_MS = 30_000;
/** Renewal attempts start failing from their own start; this boot stops after this long while holding work. */
export const CONTROLLER_LEASE_SELF_FENCE_MS = 60_000;
/** A prover fences a boot only this long after the boot's last sign of life. */
export const CONTROLLER_LEASE_FENCE_GRACE_MS = 30_000;
/** A stamp counts as held work for a full lease, so a slow ownership write commits before the lease can lapse. */
const STAMP_COMMIT_GRACE_MS = CONTROLLER_LEASE_TTL_MS;
/** A settle of other work re-checks holdings at most this often. */
const SETTLE_RECHECK_MIN_MS = 5_000;
/** A renewal that has not answered within this bound counts as failed. */
const RENEW_TIMEOUT_MS = 10_000;
const RETRY_MS = 5_000;
export type ControllerInstanceState = "live" | "grace" | "expired" | "fenced" | "stopped";
/** "holding" saw an in-progress record held by this boot; "holding_stamp" held only for a recent stamp. */
type RenewResult = "holding" | "holding_stamp" | "idle" | "fenced";
/** A stamp whose record no renewal has seen keeps the lease held until the server's tracked work next settles
* (its write then committed or rolled back), and at most this long. */
const UNOBSERVED_STAMP_CAP_MS = 10 * 60_000;
type LeaseState = {
onLost: (reason: "fenced" | "lease_lost") => void;
now: () => number;
renew: (stampedRecently: boolean) => Promise<RenewResult>;
lastStampAt: number;
/** Start of the first renewal attempt in the current failing streak. */
failingSince: number | null;
/** Renewal is due: this boot stamped work, or its last renewal found work it still holds. */
active: boolean;
epoch: number | null;
stopped: boolean;
/** Wall-clock time of the last stamp (the stamp window follows the server's clock). */
lastStampWallAt: number;
lastTickAt: number;
/** Counts stamps, so a stamp taken while a renewal is in flight is never lost. */
stampSeq: number;
/** Wall time of the oldest stamp no renewal has yet seen a held record for. */
unobservedStampSince: number | null;
unsubscribe: () => void;
wake: () => void;
};
let state: LeaseState | null = null;
let registeredDb: Db | null = null;
export function controllerInstanceLeaseEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
return env.PAPERCLIP_CONTROLLER_INSTANCE_LEASE !== "off";
}
function exitOnLostLease(reason: "fenced" | "lease_lost") {
logger.fatal({ reason }, "controller instance lease lost while holding work; stopping so a newer server can recover it");
process.exit(1);
}
/** In-progress records stamped with this boot id: the process-owned kinds of the ownership registry. */
function holdsStampedWorkSql(bootId: string) {
return sql`(
exists (select 1 from environment_leases where
(status = 'active' and metadata->'leaseController'->>'bootId' = ${bootId})
or (status = 'pending_cleanup' and metadata->>'pendingCleanupInFlight' = 'true'
and metadata->'pendingCleanupController'->>'bootId' = ${bootId}))
or exists (select 1 from sandbox_creation_operations where state = 'pending' and metadata->'creationController'->>'bootId' = ${bootId})
or exists (select 1 from workspace_operations where status in ('running', 'pending') and metadata->'finalizeOwner'->>'bootId' = ${bootId})
or exists (select 1 from agents where lifecycle_operation->'leaseHolder'->>'bootId' = ${bootId})
or exists (select 1 from native_run_finalizations where lease_owner is not null
and lease_holder->>'token' = lease_owner and lease_holder->>'bootId' = ${bootId})
or exists (select 1 from native_run_finalizations where controller_boot_id = ${bootId}
and phase not in ('committed', 'terminal_failure', 'applied'))
or exists (select 1 from heartbeat_runs where status = 'running' and controller_boot_id::text = ${bootId}))`;
}
/**
* One statement: create this boot's row on first use, and extend its lease only while the boot
* holds work (a stamp within the TTL, or an in-progress record stamped with its boot id).
* Returns whether it still holds work, or that a newer boot fenced it.
*/
function renewInDatabase(db: Db): (stampedRecently: boolean) => Promise<RenewResult> {
return async stampedRecently => {
const bootId = currentControllerBootId();
const records = holdsStampedWorkSql(bootId);
const holds = sql`(${stampedRecently} or ${records})`;
const expires = sql`now() + ${`${CONTROLLER_LEASE_TTL_MS} milliseconds`}::interval`;
const rows = await db.execute(sql`
insert into controller_instances (boot_id, process_space, pid, lease_expires_at)
values (${bootId}, ${currentProcessSpaceHash()}, ${process.pid}, ${expires})
on conflict (boot_id) do update set
lease_expires_at = case when ${holds} then ${expires} else controller_instances.lease_expires_at end,
renewed_at = case when ${holds} then now() else controller_instances.renewed_at end
where controller_instances.fenced_at is null and controller_instances.stopped_at is null
returning epoch, ${holds} as holds, ${records} as records`) as unknown as Array<{ epoch: string | number; holds: boolean; records: boolean }>;
const row = rows[0];
if (!row) return "fenced";
if (state) state.epoch = Number(row.epoch);
return row.records ? "holding" : row.holds ? "holding_stamp" : "idle";
};
}
function withTimeout<T>(work: Promise<T>, ms: number): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
return Promise.race([work, new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("controller instance lease renewal timed out")), ms);
})]).finally(() => clearTimeout(timer));
}
/**
* Registers this boot's lease with the shared delivery coordinator. The lease is renewed only
* while the boot holds work, so an idle boot issues no statements and its database can sleep.
*/
export function registerControllerInstanceLeaseWork(db: Db, coordinator: ReturnType<typeof createDeliveryWorkCoordinator>,
options: {
onLost?: (reason: "fenced" | "lease_lost") => void;
now?: () => number;
renew?: (stampedRecently: boolean) => Promise<RenewResult>;
} = {}) {
registeredDb = db;
const current: LeaseState = {
onLost: options.onLost ?? exitOnLostLease, now: options.now ?? (() => performance.now()),
renew: options.renew ?? renewInDatabase(db), lastStampAt: Number.NEGATIVE_INFINITY, failingSince: null,
active: false, epoch: null, stopped: false, unsubscribe: () => {}, wake: () => {},
lastStampWallAt: Number.NEGATIVE_INFINITY, lastTickAt: Number.NEGATIVE_INFINITY, stampSeq: 0,
unobservedStampSince: null,
};
state = current;
const worker = coordinator.register(DELIVERY_QUEUES.controllerInstanceLease, {
retryMs: RETRY_MS,
runDuringDrain: true,
run: () => tickControllerInstanceLease(),
hasPending: async () => false,
nextRunAt: () => nextLeaseDeadline(current),
});
current.wake = worker.wake;
const unsubscribeStamp = onControllerStamp(() => {
const wasActive = current.active;
current.lastStampAt = current.now();
current.lastStampWallAt = Date.now();
current.stampSeq += 1;
current.unobservedStampSince ??= Date.now();
current.active = true;
// The first stamp after an idle lapse renews at once, without delaying the stamping write.
if (!wasActive && !current.stopped) worker.wake();
});
// When the server's work settles, check holdings now rather than one renewal later,
// so an idle server issues no statement after its last work.
// The rate limit also absorbs the settle of this worker's own run.
const unsubscribeSettle = subscribeIdleWorkSettled(() => {
// All tracked work finished: any ownership write it made has committed or rolled back,
// so the holdings query is authoritative from here on.
current.unobservedStampSince = null;
if (current.active && !current.stopped && Date.now() - current.lastTickAt >= SETTLE_RECHECK_MIN_MS) worker.wake();
});
current.unsubscribe = () => { unsubscribeStamp(); unsubscribeSettle(); };
return {
ready: worker.ready,
stop: async () => {
current.unsubscribe();
await worker.stop();
},
};
}
function nextLeaseDeadline(current: LeaseState): number | null {
if (current.stopped) return null;
if (current.failingSince !== null) return Date.now() + RETRY_MS;
return current.active ? Date.now() + CONTROLLER_LEASE_RENEW_MS : null;
}
/** Renew while holding work; quiesce on failure; stop only if the failure outlasts the self-fence window with work held. */
export async function tickControllerInstanceLease(): Promise<void> {
const current = state;
if (!current || current.stopped || (!current.active && current.failingSince === null)) return;
const attemptStartedAt = current.now();
const stampSeqAtStart = current.stampSeq;
current.lastTickAt = Date.now();
// Held until a renewal sees the stamped record committed (or rolled back past the cap).
const stampedRecently = Date.now() - current.lastStampWallAt < STAMP_COMMIT_GRACE_MS
|| (current.unobservedStampSince !== null && Date.now() - current.unobservedStampSince < UNOBSERVED_STAMP_CAP_MS);
let result: RenewResult | "failed";
try { result = await withTimeout(current.renew(stampedRecently), RENEW_TIMEOUT_MS); } catch (error) {
logger.warn({ err: error }, "controller instance lease renewal failed");
result = "failed";
}
if (result === "holding" || result === "holding_stamp" || result === "idle") {
current.failingSince = null;
// Idle: the lease lapses with no further statements until the next stamp wakes it.
// A stamp taken during this attempt was not seen by its snapshot: check again now.
const stampedDuringAttempt = current.stampSeq !== stampSeqAtStart;
// A held record does not show that every stamped write has committed: another stamp's
// write may still be pending. Only the work-settled signal (or the cap) clears it.
current.active = result !== "idle" || stampedDuringAttempt;
if (result === "idle" && stampedDuringAttempt) current.wake();
setControllerLeaseQuiesce(false);
return;
}
// Fenced or unreachable: dispatch nothing new while the outcome is open.
setControllerLeaseQuiesce(true);
if (result === "fenced") { current.stopped = true; current.onLost("fenced"); return; }
current.failingSince ??= attemptStartedAt;
if (current.now() - current.failingSince >= CONTROLLER_LEASE_SELF_FENCE_MS && (current.active || stampedRecently)) {
current.stopped = true;
current.onLost("lease_lost");
}
}
/** Graceful shutdown: this boot no longer owns anything, so its stamps are gone at once. */
export async function stopControllerInstanceLease(db: Db | null = registeredDb): Promise<void> {
const current = state;
if (current) { current.stopped = true; current.unsubscribe(); }
state = null;
if (!db || current?.epoch == null) return;
await db.update(controllerInstances).set({ stoppedAt: sql`now()` })
.where(eq(controllerInstances.bootId, currentControllerBootId()))
.catch(error => logger.warn({ err: error }, "controller instance stop record failed"));
}
/**
* The lease state of another boot, for a record it last wrote at `touchedAt`. The boot's last
* sign of life is the later of its lease expiry and that write plus the TTL (a boot that resumed
* after an idle lapse renews on its first stamp). Within the grace after it, the owner is still
* treated as alive so the record stays watched; past it, the boot is fenced (only by a newer
* epoch) and its records are gone. A boot that held work exits before this point.
*/
export async function proveControllerInstance(db: Db, bootId: string, touchedAt: Date | null,
options: { readOnly?: boolean } = {}): Promise<ControllerInstanceState | null> {
const [row] = await db.select({
fenced: sql<boolean>`${controllerInstances.fencedAt} is not null`,
stopped: sql<boolean>`${controllerInstances.stoppedAt} is not null`,
live: sql<boolean>`${controllerInstances.leaseExpiresAt} > now()`,
pastGrace: sql<boolean>`greatest(${controllerInstances.leaseExpiresAt},
${touchedAt?.toISOString() ?? null}::timestamptz + ${`${CONTROLLER_LEASE_TTL_MS} milliseconds`}::interval)
< now() - ${`${CONTROLLER_LEASE_FENCE_GRACE_MS} milliseconds`}::interval`,
}).from(controllerInstances).where(eq(controllerInstances.bootId, bootId));
if (!row) return null;
if (row.stopped) return "stopped";
if (row.fenced) return "fenced";
if (row.live) return "live";
if (!touchedAt) return "expired";
if (!row.pastGrace) return "grace";
// A read-only pass reports; only a mutating pass fences (or creates this boot's row).
if (options.readOnly) return "expired";
// Only a boot that holds a lease row of its own may fence, and only an older epoch.
const epoch = await ownEpoch();
if (epoch === null) return "expired";
const [fenced] = await db.update(controllerInstances).set({ fencedAt: sql`now()`, fencedByEpoch: epoch })
.where(and(eq(controllerInstances.bootId, bootId), isNull(controllerInstances.fencedAt), isNull(controllerInstances.stoppedAt),
sql`greatest(${controllerInstances.leaseExpiresAt}, ${touchedAt.toISOString()}::timestamptz
+ ${`${CONTROLLER_LEASE_TTL_MS} milliseconds`}::interval)
< now() - ${`${CONTROLLER_LEASE_FENCE_GRACE_MS} milliseconds`}::interval`,
sql`${controllerInstances.epoch} < ${epoch}`))
.returning({ bootId: controllerInstances.bootId });
if (fenced) return "fenced";
const [after] = await db.select({ fenced: sql<boolean>`${controllerInstances.fencedAt} is not null` })
.from(controllerInstances).where(eq(controllerInstances.bootId, bootId));
return after?.fenced ? "fenced" : "expired";
}
/** This boot's epoch, creating its row on first need (one statement). Null when the lease is off or unreachable. */
async function ownEpoch(): Promise<number | null> {
const current = state;
if (!current || current.stopped) return null;
if (current.epoch === null) {
const result = await withTimeout(current.renew(false), RENEW_TIMEOUT_MS).catch(() => null);
if (result === "fenced") return null;
}
return current.epoch;
}
/** Lets the owner proof read other boots' leases, whether or not this boot holds one. */
export function registerControllerInstanceDb(db: Db): void {
registeredDb = db;
}
export function registeredControllerInstanceDb(): Db | null {
return registeredDb;
}
export function resetControllerInstanceLeaseForTests(): void {
state?.unsubscribe();
state = null;
registeredDb = null;
setControllerLeaseQuiesce(false);
}