Files
PaperClipAI/server/src/services/legacy-controller-lease.ts
T
DottaandClaude Opus 5.5 424e2c6488 fix(recovery): keep live owners' runs through an upgrade and continue replaced preparations in the restart lane (#21046)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - An upgrade replaces the server. The old and the new server serve
together for several minutes. A legacy run proves that its owner is
alive by renewing a 60-second lease on its `heartbeat_runs` row.
> - #21019 added an uncharged `server_restart` retry lane for runs that
a restart or replacement ended. On the first upgrade that shipped it, no
run used that lane. The new server also failed many runs as
`process_lost` while the old server that owned them was still serving.
> - Each of these runs then spent one failure retry. A run that was
already the last automatic retry was held for manual reconciliation, so
the task needed a person.
> - An upgrade is routine. It must not cost a task its work or a retry,
and it must not leave a task waiting for a manual Retry.
> - This pull request sends a run that a replacement stopped during
preparation to the `server_restart` lane. It also stops the reaper from
failing a run while the server process that owns it is proven alive.
> - The benefit is that a task continues by itself through an upgrade,
also when the database is slow during the swap.

## Linked Issues or Issue Description

Refs #21019, #21042

**What happened?**
A hosted staging server was replaced by an automatic upgrade. The new
server started at 06:18:46Z, became ready at 06:26:17Z, and the old
server received SIGTERM at 06:27:34Z. Between 06:23Z and 06:26Z the new
server failed 44 runs as `process_lost` ("Process lost -- server may
have restarted; retrying once",
`processLossDiagnostic.controllerReplaced: true`). The old server still
owned these runs and was still serving. Every reaped run was a Runner
run in `executionStage: preparing` with no process recorded. Each
successor was scheduled with `scheduledRetryReason: transient_failure`
and `executionRetryAccounting.failureRetries: 1`. No run on the server
has `retryReason: server_restart` or
`executionRetryAccounting.serverRestarts`.

The run evidence also shows why the leases lapsed. The old server's
lease renewals waited 19 to 50 seconds on the database
(`controllerLeaseLoss.renewalPendingMs`) while its timers fired on time
(`deadlineLateMs` 0 to 17 ms). #21042 fixes that renewal stall. This
pull request fixes how the reaper and the retry scheduler treat these
runs.

**Expected behavior**
- A server never fails a run as lost while the server process that owns
it is alive and serving.
- A run that a replacement really ended continues once, automatically,
without spending the failure budget.

**Steps to reproduce**
1. Seed a legacy `paperclip_runner` run in `executionStage: preparing`,
owned by another server boot, with a lapsed run lease.
2. Run `reapOrphanedRuns()` on the new server.
3. See that the successor is `transient_failure` with `failureRetries:
1`. When the chain already spent its failure budget, see that no
successor is created and the run is held for reconciliation.
4. Give the owner a live controller instance lease (the old server is
still serving) and run `reapOrphanedRuns()` again: the run is failed as
`process_lost` anyway.

**Root cause**
- `usesServerRestartLane` (`server/src/services/heartbeat/retries.ts`)
left every run with the bootstrap evidence `providerWorkStarted: false`
to the `interrupted_preparation` lane. But `scheduleBoundedRetryForRun`
gives that lane only to an `interrupted` run (or a native preparation).
The reaper writes the same evidence on a `failed`/`process_lost` run
that it stops during preparation. That run fell through to the charged
`transient_failure` lane. The #21019 tests used runs in `dispatching`,
so they did not catch it.
- `legacyExecutionNeedsReconciliation` held every run whose failure
budget was spent before it checked the bootstrap evidence. A run that a
replacement stopped before dispatch did no provider work, but it was
held for reconciliation, and the task waited for a person.
- The orphan reaper (`recoverActiveExecutions`,
`server/src/services/heartbeat/recovery.ts`) treated a lapsed run lease
as proof that the owner was gone. It checked the shared owner prover
only for "gone", never for "alive". During the swap the old server's
renewals were slow, so its leases lapsed while it was alive.

**Paperclip version or commit**
Staging Core `51d77648bc` (first build with #21019). The behavior is the
same on master `148d2b629c`.

## What Changed

- `retries.ts`: `usesServerRestartLane(run, preparationLaneTakesRun?)`
keeps the `interrupted_preparation` exception only when that lane really
takes the run. `scheduleBoundedRetryForRun` passes its own verdict.
Without one, only an `interrupted` run qualifies. A `process_lost` run
that a replacement stopped during preparation now continues in the
`server_restart` lane.
- `legacy-execution-recovery.ts`: a replaced preparation
(`isReplacedPreparation`) does not need reconciliation, also when its
failure budget is spent. The `server_restart` bound limits it.
- `recovery.ts` (orphan reaper):
- When a run's lease lapsed but the shared owner prover proves its owner
process alive (for example, the owner's controller instance lease is
live), the reaper leaves the run with that owner. The owner stops and
settles the run itself (`controller_lease_lost`, uncharged
`interrupted_preparation` lane). The reaper recovers the run once the
owner is gone or stopped. It also recovers the run when the owner has
not settled it within `LEGACY_LIVE_OWNER_SETTLE_GRACE_MS` (5 minutes)
after the lease lapsed.
- A setup that a replacement stopped before dispatch uses the
`server_restart` bound for its retry decision, whatever the adapter.
- `legacy-controller-lease.ts`: add `proveLegacyController(run)`, which
returns the shared prover's verdict. `legacyControllerProvenGone` uses
it, and the reaper computes the proof once per run.
- `server-replacement-interruption.ts`: add
`isReplacedPreparation(run)`: bootstrap evidence `providerWorkStarted:
false`, a recorded controller process stamp, and a server-replacement
interruption. A row saved by an older release without the stamp keeps
the charged lane (the existing documented rule).
- `legacy-execution-recovery.ts` (`settleInterruptedNativeBootstrap`): a
reconciliation hold that an older release saved on a replaced
preparation is settled once its owner is proven gone, even when the
failure budget is spent. The run continues in the `server_restart` lane.
- `legacy-controller-lease.ts`, `recovery/ownership-registry.ts`: the
`legacy_interrupted_preparation_hold` kind judges a finished run's hold
by its recorded controller process
(`finishedLegacyControllerOwnerStamp`). Revocation by the orphan reaper
swaps in a random boot id, so before this change a reaped preparation's
hold had owner "none" and was never settled.
- `recovery/service.ts`, `heartbeat.ts`: the stranded-task sweep reports
exhaustion from the scheduler's own `retry_exhausted` result, so a spent
`interrupted_preparation` lane blocks the task even when the synchronous
budget check reads an unspent restart bound.
- `doc/execution-semantics.md`: document these rules.
- No schema change. Nothing changes on the path that starts a run or a
turn (no new waits and no file work).

## Verification

- Red on `origin/master`, green on this branch
(`server/src/__tests__/heartbeat-process-recovery.test.ts`, block "runs
ended by a server restart or replacement"):
- "continues a first attempt / a run that was already the last automatic
retry that a replaced server left preparing, in the restart lane". This
is the production shape: a `paperclip_runner` run in `preparing`, two
servers sweep at the same time. Master: `transient_failure` with
`failureRetries: 1`, and no successor for the spent chain. Now: one
`server_restart` continuation, due at once, with the failure count
unchanged.
- "leaves a preparing/dispatching run whose lease lapsed with its owner,
then recovers it once the owner stops". The owner's controller instance
lease is live and two servers sweep. Master: `process_lost` at once.
Now: the run stays `running` with its owner. After the owner stops, it
becomes `process_lost` and a `server_restart` continuation is scheduled.
- "recovers the run when its live owner has not settled it within the
grace".
- `interrupted-preparation-hold-settle.test.ts`: "releases an older
release's hold on a preparation a server replacement stopped with its
failure budget spent" for an unrevoked and a reaper-revoked lease (red
without the settlement and owner-stamp changes) and "blocks the task
when a replaced preparation's lane is spent even though the restart
bound is not" (red without the sweep change).
- Related suites: heartbeat process recovery, retry scheduling, orphaned
active lease sweep, recovery deadline (bootstrap and ordinary
bootstrap), legacy continuation authority, undispatched execution wait,
agent status recovery, legacy workspace restore recovery, legacy
execution recovery, legacy controller lease, server replacement
interruption, heartbeat recovery, provider failure classification,
reconciliation run read, and wake-queue policy.
- `tsc --noEmit` for `server`.

## Risks

- A run whose owner is alive but no longer drives it now stays `running`
for up to 5 minutes after its lease lapsed, and then the reaper recovers
it. The owner's own sweep still recovers a run that the owner itself
lost the handle for (this boot is never deferred).
- The guard trusts the shared owner prover. That prover reads "alive"
only from a live or grace controller instance lease, or from a live PID
in the same PID space. A server whose instance lease cannot renew for 60
seconds stops itself, so a stuck owner cannot hold runs forever.
- More runs continue in the uncharged lanes. The `server_restart` bound
(6 in 60 minutes) still stops a loop, and the stop is shown on the task.
- Rows saved by older releases: a `process_lost` row with the bootstrap
evidence and an old-boot owner now continues in the `server_restart`
lane instead of being held. These runs did no provider work.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use (code
search, shell, test execution, read-only log and API inspection) 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-12 02:18:39 -06:00

439 lines
21 KiB
TypeScript

import { notifyLiveRunRecovery } from "./live-run-recovery-notifications.js";
import { and, eq, gt, lte, sql } from "drizzle-orm";
import { controllerLeaseDb, heartbeatRuns, type Db } from "@paperclipai/db";
import type { LegacyControllerLeaseLossCause, LegacyControllerLeaseLossTiming } from "./run-cancellation.js";
import { randomUUID } from "node:crypto";
import {
currentControllerBootId,
currentControllerProcessStamp,
readControllerProcess,
} from "./controller-process-identity.js";
import { proveOwnerGone, type LocalOwnerStamp, type OwnerProof } from "./owner-proof.js";
import type { InProgressRecord, SettleOutcome } from "./recovery/ownership-registry.js";
// A boot UUID has meaning across containers; a numeric PID does not. Legacy
// leases stamp the single process boot id shared with native claims.
export const legacyControllerBootId = currentControllerBootId();
export const LEGACY_CONTROLLER_LEASE_MS = 60_000;
export const LEGACY_CONTROLLER_RENEW_MS = 10_000;
type Run = typeof heartbeatRuns.$inferSelect;
/** A lease lost before adapter dispatch: no provider work began, so the
* continuation is retryable rather than an opaque `setup_failed`. */
export const CONTROLLER_LEASE_LOST_FAILURE_CODE = "controller_lease_lost";
/** Abort reason for a lost lease, so completion can attribute the stop. */
export class LegacyControllerLeaseLostError extends Error {
constructor(
readonly leaseLossCause: LegacyControllerLeaseLossCause,
readonly leaseLossTiming?: LegacyControllerLeaseLossTiming,
) {
super("Legacy controller lease lost");
this.name = "LegacyControllerLeaseLostError";
}
}
/** Commit these fields in the same UPDATE that claims a queued run. */
export function legacyControllerClaim(runtimeMode: string) {
if (runtimeMode === "native") return {};
return {
legacyProcessSettlement: null,
controllerBootId: legacyControllerBootId,
// This process, so a hard-killed controller is proven gone on the shared
// ownership path (no I/O: the claim is on the dispatch path).
controllerProcess: currentControllerProcessStamp(),
controllerLeaseExpiresAt: sql`clock_timestamp() + interval '60 seconds'`,
executionStage: "preparing",
};
}
type RenewalTarget = Pick<Run, "id" | "companyId"> & { stage?: "dispatching" };
type RenewalOutcome = "renewed" | "rejected" | "busy";
async function renewLegacyControllerLeasesInTransaction(
tx: Db,
targets: RenewalTarget[],
): Promise<Map<string, RenewalOutcome>> {
const requested = sql.join(targets.map(target => sql`(${target.id}::uuid, ${target.companyId}::uuid)`), sql`, `);
const dispatching = targets.filter(target => target.stage === "dispatching").map(target => sql`${target.id}::uuid`);
const stage = dispatching.length
? sql`case when r.id in (${sql.join(dispatching, sql`, `)}) then 'dispatching' else r.execution_stage end`
: sql`r.execution_stage`;
const rows = await tx.execute<{ id: string; renewed: boolean; examined: boolean }>(sql`
with requested(id, company_id) as (values ${requested}),
examined as (
select r.id from ${heartbeatRuns} r join requested q on r.id = q.id and r.company_id = q.company_id
for update of r skip locked
),
renewed as (
update ${heartbeatRuns} r set controller_lease_expires_at = clock_timestamp() + interval '60 seconds',
execution_stage = ${stage}
from examined e
where r.id = e.id and r.runtime_mode = 'legacy' and r.status = 'running'
and r.controller_boot_id = ${legacyControllerBootId}
and r.controller_lease_expires_at > clock_timestamp()
returning r.id
)
select q.id::text as id, exists (select 1 from renewed n where n.id = q.id) as renewed,
exists (select 1 from examined e where e.id = q.id) as examined
from requested q`);
const outcomes = new Map<string, RenewalOutcome>();
for (const row of rows) outcomes.set(row.id, row.renewed ? "renewed" : row.examined ? "rejected" : "busy");
return outcomes;
}
export async function renewLegacyControllerLease(
db: Db,
run: Pick<Run, "id" | "companyId" | "controllerBootId">,
stage?: "dispatching",
): Promise<boolean> {
const outcomes = await db.transaction(tx => renewLegacyControllerLeasesInTransaction(tx as unknown as Db,
[{ id: run.id, companyId: run.companyId, stage }]));
return outcomes.get(run.id) === "renewed";
}
/** One attempt may wait at most this long; a timed-out attempt is retried
* until the lease deadline instead of holding the renewal for the whole lease. */
export const LEGACY_CONTROLLER_RENEWAL_STATEMENT_TIMEOUT_MS = 10_000;
/** Pause before retrying a renewal that failed with an error, so a dropped
* connection is retried without spinning against an unavailable database. */
export const LEGACY_CONTROLLER_RENEWAL_RETRY_DELAY_MS = 1_000;
export const LEGACY_CONTROLLER_RENEWAL_BUSY_RETRY_DELAY_MS = 250;
function isStatementTimeout(error: unknown): boolean {
for (let cause = error, depth = 0; cause && depth < 4; cause = (cause as { cause?: unknown }).cause, depth++) {
if ((cause as { code?: unknown }).code === "57014") return true;
}
return false;
}
type RenewalResult = { outcome: RenewalOutcome; issuedAt: number };
type RenewalRequest = {
target: RenewalTarget;
statementTimeoutMs: number;
resolve(result: RenewalResult): void;
reject(error: unknown): void;
};
type RenewalBatch = { pending: RenewalRequest[]; flushing: boolean };
const renewalBatches = new WeakMap<object, RenewalBatch>();
function renewThroughBatch(db: Db, target: RenewalTarget, statementTimeoutMs: number): Promise<RenewalResult> {
let batch = renewalBatches.get(db);
if (!batch) renewalBatches.set(db, batch = { pending: [], flushing: false });
const outcome = new Promise<RenewalResult>((resolve, reject) => {
batch.pending.push({ target, statementTimeoutMs, resolve, reject });
});
if (!batch.flushing) void flushRenewals(db, batch);
return outcome;
}
async function flushRenewals(db: Db, batch: RenewalBatch) {
batch.flushing = true;
try {
await Promise.resolve();
while (batch.pending.length) {
const requests = batch.pending.splice(0);
const targets = new Map<string, RenewalTarget>();
for (const { target } of requests) {
const existing = targets.get(target.id);
targets.set(target.id, existing ? { ...existing, stage: existing.stage ?? target.stage } : target);
}
try {
const statementTimeoutMs = Math.min(...requests.map(request => request.statementTimeoutMs));
let issuedAt = 0;
const outcomes = await controllerLeaseDb(db).transaction(async (tx) => {
issuedAt = Date.now();
await tx.execute(sql`select set_config('statement_timeout', ${`${statementTimeoutMs}ms`}, true)`);
return renewLegacyControllerLeasesInTransaction(tx as unknown as Db, [...targets.values()]);
});
for (const request of requests) {
const sameCompany = targets.get(request.target.id)!.companyId === request.target.companyId;
request.resolve({ outcome: sameCompany ? outcomes.get(request.target.id) ?? "rejected" : "rejected", issuedAt });
}
} catch (error) {
for (const request of requests) request.reject(error);
}
}
} finally {
batch.flushing = false;
}
}
export async function liveLegacyControllerExpiresAt(db: Db, run: Run): Promise<Date | null> {
if (run.runtimeMode === "native" || !run.controllerBootId) return null;
const [owner] = await db.select({ expiresAt: heartbeatRuns.controllerLeaseExpiresAt }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, run.id), eq(heartbeatRuns.companyId, run.companyId),
eq(heartbeatRuns.status, "running"),
gt(heartbeatRuns.controllerLeaseExpiresAt, sql`clock_timestamp()`),
));
return owner?.expiresAt ?? null;
}
export async function hasLiveLegacyController(db: Db, run: Run): Promise<boolean> {
return (await liveLegacyControllerExpiresAt(db, run)) !== null;
}
export type RevokedLegacyControllerState = Pick<Run, "executionStage" | "processPid" | "processGroupId">;
/** Atomically revoke an expired controller. Renewal and revocation serialize on
* the run row. Expiry permits cleanup, never dispatch of a replacement agent.
* Returns the stage and process identifiers committed with the revocation, or
* null when another writer owns the row. They cannot advance afterwards,
* because renewal requires the replaced boot ID. */
export async function revokeExpiredLegacyControllerWithState(
db: Db,
run: Run,
): Promise<RevokedLegacyControllerState | null> {
if (run.runtimeMode === "native" || !run.controllerBootId) {
return { executionStage: run.executionStage, processPid: run.processPid, processGroupId: run.processGroupId };
}
const controllerBootId = run.controllerBootId;
const [revoked] = await db.transaction(async tx => {
await notifyLiveRunRecovery(tx, { companyId: run.companyId, runId: run.id });
return tx.update(heartbeatRuns).set({
controllerBootId: randomUUID(),
controllerLeaseExpiresAt: sql`clock_timestamp() + interval '60 seconds'`,
}).where(and(
eq(heartbeatRuns.id, run.id), eq(heartbeatRuns.companyId, run.companyId),
eq(heartbeatRuns.status, "running"),
eq(heartbeatRuns.controllerBootId, controllerBootId),
lte(heartbeatRuns.controllerLeaseExpiresAt, sql`clock_timestamp()`),
)).returning({
executionStage: heartbeatRuns.executionStage,
processPid: heartbeatRuns.processPid,
processGroupId: heartbeatRuns.processGroupId,
});
});
return revoked ?? null;
}
/**
* The recorded controller process of a legacy lease (plan Phase 2), or null.
* `controller_process` names the controller only while its boot id equals
* `controller_boot_id`: revocation swaps in a random boot id, so a revoked
* lease never keeps it. Rows saved by the previous release have none; their
* lease stays a boot-id fence (expiry, revocation and the age guard).
*/
export function legacyControllerOwnerStamp(
run: Pick<Run, "controllerBootId" | "controllerProcess">,
): LocalOwnerStamp | null {
const controller = readControllerProcess(run.controllerProcess);
if (!controller || !run.controllerBootId || controller.bootId !== run.controllerBootId) return null;
return { kind: "local", scope: "legacy_controller_lease", generation: null, host: null,
processSpace: controller.hostHash, bootId: controller.bootId, pid: controller.pid,
processStartedAt: controller.processStartedAt, processGroupId: null };
}
/**
* The controller process that ran a run which is already terminal, also when
* the orphan reaper revoked its lease: revocation swaps in a random boot id
* but keeps the recorded process. Only for judging holds on finished runs,
* never a live lease (legacyControllerOwnerStamp owns those).
*/
export function finishedLegacyControllerOwnerStamp(
run: Pick<Run, "status" | "controllerBootId" | "controllerProcess">,
): LocalOwnerStamp | null {
const current = legacyControllerOwnerStamp(run);
if (current || run.status === "running" || run.status === "queued") return current;
const controller = readControllerProcess(run.controllerProcess);
return controller ? legacyControllerOwnerStamp({ controllerBootId: controller.bootId, controllerProcess: run.controllerProcess })
: null;
}
/** The shared prover's verdict on the recorded legacy controller, or null when
* the row names none (a native run, a revoked lease, or an older row). */
export async function proveLegacyController(
run: Pick<Run, "runtimeMode" | "controllerBootId" | "controllerProcess">,
): Promise<OwnerProof | null> {
if (run.runtimeMode === "native") return null;
const stamp = legacyControllerOwnerStamp(run);
return stamp === null ? null : proveOwnerGone(stamp);
}
/** True only when the shared prover proves the recorded legacy controller gone. */
export async function legacyControllerProvenGone(
run: Pick<Run, "runtimeMode" | "controllerBootId" | "controllerProcess">,
): Promise<boolean> {
return (await proveLegacyController(run))?.verdict === "gone";
}
/**
* How long a run whose lease lapsed stays with its owner while the owner's
* server process is proven alive. The owner settles the run itself: its local
* deadline stops the adapter and records `controller_lease_lost`. A run still
* running after this grace has an owner that no longer drives it, and the
* orphan reaper recovers it.
*/
export const LEGACY_LIVE_OWNER_SETTLE_GRACE_MS = 5 * 60_000;
/**
* The boot id of a run's legacy controller whose lease has not expired yet but
* whose recorded process is proven gone, or null. A drained server terminalizes
* its runs and exits with their leases unexpired; nothing of that controller is
* left to unwind a Stop.
*/
export async function provenGoneLegacyControllerBootId(db: Db, companyId: string, runId: string): Promise<string | null> {
const [run] = await db.select({ runtimeMode: heartbeatRuns.runtimeMode, controllerBootId: heartbeatRuns.controllerBootId,
controllerProcess: heartbeatRuns.controllerProcess }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.runtimeMode, "legacy"),
gt(heartbeatRuns.controllerLeaseExpiresAt, sql`clock_timestamp()`)));
if (!run?.controllerBootId) return null;
return await legacyControllerProvenGone(run) ? run.controllerBootId : null;
}
/**
* Settle of the `legacy_controller_lease` ownership kind. The shared
* reconciler calls it only after `proveOwnerGone` proved the recorded
* controller gone. It expires exactly that controller's outstanding lease
* (compare-and-swap on run, boot id and recorded pid) after waking live-run
* recovery, which then revokes it and settles the run as a lost process under
* its own checks (a live child process still keeps it running). It settles
* ownership only.
*/
export async function settleLegacyControllerLease(db: Db, record: InProgressRecord,
context: { now: Date; proof: OwnerProof }): Promise<SettleOutcome> {
if (record.owner.kind !== "process" || !record.companyId) return { settled: false, reason: "owner_not_a_process" };
const stamp = record.owner.stamp;
if (!stamp.bootId) return { settled: false, reason: "owner_not_a_process" };
const companyId = record.companyId;
const bootId = stamp.bootId;
return db.transaction(async transaction => {
const tx = transaction as unknown as Db;
const [row] = await tx.select({ agentId: heartbeatRuns.agentId, controllerProcess: heartbeatRuns.controllerProcess,
controllerBootId: heartbeatRuns.controllerBootId })
.from(heartbeatRuns).where(and(eq(heartbeatRuns.id, record.recordId), eq(heartbeatRuns.companyId, companyId),
eq(heartbeatRuns.runtimeMode, "legacy"), eq(heartbeatRuns.status, "running"),
eq(heartbeatRuns.controllerBootId, bootId), gt(heartbeatRuns.controllerLeaseExpiresAt, sql`clock_timestamp()`)))
.for("update");
if (!row) return { settled: false, reason: "lease_no_longer_held" };
const current = legacyControllerOwnerStamp(row);
// The proof judged this exact process; the lease has moved on since.
if (!current || current.pid !== stamp.pid || current.processSpace !== stamp.processSpace
|| (current.processStartedAt ?? null) !== (stamp.processStartedAt ?? null)) {
return { settled: false, reason: "owner_changed_since_proof" };
}
// Work intent before the write it announces (sleep-aware background work).
await notifyLiveRunRecovery(tx, { companyId, runId: record.recordId, agentId: row.agentId });
const updated = await tx.update(heartbeatRuns).set({ controllerLeaseExpiresAt: sql`clock_timestamp()` })
.where(and(eq(heartbeatRuns.id, record.recordId), eq(heartbeatRuns.companyId, companyId),
eq(heartbeatRuns.status, "running"), eq(heartbeatRuns.controllerBootId, bootId),
gt(heartbeatRuns.controllerLeaseExpiresAt, sql`clock_timestamp()`)))
.returning({ id: heartbeatRuns.id });
return updated.length ? { settled: true, reason: context.proof.reason } : { settled: false, reason: "lease_no_longer_held" };
});
}
export async function revokeExpiredLegacyController(db: Db, run: Run): Promise<boolean> {
return (await revokeExpiredLegacyControllerWithState(db, run)) !== null;
}
/** Abort the adapter if the controller cannot renew. Bound each check by the
* lease duration even when the database connection never settles. */
export function watchLegacyControllerLease(
db: Db,
run: Run,
controller: AbortController,
{ renewalStatementTimeoutMs = LEGACY_CONTROLLER_RENEWAL_STATEMENT_TIMEOUT_MS } = {},
) {
if (run.runtimeMode === "native" || !run.controllerBootId) {
return { stop() {}, async assertOwned(_stage?: "dispatching") {} };
}
let stopped = false;
let pending = false;
// Timing evidence for a loss: a renewal still in flight at the deadline
// points at the database; a late deadline callback points at the event loop.
let lastRenewedAt = Date.now();
let renewalCount = 0;
// A non-timeout error since the last successful renewal, so a deadline that
// passes during the retries is attributed to the error, not to a stall.
let renewalFailed = false;
const renewalsInFlight = new Set<{ startedAt: number }>();
let deadlineDueAt = 0;
let deadline: ReturnType<typeof setTimeout> | undefined;
const lost = (cause: LegacyControllerLeaseLossCause, atDeadline = false) => {
if (stopped || controller.signal.aborted) return;
const now = Date.now();
const oldestRenewal = Math.min(...[...renewalsInFlight].map((renewal) => renewal.startedAt));
controller.abort(new LegacyControllerLeaseLostError(cause, {
sinceRenewalMs: now - lastRenewedAt,
renewalPendingMs: renewalsInFlight.size > 0 ? now - oldestRenewal : null,
deadlineLateMs: atDeadline ? Math.max(0, now - deadlineDueAt) : null,
renewalCount,
}));
};
const deadlinePassed = () => lost(renewalFailed ? "renewal_error" : "renewal_deadline", true);
const armDeadline = (delayMs: number) => {
clearTimeout(deadline);
deadlineDueAt = Date.now() + delayMs;
deadline = setTimeout(deadlinePassed, delayMs);
deadline.unref();
};
armDeadline(Math.max(0, (run.controllerLeaseExpiresAt?.getTime() ?? 0) - Date.now()));
const assertOwned = async (stage?: "dispatching") => {
if (stopped) return;
controller.signal.throwIfAborted();
const startedAt = Date.now();
const renewal = { startedAt };
renewalsInFlight.add(renewal);
let onAbort!: () => void;
const aborted = new Promise<never>((_, reject) => {
onAbort = () => reject(controller.signal.reason);
controller.signal.addEventListener("abort", onAbort, { once: true });
});
// The successful attempt's UPDATE writes a fresh lease, so the local
// deadline is measured from that attempt rather than the first one.
// Failed attempts are retried until the deadline aborts the controller:
// the UPDATE is idempotent and still requires this controller's boot ID.
let renewedFrom = startedAt;
const renew = async () => {
for (;;) {
try {
const { outcome, issuedAt } = await renewThroughBatch(db, { id: run.id, companyId: run.companyId, stage },
renewalStatementTimeoutMs);
renewedFrom = issuedAt;
if (outcome !== "busy") return outcome === "renewed";
await new Promise((resolve) => setTimeout(resolve, LEGACY_CONTROLLER_RENEWAL_BUSY_RETRY_DELAY_MS));
if (stopped || controller.signal.aborted) return false;
} catch (error) {
if (stopped || controller.signal.aborted) throw error;
// A statement timeout is a stall; it stays a `renewal_deadline` loss.
if (!isStatementTimeout(error)) {
renewalFailed = true;
await new Promise((resolve) => setTimeout(resolve, LEGACY_CONTROLLER_RENEWAL_RETRY_DELAY_MS));
if (stopped || controller.signal.aborted) throw error;
}
}
}
};
let renewed: boolean;
try {
renewed = await Promise.race([renew(), aborted]);
} finally {
renewalsInFlight.delete(renewal);
controller.signal.removeEventListener("abort", onAbort);
}
if (stopped) return;
if (!renewed) {
lost("renewal_rejected");
controller.signal.throwIfAborted();
}
controller.signal.throwIfAborted();
if (!stopped) {
lastRenewedAt = Date.now();
renewalCount += 1;
renewalFailed = false;
armDeadline(Math.max(0, LEGACY_CONTROLLER_LEASE_MS - (Date.now() - renewedFrom)));
}
};
const timer = setInterval(() => {
if (pending || stopped) return;
pending = true;
void assertOwned().catch(() => lost("renewal_error")).finally(() => { pending = false; });
}, LEGACY_CONTROLLER_RENEW_MS);
timer.unref();
return { assertOwned, stop() { stopped = true; clearInterval(timer); clearTimeout(deadline); } };
}