mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:56:58 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Legacy (non-native) runs hold a controller lease on `heartbeat_runs` while the server prepares and dispatches the adapter. The shared ownership reconciler now settles proven-gone owners (Phase 2 of the durable ownership plan) > - The lease stamped only a boot id. The reconciler could never prove its owner gone (`missing_identity` in the Phase 2 data), and recovery released it by age: lease expiry (60 s), then a 5-minute age guard on dispatched runs > - A hard-killed server's legacy run therefore stayed `running` for up to 6 minutes after the next boot > - This pull request records the controller process with the claim, registers a compare-and-swap settle, and makes live-run recovery use the shared prover instead of the age guard when the controller is proven gone > - The benefit is that a dead controller's run is settled at once, while a live, unprovable, or previous-release controller keeps today's behavior ## Linked Issues or Issue Description Refs #19933 (the settle hook, merged). Stacked on #19950 (migration 0446, registry) and targets its branch until it merges; then it retargets to master. **What happened?** After a hard kill, a dispatched legacy run stayed `running` until its controller lease expired and its last update was 5 minutes old. **What did you expect?** The next boot proves the old controller gone and fails the run as a lost process at once. **Steps to reproduce** 1. Start a legacy (non-native) run and let it dispatch the adapter. 2. Kill the server (SIGKILL). 3. Start the server. The run stays `running` for up to 6 minutes. ## What Changed - `heartbeat_runs.controller_process` (jsonb, migration 0447): `{bootId, pid, hostHash, processStartedAt}` of the claiming server process. It names the controller only while its `bootId` equals `controller_boot_id`. Revocation swaps the boot id, so a revoked lease never keeps it. - `legacyControllerClaim` writes it in the same UPDATE (from `currentControllerProcessStamp()`, no I/O). - `legacy-controller-lease.ts`: `legacyControllerOwnerStamp`, `legacyControllerProvenGone` (calls `proveOwnerGone`), and `settleLegacyControllerLease` (compare-and-swap expiry of the proven controller's outstanding lease after it wakes live-run recovery). - `ownership-registry.ts`: `legacy_controller_lease` registers `settle` and `listForRuns`. The owner is the bound process; other rows are boot-id fences. The listing covers outstanding leases only. - `heartbeat/recovery.ts` (3 small hunks): a controller of another boot that the shared prover proves gone has its live lease settled through `settleOwnableKind` and skips the age guard. The local child-process check after it is unchanged, so a live child still keeps the run running. - Migration 0447 builds the partial index with `CREATE INDEX CONCURRENTLY` (heartbeat_runs is a known-large table) and is added to `MIGRATIONS_WITH_TOP_LEVEL_STATEMENTS`. Bespoke code deleted: none. The kind had no liveness proof; the age guard stays for controllers that are not proven gone. ## Verification - `cd server && npx vitest run src/services/legacy-controller-lease.test.ts`: 33 passed. - `cd server && npx vitest run src/__tests__/ownership-reconciliation-report.test.ts`: 17 passed. - Red with `recovery.ts` and `ownership-registry.ts` from the base commit (tests, schema and lease module from this branch): 4 failed, 29 passed. Red: the crash-point reaper test, the expired-lease age-guard test, the reconciler settle test, the exact-controller CAS test. The tests were written after the code. - New tests: - the claim stamps this process, and a changed boot id unbinds it; - crash point: a controller dispatched the adapter and was killed with its lease live; the startup reaper (default 5-minute age guard) fails the run as `process_lost` at once; - a dead controller's expired lease skips the age guard; - counter-cases keep the run and lease through the reaper and a reconciler pass: a live earlier-boot PID, a dead PID in another PID space, and a previous-release row with no `controller_process`; an expired lease of a live controller stays behind the age guard; - a read-only pass (`record: false`) never settles; a recording pass expires the lease and leaves the run to live-run recovery; - settle refuses a controller that changed after the proof. - Not run locally: typecheck (CI runs it) and the wide suites. ## Risks - Previous-release story (rules/upgrade-safe-run-state.mdc): rows saved by the previous release have no `controller_process`. They stay boot-id fences with today's release (expiry, revocation, age guard). A rollback ignores the column. - Unprovable controllers (another host or PID space, unknown space, a live PID with an unreadable start time) never release early. Their handoff stays the lease expiry plus revocation, and the age guard. A different host is never proof. - Behavior change: a dispatched run whose controller is proven gone is failed as `process_lost` without the 5-minute wait. The retry decision is unchanged. - The settle expires the lease only. Live-run recovery still settles the run under its own checks. Accounting and workspace obligations are not touched. - The index build is concurrent. A cancelled build is retried by the migrator's invalid-index handling. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with tool use (shell, file edits, local test runs). ## 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>
328 lines
15 KiB
TypeScript
328 lines
15 KiB
TypeScript
import { notifyActiveExecutionRecovery, notifyLiveRunRecovery } from "./live-run-recovery-notifications.js";
|
|
import { and, eq, gt, lte, sql } from "drizzle-orm";
|
|
import { heartbeatRuns, livenessDb, 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",
|
|
};
|
|
}
|
|
|
|
async function renewLegacyControllerLeaseInTransaction(
|
|
tx: Db,
|
|
run: Pick<Run, "id" | "companyId" | "controllerBootId">,
|
|
stage?: "dispatching",
|
|
): Promise<boolean> {
|
|
await notifyActiveExecutionRecovery(tx, { companyId: run.companyId, runId: run.id });
|
|
const [renewed] = await tx.update(heartbeatRuns).set({
|
|
controllerLeaseExpiresAt: sql`clock_timestamp() + interval '60 seconds'`,
|
|
...(stage ? { executionStage: stage } : {}),
|
|
}).where(and(
|
|
eq(heartbeatRuns.id, run.id), eq(heartbeatRuns.companyId, run.companyId),
|
|
eq(heartbeatRuns.runtimeMode, "legacy"), eq(heartbeatRuns.status, "running"),
|
|
eq(heartbeatRuns.controllerBootId, legacyControllerBootId),
|
|
gt(heartbeatRuns.controllerLeaseExpiresAt, sql`clock_timestamp()`),
|
|
)).returning({ id: heartbeatRuns.id });
|
|
return Boolean(renewed);
|
|
}
|
|
|
|
export async function renewLegacyControllerLease(
|
|
db: Db,
|
|
run: Pick<Run, "id" | "companyId" | "controllerBootId">,
|
|
stage?: "dispatching",
|
|
): Promise<boolean> {
|
|
return db.transaction(tx => renewLegacyControllerLeaseInTransaction(tx as unknown as Db, run, stage));
|
|
}
|
|
|
|
/** 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;
|
|
|
|
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;
|
|
}
|
|
|
|
/** Renew on the private liveness pool so the write cannot queue behind an
|
|
* unrelated slow statement on a busy shared connection. */
|
|
async function renewOnLivenessPool(
|
|
db: Db,
|
|
run: Pick<Run, "id" | "companyId" | "controllerBootId">,
|
|
stage: "dispatching" | undefined,
|
|
statementTimeoutMs: number,
|
|
): Promise<boolean> {
|
|
return livenessDb(db).transaction(async (tx) => {
|
|
await tx.execute(sql`select set_config('statement_timeout', ${`${statementTimeoutMs}ms`}, true)`);
|
|
return renewLegacyControllerLeaseInTransaction(tx as unknown as Db, run, stage);
|
|
});
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
/** True only when the shared prover proves the recorded legacy controller gone. */
|
|
export async function legacyControllerProvenGone(
|
|
run: Pick<Run, "runtimeMode" | "controllerBootId" | "controllerProcess">,
|
|
): Promise<boolean> {
|
|
if (run.runtimeMode === "native") return false;
|
|
const stamp = legacyControllerOwnerStamp(run);
|
|
return stamp !== null && (await proveOwnerGone(stamp)).verdict === "gone";
|
|
}
|
|
|
|
/**
|
|
* 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 attemptStartedAt = startedAt;
|
|
const renew = async () => {
|
|
for (;;) {
|
|
attemptStartedAt = Date.now();
|
|
try {
|
|
return await renewOnLivenessPool(db, run, stage, renewalStatementTimeoutMs);
|
|
} 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() - attemptStartedAt)));
|
|
}
|
|
};
|
|
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); } };
|
|
}
|