Files
PaperClipAI/server/src/services/native-runtime/native-restart-recovery.ts
T
DottaandPaperclip 16643a6e03 fix(runner): recover pending Stop cleanup after controller restart (#18682)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The server owns execution, Stop delivery, and usage settlement.
> - A Stop can remain pending when the controller restarts.
> - Recovery must reach the existing session without starting more paid
work.
> - Startup and budget fences currently block that cleanup, and
interrupted runs leave the restart selection.
> - This pull request gives exact audited Stop cleanup a shared
authority check and records its real acknowledgement.
> - The benefit is safe cleanup while usage debt remains honest.

## Linked Issues or Issue Description

**What happened?**

A pending native run-only Stop can retain an unfinished execution owner
after controller loss. Its startup cancellation fence prevents recovery
admission. If teardown marks the run interrupted and clears its task
lock, restart selection also omits it. A recovered session can receive
cancellation without the durable acknowledgement that the normal Stop
request would save.

**Expected behavior**

Recover only the existing remote session for the audited pending Stop.
Keep fresh provider work blocked, including during budget pause. Keep
incomplete usage pending until a real complete receipt arrives.

**Steps to reproduce**

1. Start a native run with a held reservation and an authenticated
remote checkpoint.
2. Persist a run-only Stop and its startup and execution cancellation
fences before acknowledgement.
3. Lose the controller. Leave the coordinator unfinished, mark the run
interrupted, and clear its task lock.
4. Attempt restart recovery and observe the missing claim or
cancellation admission rejection.

Related work: #14733 binds caller Stop requests. #18658 handles plugin
startup cleanup obligations. #18035 proposes broader accounting changes.
This change repairs existing-session Stop recovery and preserves current
accounting guards.

## What Changed

- Add one shared check for the exact run, company, issue, pending Stop,
intent audit, live recovery lease, generation, and remote reattachment
disposition.
- Include eligible interrupted Stop owners in restart selection. Keep
generic interruptions and another task execution owner fenced.
- Reuse only the original held reservation for cleanup. Keep budget
pause effective for fresh, bootstrap, dead-runner, and unproven recovery
work.
- Apply the same cleanup authority at heartbeat runtime selection and
native entry. Skip attachment staging and warm-owner retirement only for
that exact cleanup. Revalidate at session publication and fail closed if
the proof changed.
- Send the existing Stop through the audited cancellation producer
before turn submission. Close the coordinator only after
acknowledgement, including system-owned run-only Stops.
- Give verified cleanup a transport purpose that retains pending
semantic inputs without replay, dispatch, or a semantic reply. Add an
explicit Core retain-only intake mode; preserve authenticated identity,
schema, digest, sequence and replay checks. Ordinary execution still
requires its dispatcher.
- Add PostgreSQL, native protocol, and startup-fence regressions. Update
the execution contract and recovery maps.

## Verification

- The combined Stop regression fails on unchanged production at the
cancellation reservation guard. Interrupted restart selection also fails
before the repair while its running sibling passes.
- An assembled `heartbeatService.recoverNativeRunsAfterRestart`
regression exposes the earlier runtime-selection fence with the rest of
the repair present. The fixed path reaches selector/claim, runtime
selection, original held-budget reuse during pause, dispatch, real
native protocol, audited cancellation, and heartbeat settlement. It
starts no provider turn, acquires no new sandbox, and does not reclaim
the task lock.
- Fail-if-called attachment staging and turn boundaries remain
untouched. A retained warm-owner regression prevents retirement.
Invalidation at session publication fails closed. Actual backend/driver
constructor tests verify cleanup-purpose forwarding, absent default
options, and denial under foreign ownership.
- Missing usage retains accounting debt and the hold. A later complete
receipt records one 25-cent charge and settles that same hold.
Concurrent duplicate cleanup is denied. Both user-owned and system-owned
Stops pass.
- Real transport tests: 9 pass on Node 24.22.1. They preserve ordinary
saved-tool and completion recovery, prove the authority-denial race
before cancellation, and qualify cleanup with pending
ordinary/completion inputs and either handler ordering.
- Real authenticated Core intake: 11 pass. Callback-present and
callback-absent retain-only cases persist and acknowledge unanswered
input without dispatch, recovery or result. Default missing-dispatcher
rejection and digest-integrity/suspension guards remain effective.
Omitting the callback alone first failed by disconnecting; the explicit
retain-only mode fixes that defect.
- Repository `pnpm -r typecheck`, the server/runner-vendor build, map
checks, and module boundary checks passed locally before integrating the
latest retained-startup change. That integration passes 11 native
startup/cleanup compatibility cases and 3 assembled recovery/accounting
cases. The final broad affected-suite attempt was stopped without a
verdict; it is incomplete. Full repository tests and build are delegated
to current-head GitHub CI.
- Review exposed an additional real-process close failure with
unanswered input. The corrected path requires a fresh exact
provider-stop receipt confirming exit, followed by durable event drain
and exact runner suspension. All 11 focused transport cases pass,
including missing/false exit-proof negatives and ordinary recovery
siblings. Runner TypeScript and map validation pass after this
correction.
- The assembled test controls provider, workspace-transfer and GitHub
bootstrap I/O. It does not qualify hosted credentials, physical provider
termination, workspace preservation, or historical receipt recovery.
Local socket-permission setup failures are separate from semantic
regression results. No hosted replay has run on this repair.

- CI exposed an ordinary factory-call compatibility regression and an
obsolete authority-read test fixture. The repair preserves
single-argument ordinary calls and updates the race fixture without
changing its Stop assertions. The complete native executor suite now
passes all 1,049 tests; the ordinary factory boundary and all five
Stop-accounting cases pass. A separate local environment-default case
twice exceeded its five-second wait while other work ran; its prior-head
CI case passed. The same unchanged case passed an isolated diagnostic
without changing its assertion or timeout; the precise contention source
remains unproven.

- The final fixture edit initially used the wrong overloaded promise
type. CI rejected it; the annotation-only correction passes the exact
`pnpm run typecheck:build-gaps` entry point locally, including server
service tests and remaining workspaces. No runtime assertion changed.

- Final exact head `007fd4d04a2d6338c6dc073e97ab470a277fc3d0`: required
`ci / verify` and `ci / e2e`, build, typecheck and test groups pass.
Greptile is 5/5 with both findings resolved. Three expected checks are
skipped; the optional native check is neutral, while its final
current-head comment gives 5/5 from source inspection without a checkout
or test execution. The first server-shard attempt lost its self-hosted
runner connection; one scoped rerun passed. Its original annotation and
unavailable-log evidence are retained.

## Risks

- This changes execution authority. Cleanup requires an audited, exactly
bound pending Stop, an unfinished coordinator, a live remote
reattachment claim, and the original held reservation. It never
authorizes a replacement provider turn.
- Missing checkpoint, original held reservation, termination, or usage
evidence can keep cleanup or accounting pending. The repair does not
manufacture acknowledgement, replies, receipts, or zero cost.
- Retain-only intake is an internal trusted constructor option. It
changes no agent tool permission. Ordinary callback requirements and
semantic dispatch remain unchanged.
- No schema, public API, provider profile, or deployment setting changes
are required.

## Model Used

OpenAI Codex, `gpt-6.1-sol`, Extra High reasoning, with source
inspection, code execution, and regression testing. The context-window
capacity is not exposed in this session.

## 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 described the issue in-PR following the bug report template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal
ticket ID
- [x] I have run affected 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 risks
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open findings
- [x] I will address all reviewer comments before merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-02 18:22:40 -06:00

1233 lines
48 KiB
TypeScript

import { readPendingNativeRunStop } from "./native-cancellation-request.js";
import { notifyLiveRunRecovery } from "../live-run-recovery-notifications.js";
import { NATIVE_RETAINED_OWNER_WAIT } from "./native-controller-recovery.js";
import { notifyNativeEnvironmentSettlement } from "../native-environment-settlement-notifications.js";
import { remoteWorkspaceRunReferenceSchema } from "@paperclipai/shared";
import { notifyExternalAgentSettlement } from "../../modules/external-agents/index.js";
import { notifyNativeStatusReassessment } from "../native-status-reassessment-notifications.js";
import { notifyUnsafeWorkspaceExportRunChange } from "../unsafe-workspace-export-notifications.js";
import { notifyCancelledWorkspacePreservation } from "../cancelled-workspace-preservation-notifications.js";
import { notifyInstructionCollection } from "../instruction-collection-notifications.js";
import { notifyExecutionRunWork } from "../execution-control-notifications.js";
import { preserveNativeFailedWorkspace } from "./native-failed-workspace-recovery.js";
import { hasNativeRunnerCheckpoint, loadNativeRunnerCheckpointHeader } from "./native-runner-checkpoint.js";
import { resolveNativeStagedAttachmentRecovery, type NativeStagedAttachmentRecovery } from "./native-staged-attachment-recovery.js";
import { readNativeWorkspaceSyncReference } from "./native-workspace-sync.js";
import { activeNativeExecutionRunIds, NATIVE_PREPARATION_FENCING_VERSION } from "./native-active-runs.js";
import {
PROCESS_IDENTITY_RECORDED,
PROCESS_START_REQUESTED,
recordNativeLocalProcessStop,
} from "../native-local-process-stop.js";
import { randomUUID } from "node:crypto";
import { and, desc, eq, inArray, isNotNull, isNull, lte, ne, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
environmentLeases,
heartbeatRunEvents,
heartbeatRuns,
issues,
nativeRunFinalizations,
} from "@paperclipai/db";
import { REMOTE_CONTROLLER_LEASE_MS } from "./native-runner-recovery-contract.js";
import { readProcessStartedAt } from "../hot-restart.js";
import { getServerProcessStartedAt } from "../../server-info.js";
import { redactSensitiveText } from "../../redaction.js";
import { issueRecoveryActionService } from "../issue-recovery-actions.js";
import { isNewTerminalRunFailure, reportRunFailure } from "../run-failure-report.js";
import { isNativeRunnerOwnershipHeld } from "./native-runner-ownership.js";
import { nativeRestartInterruptedTurnId, type PersistedNativeSession } from "../../vendor/paperclip-runner/index.js";
import { retireSupersededNativeRetry } from "./native-superseded-retry.js";
import { requestNativeStartupCleanup, readNativeStartupContext, promoteNativeStartupCleanup } from "./native-startup-cleanup.js";
export type NativeControllerIdentity = {
bootId: string;
pid: number;
processStartedAt: Date;
};
export type NativeRestartKind = "hot" | "hard" | "graceful";
export type NativeRestartRecoveryClaim =
| {
kind: "reattach_existing_runner";
runId: string;
leaseOwner: string;
controllerGeneration: number;
providerAttempt: number;
restartKind: NativeRestartKind;
recoveryRequestId: string | null;
process: {
pid: number;
processGroupId: number | null;
startedAt: string;
};
}
| {
kind: "reattach_remote_runner";
runId: string;
leaseOwner: string;
controllerGeneration: number;
providerAttempt: number;
restartKind: NativeRestartKind;
recoveryRequestId: string | null;
remote: { kind?: "sandbox"; providerLeaseId: string; remoteCwd: string }
| { kind: "plugin_task"; leaseId: string };
/** Discovery evidence only; executor revalidates the encrypted header. */
stagedAttachment?: Pick<NativeStagedAttachmentRecovery, "sourceIdentity" | "targetIdentity" | "templateDigest">;
}
| {
kind: "resume_dead_runner";
runId: string;
leaseOwner: string;
controllerGeneration: number;
providerAttempt: number;
restartKind: NativeRestartKind;
recoveryRequestId: string | null;
}
| {
kind: "bootstrap_incomplete";
runId: string;
leaseOwner: string;
controllerGeneration: number;
providerAttempt: number;
restartKind: NativeRestartKind;
recoveryRequestId: string | null;
};
export type NativeRestartRecoveryDisposition =
| NativeRestartRecoveryClaim
| {
kind: "awaiting_evidence" | "blocked" | "already_finalized";
runId: string;
reason: string;
retryAt?: Date;
};
export function nextNativeProviderAttempt(
currentAttempt: number,
recoveryKind?: NativeRestartRecoveryClaim["kind"],
): number {
return recoveryKind === "reattach_existing_runner" ||
recoveryKind === "reattach_remote_runner"
? currentAttempt
: currentAttempt + 1;
}
const controllerBootId = randomUUID();
function processIsAlive(pid: number | null | undefined): boolean {
if (!pid || !Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException | undefined)?.code === "EPERM";
}
}
function processGroupIsAlive(
processGroupId: number | null | undefined,
): boolean {
if (
process.platform === "win32" ||
!processGroupId ||
!Number.isInteger(processGroupId) ||
processGroupId <= 0
) {
return false;
}
try {
process.kill(-processGroupId, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException | undefined)?.code === "EPERM";
}
}
async function observedProcessStart(pid: number): Promise<Date | null> {
try {
const startedAt = await readProcessStartedAt(pid);
if (!startedAt) return null;
const parsed = new Date(startedAt);
return Number.isNaN(parsed.getTime()) ? null : parsed;
} catch {
return null;
}
}
export async function currentNativeControllerIdentity(): Promise<NativeControllerIdentity> {
const observed = await observedProcessStart(process.pid);
const fallback = new Date(getServerProcessStartedAt());
return {
bootId: controllerBootId,
pid: process.pid,
processStartedAt:
observed ?? (Number.isNaN(fallback.getTime()) ? new Date() : fallback),
};
}
function sameProcessStart(left: Date | null, right: Date | null): boolean {
return left !== null && right !== null && left.getTime() === right.getTime();
}
function definitivelyDifferentProcessStart(
left: Date | null,
right: Date | null,
): boolean {
if (left === null || right === null) return false;
// `ps -o lstart` has only whole-second precision on macOS and BSD. A
// sub-second disagreement can therefore be the same process when one probe
// fell back to a higher-precision spawn timestamp. Treat it as ambiguous and
// fail closed instead of declaring the live PID recycled.
return Math.abs(left.getTime() - right.getTime()) >= 1_000;
}
export async function evaluateNativeControllerTakeover(input: {
owner: Pick<
typeof nativeRunFinalizations.$inferSelect,
| "leaseOwner"
| "leaseExpiresAt"
| "controllerPid"
| "controllerProcessStartedAt"
>;
now: Date;
coordinatedPreviousController?: {
pid: number;
processStartedAt: Date | null;
} | null;
isProcessAlive?: (pid: number) => boolean;
readProcessStartedAt?: (pid: number) => Promise<Date | null>;
requireStoppedController?: boolean;
remoteLeaseFenced?: boolean;
}): Promise<{ allowed: boolean; reason: string }> {
const { owner, now } = input;
if (input.remoteLeaseFenced) {
return owner.leaseExpiresAt && owner.leaseExpiresAt > now
? { allowed: false, reason: "remote_controller_lease_live" }
: { allowed: true, reason: "remote_controller_lease_released_or_expired" };
}
const isAlive = input.isProcessAlive ?? processIsAlive;
const readStartedAt = input.readProcessStartedAt ?? observedProcessStart;
if ((!owner.leaseOwner || !owner.leaseExpiresAt) && !input.requireStoppedController) {
return { allowed: true, reason: "unowned" };
}
const priorPid = owner.controllerPid;
if (!priorPid) {
return {
allowed: false,
reason:
owner.leaseExpiresAt !== null && owner.leaseExpiresAt <= now
? "expired_lease_without_controller_identity"
: "live_legacy_lease_without_controller_identity",
};
}
if (!isAlive(priorPid)) {
return {
allowed: true,
reason:
owner.leaseExpiresAt !== null && owner.leaseExpiresAt <= now
? "expired_lease_controller_process_dead"
: "controller_process_dead",
};
}
const observedStartedAt = await readStartedAt(priorPid);
if (
owner.controllerProcessStartedAt &&
observedStartedAt &&
definitivelyDifferentProcessStart(
owner.controllerProcessStartedAt,
observedStartedAt,
)
) {
return { allowed: true, reason: "controller_pid_recycled" };
}
const coordinated = input.coordinatedPreviousController;
if (
coordinated &&
coordinated.pid === priorPid &&
coordinated.processStartedAt &&
owner.controllerProcessStartedAt &&
sameProcessStart(
coordinated.processStartedAt,
owner.controllerProcessStartedAt,
)
) {
return { allowed: false, reason: "coordinated_controller_still_alive" };
}
return { allowed: false, reason: "controller_still_alive" };
}
export type NativeProviderProcessIdentity = {
pid: number;
processStartedAt: Date | null;
};
export async function evaluateNativeProviderProcesses(input: {
identities: NativeProviderProcessIdentity[];
isProcessAlive?: (pid: number) => boolean;
readProcessStartedAt?: (pid: number) => Promise<Date | null>;
}): Promise<{
knownPids: number[];
livePids: number[];
ambiguousLivePids: number[];
recycledPids: number[];
}> {
const isAlive = input.isProcessAlive ?? processIsAlive;
const readStartedAt = input.readProcessStartedAt ?? observedProcessStart;
const expectedStarts = new Map<number, Date | null>();
for (const identity of input.identities) {
const current = expectedStarts.get(identity.pid);
if (
current === undefined ||
(current === null && identity.processStartedAt)
) {
expectedStarts.set(identity.pid, identity.processStartedAt);
}
}
const livePids: number[] = [];
const ambiguousLivePids: number[] = [];
const recycledPids: number[] = [];
for (const [pid, expectedStartedAt] of expectedStarts) {
if (!isAlive(pid)) continue;
const observedStartedAt = await readStartedAt(pid);
if (!expectedStartedAt || !observedStartedAt) {
ambiguousLivePids.push(pid);
} else if (sameProcessStart(expectedStartedAt, observedStartedAt)) {
livePids.push(pid);
} else if (
definitivelyDifferentProcessStart(expectedStartedAt, observedStartedAt)
) {
recycledPids.push(pid);
} else {
ambiguousLivePids.push(pid);
}
}
return {
knownPids: [...expectedStarts.keys()],
livePids,
ambiguousLivePids,
recycledPids,
};
}
export function classifyNativeRunnerRecoveryEvidence(input: {
runnerPidAlive: boolean;
runnerGroupAlive: boolean;
remoteSandbox?: boolean;
unclaimedBootstrap?: boolean;
processStartMatches: boolean;
knownProviderProcessAlive?: boolean;
knownProviderProcessIdentityAmbiguous?: boolean;
hasCheckpoint: boolean;
checkpointIdentityMatches?: boolean;
hasProviderEvidence: boolean;
checkpointFailed?: boolean;
stagedAttachmentVerified?: boolean;
providerAttempt?: number;
}): {
claimKind: NativeRestartRecoveryClaim["kind"] | null;
reason: string;
} {
if (input.checkpointFailed)
return {
claimKind: null,
reason: "provider_checkpoint_permanently_failed",
};
// Sandbox PIDs belong to another host. This claim only authorizes remote
// inspection; the executor must prove the original lease and authenticate
// the surviving runner before it can resume delivery.
if (input.remoteSandbox) {
if (input.stagedAttachmentVerified === true) {
return { claimKind: "reattach_remote_runner", reason: "staged_remote_attachment_requires_sandbox_verification" };
}
if (input.unclaimedBootstrap === true && !input.hasCheckpoint && !input.hasProviderEvidence) {
return { claimKind: "bootstrap_incomplete", reason: "remote_execution_never_claimed" };
}
return input.hasCheckpoint &&
input.checkpointIdentityMatches === true &&
input.hasProviderEvidence
? {
claimKind: "reattach_remote_runner",
reason: "remote_runner_requires_sandbox_verification",
}
: { claimKind: null, reason: "ambiguous_remote_recovery_evidence" };
}
if (input.runnerPidAlive && input.processStartMatches) {
return {
claimKind: "reattach_existing_runner",
reason: "runner_process_identity_verified",
};
}
if (input.runnerPidAlive || input.runnerGroupAlive) {
return {
claimKind: null,
reason: input.runnerPidAlive
? "live_runner_identity_mismatch"
: "live_runner_process_group_without_exact_pid",
};
}
if (input.knownProviderProcessAlive) {
return {
claimKind: null,
reason: "live_provider_process_without_runner_authority",
};
}
if (input.knownProviderProcessIdentityAmbiguous) {
return {
claimKind: null,
reason: "live_provider_process_identity_unverifiable",
};
}
if ((input.providerAttempt ?? 0) >= 3)
return { claimKind: null, reason: "execution_recovery_budget_exhausted" };
const checkpointIdentityMatches =
input.checkpointIdentityMatches ?? input.hasCheckpoint;
if (
input.hasCheckpoint &&
checkpointIdentityMatches &&
input.hasProviderEvidence
) {
return {
claimKind: "resume_dead_runner",
reason: "dead_runner_with_exact_provider_checkpoint",
};
}
if (!input.hasCheckpoint && !input.hasProviderEvidence) {
return {
claimKind: "bootstrap_incomplete",
reason: "dead_runner_before_provider_identity",
};
}
return { claimKind: null, reason: "ambiguous_provider_recovery_evidence" };
}
function historyEntry(input: {
now: Date;
restartKind: NativeRestartKind;
controller: NativeControllerIdentity;
generation: number;
disposition: string;
reason: string;
requestId: string | null;
providerAttempt: number;
processPid: number | null;
processStartedAt: Date | null;
hasCheckpoint: boolean;
checkpointIdentityMatches?: boolean;
hasProviderEvidence: boolean;
knownProviderPids?: number[];
liveProviderPids?: number[];
ambiguousProviderPids?: number[];
recycledProviderPids?: number[];
stderrTail?: string | null;
}) {
return {
at: input.now.toISOString(),
restartKind: input.restartKind,
controllerBootId: input.controller.bootId,
controllerPid: input.controller.pid,
controllerProcessStartedAt: input.controller.processStartedAt.toISOString(),
controllerGeneration: input.generation,
disposition: input.disposition,
reason: input.reason,
stateRootAction:
input.disposition === "reattach_existing_runner" ||
input.disposition === "reattach_remote_runner"
? "reopen_exact_root"
: input.disposition === "resume_dead_runner"
? "reuse_exact_root"
: input.disposition === "bootstrap_incomplete"
? "quarantine_incomplete_root_then_bootstrap"
: input.disposition === "awaiting_evidence"
? "preserve_awaiting_evidence"
: "preserve_fail_closed",
recoveryRequestId: input.requestId,
providerAttempt: input.providerAttempt,
processPid: input.processPid,
processStartedAt: input.processStartedAt?.toISOString() ?? null,
hasCheckpoint: input.hasCheckpoint,
checkpointIdentityMatches: input.checkpointIdentityMatches ?? false,
hasProviderEvidence: input.hasProviderEvidence,
knownProviderPids: input.knownProviderPids ?? [],
liveProviderPids: input.liveProviderPids ?? [],
ambiguousProviderPids: input.ambiguousProviderPids ?? [],
recycledProviderPids: input.recycledProviderPids ?? [],
stderrTail:
input.stderrTail && input.stderrTail.trim()
? redactSensitiveText(input.stderrTail).slice(-4_096)
: null,
} satisfies Record<string, unknown>;
}
function appendBoundedRecoveryHistory(entry: Record<string, unknown>) {
const encoded = JSON.stringify(entry);
return sql`(
select coalesce(jsonb_agg(item order by ordinal), '[]'::jsonb)
from jsonb_array_elements(
coalesce(${nativeRunFinalizations.recoveryHistory}, '[]'::jsonb)
|| jsonb_build_array(${encoded}::jsonb)
) with ordinality as history(item, ordinal)
where ordinal > greatest(
jsonb_array_length(
coalesce(${nativeRunFinalizations.recoveryHistory}, '[]'::jsonb)
|| jsonb_build_array(${encoded}::jsonb)
) - 20,
0
)
)`;
}
/**
* Claims abandoned local runner executions without changing the provider retry
* attempt. The transaction locks the run, coordinator, and issue execution
* owner so two successor servers cannot both reconstruct the same authority.
*/
export async function claimNativeRestartRecoveries(input: {
db: Db;
controller?: NativeControllerIdentity;
restartKind: NativeRestartKind;
recoveryRequestId?: string | null;
runIds?: string[];
liveRunExecutions?: Pick<ReadonlySet<string>, "has">;
now?: Date;
limit?: number;
/** Called after each committed claim, before scanning the next candidate. */
onClaim?: (claim: NativeRestartRecoveryClaim, scope: {
companyId: string; issueId: string; controller: NativeControllerIdentity; remote: boolean;
}) => void;
coordinatedPreviousController?: {
pid: number;
processStartedAt: Date | null;
} | null;
}): Promise<NativeRestartRecoveryDisposition[]> {
const now = input.now ?? new Date();
const controller =
input.controller ?? (await currentNativeControllerIdentity());
const candidateQuery = input.db
.select({
runId: heartbeatRuns.id,
issueId: nativeRunFinalizations.issueId,
})
.from(heartbeatRuns)
.innerJoin(agents, eq(agents.id, heartbeatRuns.agentId))
.innerJoin(
nativeRunFinalizations,
eq(nativeRunFinalizations.runId, heartbeatRuns.id),
)
.where(
and(
eq(heartbeatRuns.runtimeMode, "native"),
eq(agents.adapterType, "paperclip_runner"),
or(inArray(heartbeatRuns.status, ["running", "failed"]), and(
eq(heartbeatRuns.status, "interrupted"), eq(nativeRunFinalizations.phase, "observed"),
sql`${heartbeatRuns.resultJson}->'nativeCancellation'->>'dispatchState' = 'pending'`,
)),
isNull(nativeRunFinalizations.resultId),
// A pre-provider retained-owner wait belongs to normal same-run
// resumption, including when its deadline elapsed while we were down.
or(isNull(nativeRunFinalizations.failureCode), ne(nativeRunFinalizations.failureCode, NATIVE_RETAINED_OWNER_WAIT)),
or(
eq(nativeRunFinalizations.phase, "observed"),
and(
eq(nativeRunFinalizations.phase, "retryable_failure"),
or(
isNull(nativeRunFinalizations.nextAttemptAt),
lte(nativeRunFinalizations.nextAttemptAt, now),
),
),
),
...(input.runIds?.length
? [inArray(heartbeatRuns.id, input.runIds)]
: []),
),
)
.orderBy(heartbeatRuns.id);
const candidates = await (input.limit === undefined
? candidateQuery
: candidateQuery.limit(input.limit));
const dispositions: NativeRestartRecoveryDisposition[] = [];
for (const candidate of candidates) {
const now = input.now ?? new Date();
let claimScope: { companyId: string; issueId: string; controller: NativeControllerIdentity; remote: boolean } | undefined;
// Set inside the transaction only when the write below genuinely
// transitions the run into "failed". Read after the transaction
// commits, so a rolled-back write never reports a false failure.
let terminalRunToReport: typeof heartbeatRuns.$inferSelect | null = null;
const disposition = await input.db.transaction(async (tx) => {
await tx.execute(
sql`select set_config('statement_timeout', '15000', true), set_config('lock_timeout', '1000', true)`,
);
const busy = () => ({ kind: "awaiting_evidence" as const, runId: candidate.runId,
reason: "recovery_rows_busy", retryAt: new Date(now.getTime() + 1_000) });
// During overlap the serving controller can be committing this run.
// Defer that concrete recovery obligation instead of failing startup
// or waiting while holding a lock the serving controller needs.
const [issue] = await tx
.select({ id: issues.id })
.from(issues)
.where(eq(issues.id, candidate.issueId))
.for("update", { skipLocked: true });
if (!issue) return busy();
const row = await tx
.select({
run: heartbeatRuns,
coordinator: nativeRunFinalizations,
issueExecutionRunId: issues.executionRunId,
issueAssigneeAgentId: issues.assigneeAgentId,
issueStatus: issues.status,
})
.from(heartbeatRuns)
.innerJoin(
nativeRunFinalizations,
eq(nativeRunFinalizations.runId, heartbeatRuns.id),
)
.innerJoin(
issues,
and(
eq(issues.id, nativeRunFinalizations.issueId),
eq(issues.companyId, nativeRunFinalizations.companyId),
),
)
.where(eq(heartbeatRuns.id, candidate.runId))
.for("update", { skipLocked: true })
.limit(1)
.then((rows) => rows[0] ?? null);
if (!row) {
return busy();
}
const stopCleanup = row.coordinator.phase === "observed"
&& row.coordinator.companyId === row.run.companyId && row.coordinator.issueId === row.run.nativeIssueId
&& await readPendingNativeRunStop(tx as unknown as Db, row.run);
if (!["running", "failed"].includes(row.run.status) && !(row.run.status === "interrupted" && stopCleanup)) {
return { kind: "blocked", runId: row.run.id, reason: "run_no_longer_recoverable" } as const;
}
if (isNativeRunnerOwnershipHeld(row.run)) {
return {
kind: "blocked",
runId: row.run.id,
reason: "native_execution_ownership_unverified",
} as const;
}
if (row.coordinator.resultId) {
return {
kind: "already_finalized",
runId: row.run.id,
reason: "result_already_persisted",
} as const;
}
if (!input.liveRunExecutions?.has(row.run.id) && await retireSupersededNativeRetry(tx as unknown as Db, {
run: row.run, coordinator: row.coordinator, issueExecutionRunId: row.issueExecutionRunId, now,
})) return { kind: "blocked", runId: row.run.id, reason: "native_retry_superseded" } as const;
if (row.issueExecutionRunId !== row.run.id && !(stopCleanup && row.issueExecutionRunId === null)) {
return {
kind: "blocked",
runId: row.run.id,
reason: "issue_execution_lock_changed",
} as const;
}
const startupContext = readNativeStartupContext(row.run, row.coordinator);
const startupRetryAt = startupContext ? new Date(Math.max(now.getTime() + 30_000,
startupContext.deadlineAtMs, row.coordinator.leaseExpiresAt?.getTime() ?? 0)) : undefined;
if (input.liveRunExecutions?.has(row.run.id)) {
return {
kind: "awaiting_evidence",
runId: row.run.id,
reason: "current_controller_preparing",
...(startupRetryAt ? { retryAt: startupRetryAt } : {}),
} as const;
}
if (startupContext) {
if (await promoteNativeStartupCleanup(tx as unknown as Db, row.run, row.coordinator, now)) {
return { kind: "blocked", runId: row.run.id, reason: "native_startup_cleanup_pending" } as const;
}
return { kind: "awaiting_evidence", runId: row.run.id, reason: "native_startup_cleanup_pending", retryAt: startupRetryAt } as const;
}
const profile = row.run.runnerProfileJson ?? {};
const storedRemoteWorkspace = remoteWorkspaceRunReferenceSchema.safeParse(profile.remoteWorkspace);
const remoteWorkspace = readNativeWorkspaceSyncReference(profile.nativeWorkspaceSync)
?? (storedRemoteWorkspace.success ? storedRemoteWorkspace.data : null);
const [pluginTask] = await tx.select({ id: environmentLeases.id, metadata: environmentLeases.metadata }).from(environmentLeases).where(and(
eq(environmentLeases.companyId, row.run.companyId), eq(environmentLeases.heartbeatRunId, row.run.id),
sql`${environmentLeases.metadata}->>'runnerTaskLease' = 'true'`,
)).orderBy(desc(environmentLeases.createdAt)).limit(1);
const startup = pluginTask?.metadata?.runnerTaskStartup as { contextId?: unknown; deadlineAtMs?: unknown; runId?: unknown } | undefined;
if (startup?.runId === row.run.id && typeof startup.contextId === "string" &&
Number.isSafeInteger(startup.deadlineAtMs) && Number(startup.deadlineAtMs) <= now.getTime() &&
!row.run.sessionIdAfter && row.coordinator.leaseOwner && row.coordinator.leaseExpiresAt && row.coordinator.leaseExpiresAt <= now) {
await requestNativeStartupCleanup(tx as unknown as Db, { companyId: row.run.companyId, agentId: row.run.agentId,
runId: row.run.id, contextId: startup.contextId, deadlineAtMs: Number(startup.deadlineAtMs),
leaseOwner: row.coordinator.leaseOwner, requiresPreparationStop: true, leaseId: pluginTask!.id }, { publish: false });
return { kind: "blocked", runId: row.run.id, reason: "native_startup_cleanup_pending" } as const;
}
const remoteSandbox = profile.nativeWorkspaceSync != null || profile.remoteWorkspace != null;
const remoteProcess = remoteSandbox || !!pluginTask;
const durableAuthority = (remoteWorkspace || pluginTask) && await hasNativeRunnerCheckpoint(tx, {
companyId: row.run.companyId, issueId: row.coordinator.issueId, runId: row.run.id,
});
const takeover = await evaluateNativeControllerTakeover({
owner: row.coordinator,
now,
remoteLeaseFenced: Boolean(durableAuthority),
coordinatedPreviousController:
input.coordinatedPreviousController ?? null,
});
if (!takeover.allowed) {
const event = historyEntry({
now,
restartKind: input.restartKind,
controller,
generation: row.coordinator.controllerGeneration,
disposition: "awaiting_evidence",
reason: takeover.reason,
requestId: input.recoveryRequestId ?? null,
providerAttempt: row.coordinator.attempt,
processPid: row.run.processPid,
processStartedAt: row.run.processStartedAt,
hasCheckpoint: false,
hasProviderEvidence: false,
stderrTail: row.run.stderrExcerpt,
});
await notifyLiveRunRecovery(tx, { companyId: row.run.companyId, runId: row.run.id, agentId: row.run.agentId });
await tx
.update(nativeRunFinalizations)
.set({
recoveryState: "awaiting_evidence",
recoveryRequestId: input.recoveryRequestId ?? null,
recoveryHistory: appendBoundedRecoveryHistory(event),
updatedAt: now,
})
.where(
and(
eq(nativeRunFinalizations.runId, row.run.id),
eq(nativeRunFinalizations.attempt, row.coordinator.attempt),
eq(nativeRunFinalizations.phase, row.coordinator.phase),
),
);
return {
kind: "awaiting_evidence",
runId: row.run.id,
reason: takeover.reason,
...(durableAuthority && row.coordinator.leaseExpiresAt ? { retryAt: row.coordinator.leaseExpiresAt } : {}),
} as const;
}
// Only the classic remote path stages attachments. After the owner is
// eligible for takeover, inspect its bounded header even if activation
// already published a runtime snapshot. Never scan command/event payloads.
const stagedHeader = remoteWorkspace && !pluginTask && durableAuthority
? await loadNativeRunnerCheckpointHeader(tx, {
companyId: row.run.companyId, issueId: row.coordinator.issueId, runId: row.run.id,
}) : null;
const stagedAttachment = stagedHeader ? await resolveNativeStagedAttachmentRecovery({
db: tx, currentRun: row.run, header: stagedHeader,
}) : null;
const runnerPidAlive =
!remoteProcess && processIsAlive(row.run.processPid);
const runnerGroupAlive =
!remoteProcess && processGroupIsAlive(row.run.processGroupId);
const observedRunnerStart =
row.run.processPid && runnerPidAlive
? await observedProcessStart(row.run.processPid)
: null;
const exactRunnerIdentity =
runnerPidAlive &&
row.run.processPid !== null &&
sameProcessStart(row.run.processStartedAt, observedRunnerStart);
const checkpoint = profile.sessionCheckpoint;
const checkpointRecord =
checkpoint &&
typeof checkpoint === "object" &&
!Array.isArray(checkpoint)
? (checkpoint as Record<string, unknown>)
: {};
const hasCheckpoint = checkpoint !== undefined && checkpoint !== null;
const checkpointIdentity =
checkpointRecord.identity &&
typeof checkpointRecord.identity === "object" &&
!Array.isArray(checkpointRecord.identity)
? (checkpointRecord.identity as Record<string, unknown>)
: {};
const checkpointIdentityMatches =
hasCheckpoint &&
typeof row.run.nativeSessionId === "string" &&
row.run.nativeSessionId.length > 0 &&
checkpointIdentity.runId === row.run.id &&
checkpointIdentity.companyId === row.run.companyId &&
checkpointIdentity.issueId === row.coordinator.issueId &&
checkpointIdentity.agentId === row.run.agentId &&
checkpointIdentity.sessionId === row.run.nativeSessionId;
const hasCheckpointProviderIdentity =
(typeof checkpointRecord.providerSessionId === "string" &&
checkpointRecord.providerSessionId.length > 0) ||
(checkpointRecord.providerIdentity !== null &&
typeof checkpointRecord.providerIdentity === "object" &&
!Array.isArray(checkpointRecord.providerIdentity) &&
Object.keys(checkpointRecord.providerIdentity).length > 0);
const providerEvents = await tx
.select({
id: heartbeatRunEvents.id,
payload: heartbeatRunEvents.payload,
})
.from(heartbeatRunEvents)
.where(
and(
eq(heartbeatRunEvents.runId, row.run.id),
inArray(heartbeatRunEvents.eventType, [
"harness.ready",
"session.started",
"session.resumed",
"session.updated",
"turn.started",
"provider.event",
"provider.rpc_result",
]),
),
)
.orderBy(desc(heartbeatRunEvents.id))
.limit(100);
const checkpointProcess =
checkpointRecord.process &&
typeof checkpointRecord.process === "object" &&
!Array.isArray(checkpointRecord.process)
? (checkpointRecord.process as Record<string, unknown>)
: {};
const providerProcessIdentities: NativeProviderProcessIdentity[] = [];
const checkpointProcessFields = [
["providerPid", "providerProcessStartedAt"],
["codexPid", "codexProcessStartedAt"],
["sidecarPid", "sidecarProcessStartedAt"],
["agentPid", "agentProcessStartedAt"],
] as const;
for (const [field, startedAtField] of checkpointProcessFields) {
const value = checkpointProcess[field];
if (typeof value === "number" && Number.isInteger(value) && value > 0) {
const rawStartedAt = checkpointProcess[startedAtField];
const parsedStartedAt =
typeof rawStartedAt === "string" ? new Date(rawStartedAt) : null;
providerProcessIdentities.push({
pid: value,
processStartedAt:
parsedStartedAt && !Number.isNaN(parsedStartedAt.getTime())
? parsedStartedAt
: null,
});
}
}
for (const providerEvent of providerEvents) {
const prpEvent = providerEvent.payload?.prpEvent;
const event =
prpEvent && typeof prpEvent === "object" && !Array.isArray(prpEvent)
? (prpEvent as Record<string, unknown>)
: {};
const eventPayload =
event.payload &&
typeof event.payload === "object" &&
!Array.isArray(event.payload)
? (event.payload as Record<string, unknown>)
: {};
const processId = eventPayload.processId;
if (
typeof processId === "number" &&
Number.isInteger(processId) &&
processId > 0
) {
const rawStartedAt =
eventPayload.processStartedAt ??
eventPayload.providerProcessStartedAt;
const parsedStartedAt =
typeof rawStartedAt === "string" ? new Date(rawStartedAt) : null;
providerProcessIdentities.push({
pid: processId,
processStartedAt:
parsedStartedAt && !Number.isNaN(parsedStartedAt.getTime())
? parsedStartedAt
: null,
});
}
}
const providerProcesses = await evaluateNativeProviderProcesses({
identities: (remoteProcess ? [] : providerProcessIdentities).filter(
(identity) => identity.pid !== row.run.processPid,
),
});
const hasProviderEvidence =
hasCheckpointProviderIdentity || providerEvents.length > 0;
let unclaimedBootstrap =
remoteWorkspace?.state === "prepared" &&
row.run.runnerProfileJson?.nativePreparationFencingVersion === NATIVE_PREPARATION_FENCING_VERSION &&
row.coordinator.phase === "observed" &&
row.coordinator.attempt === 0 &&
row.coordinator.controllerGeneration === 0 &&
!row.coordinator.controllerBootId &&
!row.coordinator.controllerPid &&
!row.coordinator.controllerProcessStartedAt &&
!row.coordinator.leaseOwner &&
!row.coordinator.leaseExpiresAt &&
!row.coordinator.failureDetail?.successorRunId &&
!row.run.processPid &&
!row.run.processGroupId &&
!row.run.processStartedAt &&
!row.run.sessionIdBefore &&
!row.run.sessionIdAfter &&
Boolean(row.run.nativeSessionId && row.run.runnerInstanceId) &&
!hasCheckpoint &&
!hasProviderEvidence;
if (unclaimedBootstrap) {
if (activeNativeExecutionRunIds.has(row.run.id) &&
controller.bootId === (await currentNativeControllerIdentity()).bootId) {
return { kind: "awaiting_evidence", runId: row.run.id, reason: "current_controller_preparing" } as const;
}
const [executionEvent] = await tx
.select({ id: heartbeatRunEvents.id })
.from(heartbeatRunEvents)
.where(and(
eq(heartbeatRunEvents.companyId, row.run.companyId),
eq(heartbeatRunEvents.runId, row.run.id),
or(
isNotNull(heartbeatRunEvents.sourceEventId),
inArray(heartbeatRunEvents.eventType, [
"adapter.invoke",
PROCESS_START_REQUESTED,
PROCESS_IDENTITY_RECORDED,
"tool.execution.started",
]),
),
))
.limit(1);
unclaimedBootstrap = !executionEvent;
}
const classification = classifyNativeRunnerRecoveryEvidence({
remoteSandbox: remoteSandbox || Boolean(pluginTask && durableAuthority),
unclaimedBootstrap,
runnerPidAlive,
runnerGroupAlive,
processStartMatches: exactRunnerIdentity,
knownProviderProcessAlive: providerProcesses.livePids.length > 0,
knownProviderProcessIdentityAmbiguous:
providerProcesses.ambiguousLivePids.length > 0,
hasCheckpoint,
stagedAttachmentVerified: stagedAttachment !== null,
checkpointIdentityMatches:
checkpointIdentityMatches &&
(!remoteSandbox || remoteWorkspace !== null),
hasProviderEvidence,
checkpointFailed:
(checkpointRecord.terminal as Record<string, unknown> | undefined)
?.runTerminalState === "failed" &&
nativeRestartInterruptedTurnId(checkpointRecord as unknown as PersistedNativeSession) === null,
providerAttempt: row.coordinator.attempt,
});
if (stopCleanup && classification.claimKind !== "reattach_remote_runner") {
return { kind: "blocked", runId: row.run.id, reason: "native_stop_requires_existing_remote_session" } as const;
}
const ownershipChanged =
row.issueAssigneeAgentId !== row.run.agentId ||
["done", "cancelled"].includes(row.issueStatus);
const claimKind = ownershipChanged ? null : classification.claimKind;
const reason = ownershipChanged
? "task_ownership_or_status_changed"
: classification.reason;
if (!claimKind) {
const generation = row.coordinator.controllerGeneration;
const event = historyEntry({
now,
restartKind: input.restartKind,
controller,
generation,
disposition: "blocked",
reason,
requestId: input.recoveryRequestId ?? null,
providerAttempt: row.coordinator.attempt,
processPid: row.run.processPid,
processStartedAt: row.run.processStartedAt,
hasCheckpoint,
checkpointIdentityMatches,
hasProviderEvidence,
knownProviderPids: providerProcesses.knownPids,
liveProviderPids: providerProcesses.livePids,
ambiguousProviderPids: providerProcesses.ambiguousLivePids,
recycledProviderPids: providerProcesses.recycledPids,
stderrTail: row.run.stderrExcerpt,
});
await notifyExecutionRunWork(tx, { companyId: row.run.companyId });
await tx
.update(nativeRunFinalizations)
.set({
phase: "terminal_failure",
leaseOwner: null,
leaseExpiresAt: null,
nextAttemptAt: null,
failureCode: "native_restart_recovery_blocked",
failureDetail: {
...row.coordinator.failureDetail,
reason,
nextAction:
"Inspect the preserved checkpoint and reconcile the previous execution before starting a fresh session.",
},
recoveryState: "blocked",
recoveryRequestId: input.recoveryRequestId ?? null,
recoveryHistory: appendBoundedRecoveryHistory(event),
updatedAt: now,
})
.where(
and(
eq(nativeRunFinalizations.runId, row.run.id),
eq(nativeRunFinalizations.attempt, row.coordinator.attempt),
eq(nativeRunFinalizations.phase, row.coordinator.phase),
),
);
await notifyExternalAgentSettlement(tx, { companyId: row.run.companyId, runId: row.run.id });
await notifyNativeEnvironmentSettlement(tx, { companyId: row.run.companyId, runId: row.run.id });
await notifyInstructionCollection(tx, { companyId: row.run.companyId, runId: row.run.id });
await notifyCancelledWorkspacePreservation(tx, { companyId: row.run.companyId, runId: row.run.id });
await notifyUnsafeWorkspaceExportRunChange(tx, row.run);
const [updatedRun] = await tx
.update(heartbeatRuns)
.set({
status: "failed",
nativePhase: "terminal_failure",
nativePhaseUpdatedAt: now,
executionStatusDeliveryId: randomUUID(),
finishedAt: now,
errorCode: "native_restart_recovery_blocked",
error: reason,
updatedAt: now,
})
.where(eq(heartbeatRuns.id, row.run.id))
.returning();
if (updatedRun && isNewTerminalRunFailure(row.run, updatedRun)) {
terminalRunToReport = updatedRun;
}
await notifyNativeStatusReassessment(tx, { companyId: row.run.companyId, issueId: row.coordinator.issueId });
await tx
.update(issues)
.set({ executionRunId: null, updatedAt: now })
.where(
and(
eq(issues.id, row.coordinator.issueId),
eq(issues.companyId, row.run.companyId),
eq(issues.executionRunId, row.run.id),
),
);
await tx
.update(issues)
.set({ checkoutRunId: null, updatedAt: now })
.where(
and(
eq(issues.id, row.coordinator.issueId),
eq(issues.companyId, row.run.companyId),
eq(issues.checkoutRunId, row.run.id),
),
);
if (
row.issueAssigneeAgentId === row.run.agentId &&
!["done", "cancelled"].includes(row.issueStatus)
)
await issueRecoveryActionService(
tx as unknown as Db,
).upsertSourceScoped({
companyId: row.run.companyId,
sourceIssueId: row.coordinator.issueId,
kind: "active_run_watchdog",
ownerType: "board",
returnOwnerAgentId: row.run.agentId,
cause: "native_restart_recovery_blocked",
fingerprint: `native-restart:${row.run.id}`,
evidence: {
runId: row.run.id,
reason,
providerAttempt: row.coordinator.attempt,
},
nextAction:
"Inspect the preserved checkpoint and reconcile the previous execution before starting a fresh session.",
maxAttempts: 3,
wakePolicy: null,
supersedeOnIdentityChange: true,
});
if (updatedRun) await preserveNativeFailedWorkspace(tx as unknown as Db, updatedRun);
return { kind: "blocked", runId: row.run.id, reason } as const;
}
const generation = row.coordinator.controllerGeneration + 1;
const leaseOwner = `${controller.bootId}:${generation}:${randomUUID()}`;
const event = historyEntry({
now,
restartKind: input.restartKind,
controller,
generation,
disposition: claimKind,
reason,
requestId: input.recoveryRequestId ?? null,
providerAttempt: row.coordinator.attempt,
processPid: row.run.processPid,
processStartedAt: row.run.processStartedAt,
hasCheckpoint,
checkpointIdentityMatches,
hasProviderEvidence,
knownProviderPids: providerProcesses.knownPids,
liveProviderPids: providerProcesses.livePids,
ambiguousProviderPids: providerProcesses.ambiguousLivePids,
recycledProviderPids: providerProcesses.recycledPids,
stderrTail: row.run.stderrExcerpt,
});
await notifyLiveRunRecovery(tx, { companyId: row.run.companyId, runId: row.run.id, agentId: row.run.agentId });
const claimed = await tx
.update(nativeRunFinalizations)
.set({
phase: "observed",
leaseOwner,
// Cleanup and earlier candidates can outlive the batch observation.
// The owner row is locked: grant the full lease at this write.
leaseExpiresAt: sql`clock_timestamp() + ${durableAuthority ? REMOTE_CONTROLLER_LEASE_MS : 20 * 60_000} * interval '1 millisecond'`,
controllerBootId: controller.bootId,
controllerPid: controller.pid,
controllerProcessStartedAt: controller.processStartedAt,
controllerGeneration: generation,
recoveryState:
claimKind === "reattach_existing_runner" ||
claimKind === "reattach_remote_runner"
? "awaiting_runner_reattach"
: claimKind === "resume_dead_runner"
? "resuming_session"
: "bootstrap_incomplete",
recoveryRequestId: input.recoveryRequestId ?? null,
recoveryHistory: appendBoundedRecoveryHistory(event),
failureCode: null,
nextAttemptAt: null,
updatedAt: now,
})
.where(
and(
eq(nativeRunFinalizations.runId, row.run.id),
eq(nativeRunFinalizations.attempt, row.coordinator.attempt),
eq(nativeRunFinalizations.phase, row.coordinator.phase),
row.coordinator.leaseOwner === null
? isNull(nativeRunFinalizations.leaseOwner)
: eq(
nativeRunFinalizations.leaseOwner,
row.coordinator.leaseOwner,
),
),
)
.returning({ runId: nativeRunFinalizations.runId })
.then((rows) => rows[0] ?? null);
if (!claimed) {
return {
kind: "awaiting_evidence",
runId: row.run.id,
reason: "concurrent_recovery_claim",
} as const;
}
if (
claimKind !== "reattach_existing_runner" &&
claimKind !== "reattach_remote_runner"
) {
await recordNativeLocalProcessStop(tx as unknown as Db, row.run);
}
await tx
.update(heartbeatRuns)
.set({
status: "running",
finishedAt: null,
error: null,
errorCode: null,
nativePhase: "observed",
nativePhaseUpdatedAt: now,
...(claimKind === "reattach_existing_runner" ||
claimKind === "reattach_remote_runner"
? {}
: {
processPid: null,
processGroupId: null,
processStartedAt: null,
}),
updatedAt: now,
})
.where(eq(heartbeatRuns.id, row.run.id));
claimScope = { companyId: row.run.companyId, issueId: row.coordinator.issueId, controller, remote: Boolean(durableAuthority) };
const common = {
runId: row.run.id,
leaseOwner,
controllerGeneration: generation,
providerAttempt: row.coordinator.attempt,
restartKind: input.restartKind,
recoveryRequestId: input.recoveryRequestId ?? null,
};
if (claimKind === "reattach_remote_runner") {
return {
kind: claimKind,
...common,
...(stagedAttachment ? { stagedAttachment: {
sourceIdentity: stagedAttachment.sourceIdentity, targetIdentity: stagedAttachment.targetIdentity,
templateDigest: stagedAttachment.templateDigest,
} } : {}),
remote: pluginTask ? { kind: "plugin_task", leaseId: pluginTask.id } : {
providerLeaseId: remoteWorkspace!.providerLeaseId,
remoteCwd: remoteWorkspace!.remoteCwd,
},
} satisfies NativeRestartRecoveryClaim;
}
if (claimKind === "reattach_existing_runner") {
return {
kind: claimKind,
...common,
process: {
pid: row.run.processPid!,
processGroupId: row.run.processGroupId,
startedAt: row.run.processStartedAt!.toISOString(),
},
} satisfies NativeRestartRecoveryClaim;
}
return {
kind: claimKind,
...common,
} satisfies NativeRestartRecoveryClaim;
});
if (terminalRunToReport)
void reportRunFailure(input.db, terminalRunToReport);
if (claimScope && "leaseOwner" in disposition) input.onClaim?.(disposition, claimScope);
dispositions.push(disposition);
}
return dispositions;
}
export async function nativeRestartRecoverySummary(db: Db) {
const rows = await db
.select({
state: nativeRunFinalizations.recoveryState,
count: sql<number>`count(*)::int`,
})
.from(nativeRunFinalizations)
.where(
inArray(nativeRunFinalizations.recoveryState, [
"awaiting_evidence",
"awaiting_runner_reattach",
"resuming_session",
"bootstrap_incomplete",
"blocked",
]),
)
.groupBy(nativeRunFinalizations.recoveryState);
return Object.fromEntries(
rows.map((row) => [row.state ?? "unknown", row.count]),
);
}